mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-27 19:50:36 +02:00
[yamlcomposer] YAML Composer Add-on for Enhanced YAML Preprocessing (#20305)
* [yamlcomposer] New Yaml Composer add-on Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
This commit is contained in:
@@ -473,6 +473,7 @@
|
||||
/bundles/org.openhab.io.metrics/ @pravussum
|
||||
/bundles/org.openhab.io.neeo/ @morph166955
|
||||
/bundles/org.openhab.io.openhabcloud/ @kaikreuzer
|
||||
/bundles/org.openhab.io.yamlcomposer/ @jimtng
|
||||
/bundles/org.openhab.persistence.dynamodb/ @ssalonen
|
||||
/bundles/org.openhab.persistence.influxdb/ @lujop
|
||||
/bundles/org.openhab.persistence.inmemory/ @J-N-K
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
@@ -0,0 +1,329 @@
|
||||
# YAML Composer
|
||||
|
||||
YAML Composer introduces extended YAML features that make openHAB configuration more modular, reusable, and maintainable. These features let you structure configuration as composable building blocks rather than large, repetitive files.
|
||||
|
||||
The add-on loads enhanced-syntax YAML files from `OPENHAB_CONF/yamlcomposer/` and compiles them into fully resolved plain YAML written to `OPENHAB_CONF/yaml/composed/`.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Feature Summary
|
||||
|
||||
YAML Composer adds several enhancements on top of standard YAML.
|
||||
Each feature addresses a different kind of reuse, composition, or abstraction to help you build cleaner and more maintainable YAML.
|
||||
|
||||
| Feature | Purpose | Typical Use |
|
||||
|--------------------------------------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| **Variables and Substitution (`${..}`)** | Insert dynamic values or evaluate expressions | Build labels, topics, IDs, or computed values |
|
||||
| **Conditionals (`!if`)** | Conditionally include or exclude YAML blocks | Enable or disable features when using packages or template flags |
|
||||
| **Include (`!include`)** | Insert the contents of another file | Reuse YAML across files; parameterize reusable blocks |
|
||||
| **Templates (`!insert`)** | Reuse YAML defined within the same file | Local parameterized blocks; reusable channel or item fragments |
|
||||
| **Packages** | Bundle multiple top-level sections into one reusable unit | Define reusable device structures containing things, items, metadata; sourced from external files or templates |
|
||||
| **Anchors and Aliases (`&name`, `*name`)** | Define small, reusable YAML fragments | Static defaults, shared fields |
|
||||
| **Merge Keys (`<<:`)** | Combine mappings from multiple sources | Layer defaults, override fields, compose structures |
|
||||
|
||||
Each feature has a dedicated documentation page:
|
||||
|
||||
- [Variables and Substitution](doc/variables.md)
|
||||
- [Conditionals](doc/conditionals.md)
|
||||
- [Include](doc/include.md)
|
||||
- [Templates](doc/templates.md)
|
||||
- [Packages](doc/packages.md)
|
||||
- [Anchors and Aliases](doc/anchors.md)
|
||||
- [Merge Keys](doc/merge-keys.md)
|
||||
|
||||
These features can be used independently, but they become especially powerful when combined.
|
||||
|
||||
For a general introduction to YAML, see [YAML Basics](doc/basics.md).
|
||||
|
||||
## Packaging Example
|
||||
|
||||
**CONF/yamlcomposer/LivingRoom.yaml:**
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
|
||||
variables:
|
||||
# Captures "LivingRoom" from the filename automatically
|
||||
location: ${__FILE_NAME__} # => LivingRoom
|
||||
|
||||
packages:
|
||||
Light1: !include
|
||||
file: $pkg/zigbee_light.inc.yaml
|
||||
vars: &LIGHT_VARS
|
||||
color_temperature: {} # Include color temperature feature
|
||||
power: # Customize the power item
|
||||
groups:
|
||||
- gInsideLights
|
||||
- gSmartLights
|
||||
|
||||
Light2: !include
|
||||
file: $pkg/zigbee_light.inc.yaml
|
||||
vars:
|
||||
<<: *LIGHT_VARS
|
||||
```
|
||||
|
||||
**CONF/yamlcomposer/pkg/zigbee_light.inc.yaml:**
|
||||
|
||||
```yaml
|
||||
# This is the package file, i.e. the template for a zigbee light
|
||||
variables:
|
||||
id: ${location}_${package_id}
|
||||
thingid: ${id | lower | replace("_", "-")}
|
||||
equipment: ${id}_Equipment
|
||||
label: ${id | label}
|
||||
|
||||
# Set defaults
|
||||
power: &DEFAULTS
|
||||
groups: []
|
||||
dimmer:
|
||||
<<: *DEFAULTS
|
||||
|
||||
things:
|
||||
mqtt:topic:${thingid}:
|
||||
bridge: mqtt:broker:mosquitto
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/${thingid}/state
|
||||
commandTopic: zigbee2mqtt/${thingid}/set/state
|
||||
|
||||
dimmer:
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/${thingid}/brightness
|
||||
commandTopic: zigbee2mqtt/${thingid}/set/brightness
|
||||
|
||||
<<: !if
|
||||
if: VARS.containsKey('color_temperature')
|
||||
then:
|
||||
color-temperature: !sub
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/${thingid}/color_temp
|
||||
commandTopic: zigbee2mqtt/${thingid}/set/color_temp
|
||||
|
||||
items:
|
||||
${equipment}:
|
||||
type: Group
|
||||
label: ${label} Equipment
|
||||
tags: [Lightbulb]
|
||||
groups: ${[location]}
|
||||
|
||||
${id}:
|
||||
type: Switch
|
||||
label: ${label}
|
||||
tags: [Control, Light]
|
||||
groups: ${[equipment] + power.groups}
|
||||
channel: mqtt:topic:${thingid}:power
|
||||
|
||||
${id}_Dimmer:
|
||||
type: Dimmer
|
||||
label: ${label} Brightness
|
||||
tags: [Control, Level]
|
||||
groups: ${[equipment] + dimmer.groups}
|
||||
channel: mqtt:topic:${thingid}:dimmer
|
||||
|
||||
<<: !if
|
||||
if: VARS.containsKey('color_temperature')
|
||||
then:
|
||||
${id}_CT:
|
||||
type: Dimmer
|
||||
label: ${label} Color Temperature
|
||||
tags: [Control, ColorTemperature]
|
||||
groups: ${[equipment] + color_temperature.groups}
|
||||
channel: mqtt:topic:${thingid}:color-temperature
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Output in CONF/yaml/composed/LivingRoom.yaml:</b></summary>
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
|
||||
things:
|
||||
mqtt:topic:livingroom-light1:
|
||||
bridge: mqtt:broker:mosquitto
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light1/state
|
||||
commandTopic: zigbee2mqtt/livingroom-light1/set/state
|
||||
dimmer:
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light1/brightness
|
||||
commandTopic: zigbee2mqtt/livingroom-light1/set/brightness
|
||||
color-temperature:
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light1/color_temp
|
||||
commandTopic: zigbee2mqtt/livingroom-light1/set/color_temp
|
||||
|
||||
mqtt:topic:livingroom-light2:
|
||||
bridge: mqtt:broker:mosquitto
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light2/state
|
||||
commandTopic: zigbee2mqtt/livingroom-light2/set/state
|
||||
dimmer:
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light2/brightness
|
||||
commandTopic: zigbee2mqtt/livingroom-light2/set/brightness
|
||||
color-temperature:
|
||||
type: dimmer
|
||||
config:
|
||||
stateTopic: zigbee2mqtt/livingroom-light2/color_temp
|
||||
commandTopic: zigbee2mqtt/livingroom-light2/set/color_temp
|
||||
|
||||
items:
|
||||
LivingRoom_Light1_Equipment:
|
||||
type: Group
|
||||
label: Living Room Light 1 Equipment
|
||||
tags:
|
||||
- Lightbulb
|
||||
groups:
|
||||
- LivingRoom
|
||||
|
||||
LivingRoom_Light1:
|
||||
type: Switch
|
||||
label: Living Room Light 1
|
||||
tags:
|
||||
- Control
|
||||
- Light
|
||||
groups:
|
||||
- LivingRoom_Light1_Equipment
|
||||
- gInsideLights
|
||||
- gSmartLights
|
||||
channel: mqtt:topic:livingroom-light1:power
|
||||
|
||||
LivingRoom_Light1_Dimmer:
|
||||
type: Dimmer
|
||||
label: Living Room Light 1 Brightness
|
||||
tags:
|
||||
- Control
|
||||
- Level
|
||||
groups:
|
||||
- LivingRoom_Light1_Equipment
|
||||
channel: mqtt:topic:livingroom-light1:dimmer
|
||||
|
||||
LivingRoom_Light1_CT:
|
||||
type: Dimmer
|
||||
label: Living Room Light 1 Color Temperature
|
||||
tags:
|
||||
- Control
|
||||
- ColorTemperature
|
||||
groups:
|
||||
- LivingRoom_Light1_Equipment
|
||||
channel: mqtt:topic:livingroom-light1:color-temperature
|
||||
|
||||
LivingRoom_Light2_Equipment:
|
||||
type: Group
|
||||
label: Living Room Light 2 Equipment
|
||||
tags:
|
||||
- Lightbulb
|
||||
groups:
|
||||
- LivingRoom
|
||||
|
||||
LivingRoom_Light2:
|
||||
type: Switch
|
||||
label: Living Room Light 2
|
||||
tags:
|
||||
- Control
|
||||
- Light
|
||||
groups:
|
||||
- LivingRoom_Light2_Equipment
|
||||
- gInsideLights
|
||||
- gSmartLights
|
||||
channel: mqtt:topic:livingroom-light2:power
|
||||
|
||||
LivingRoom_Light2_Dimmer:
|
||||
type: Dimmer
|
||||
label: Living Room Light 2 Brightness
|
||||
tags:
|
||||
- Control
|
||||
- Level
|
||||
groups:
|
||||
- LivingRoom_Light2_Equipment
|
||||
channel: mqtt:topic:livingroom-light2:dimmer
|
||||
|
||||
LivingRoom_Light2_CT:
|
||||
type: Dimmer
|
||||
label: Living Room Light 2 Color Temperature
|
||||
tags:
|
||||
- Control
|
||||
- ColorTemperature
|
||||
groups:
|
||||
- LivingRoom_Light2_Equipment
|
||||
channel: mqtt:topic:livingroom-light2:color-temperature
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Processing Overview
|
||||
|
||||
YAML Composer reads enhanced-syntax YAML files and performs a compilation pass that expands all extended features into a single, fully resolved YAML document that openHAB can load.
|
||||
|
||||
During compilation, YAML Composer performs the following steps:
|
||||
|
||||
1. **YAML Parsing**: The source file is parsed into an internal structure.
|
||||
1. **Variable Substitution (`${..}`)**: Expressions are evaluated and injected.
|
||||
1. **Conditionals (`!if`)**: Conditional logic determines which blocks remain.
|
||||
1. **Template and Include Expansion**: `!insert` and `!include` bring in referenced content, often using resolved variables.
|
||||
1. **Package Expansion**: External or local packages are loaded and expanded into their component sections.
|
||||
1. **Recursive Merging**: Merge keys and package structures are combined into the main document.
|
||||
1. **Hidden Key Removal**: Keys beginning with `.` are removed from the final output.
|
||||
|
||||
The resulting YAML contains:
|
||||
|
||||
- all variables resolved
|
||||
- all conditionals evaluated
|
||||
- all templates and includes expanded
|
||||
- all anchors and merges applied
|
||||
- all packages integrated
|
||||
- all hidden keys removed
|
||||
|
||||
This final compiled YAML is written to `OPENHAB_CONF/yaml/composed/`, where openHAB loads it as Things, Items, Metadata, and other configuration elements as defined by the Core YAML Configuration structure.
|
||||
|
||||
## Hidden Keys
|
||||
|
||||
Keys beginning with a dot (`.`) are treated as hidden. They:
|
||||
|
||||
- exist only during compilation
|
||||
- are ideal for storing anchors, templates, or shared structures
|
||||
- keep visible configuration clean
|
||||
- are removed from the final output
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
.base-switch: &BASE_SWITCH
|
||||
type: Switch
|
||||
autoupdate: false
|
||||
|
||||
items:
|
||||
Light1:
|
||||
<<: *BASE_SWITCH
|
||||
label: Light One
|
||||
```
|
||||
|
||||
## File Structure and Conventions
|
||||
|
||||
YAML files can be organized freely, but the following conventions improve clarity and maintainability:
|
||||
|
||||
- Place `variables:` and `templates:` near the top of the file.
|
||||
- Group reusable structures under hidden keys.
|
||||
- Use anchors for static fragments and includes for parameterized ones.
|
||||
- Keep packages in separate files when they represent reusable device or feature definitions.
|
||||
|
||||
These conventions are optional but help keep complex configurations predictable and easy to navigate.
|
||||
|
||||
### File naming convention
|
||||
|
||||
- Use `*.yaml` or `*.yml` for main source files that produce composed output.
|
||||
- Use `*.inc.yaml` or `*.inc.yml` for include fragments referenced by `!include`.
|
||||
- Mixed-role files (both main and include) are not supported.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Anchors & Aliases
|
||||
|
||||
Anchors and aliases provide efficient, in‑file content reuse to reduce repetition without the overhead of external include files or packages.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Core Concepts
|
||||
|
||||
YAML provides two primary symbols for defining reusable content:
|
||||
|
||||
- **Anchor (`&name`)** — Marks a YAML node (a map, list, or scalar) so it can be reused later.
|
||||
- **Alias (`*name`)** — References a previously anchored node and injects its content at the current location.
|
||||
|
||||
::: tip Scope Note
|
||||
Anchors and aliases are **file‑local**.
|
||||
An anchor defined in an included file cannot be referenced from the main file, and vice versa.
|
||||
:::
|
||||
|
||||
### Using Hidden Keys for Anchors
|
||||
|
||||
Hidden keys are a convenient place to store anchored nodes without exposing them in the final configuration.
|
||||
|
||||
For a full explanation of hidden keys, see [Hidden Keys](../#hidden-keys).
|
||||
|
||||
Anchors are often combined with the YAML **merge key** (`<<:`), which inserts the contents of an anchored map into the current map.
|
||||
For full merge‑key semantics, see [Merge Keys](merge-keys.md).
|
||||
|
||||
```yaml
|
||||
.base-switch: &BASE_SWITCH
|
||||
type: Switch
|
||||
autoupdate: false
|
||||
|
||||
items:
|
||||
Light1:
|
||||
<<: *BASE_SWITCH
|
||||
label: Light One
|
||||
```
|
||||
|
||||
Anchors can also be applied to scalar values.
|
||||
This works similarly to using a variable:
|
||||
|
||||
```yaml
|
||||
.bar: &BAR value
|
||||
|
||||
foo: *BAR
|
||||
```
|
||||
|
||||
## When to Use Anchors
|
||||
|
||||
Anchors and aliases are most useful when reusing structural blocks **within the same file**.
|
||||
They work well for:
|
||||
|
||||
- repeating common fields across many entries
|
||||
- sharing channel or configuration structures
|
||||
- keeping small reusable fragments close to where they are used
|
||||
|
||||
Anchors are a YAML‑native mechanism.
|
||||
They participate in variable substitution, but they do not support cross‑file reuse or template‑style evaluation.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Naming** — Use `UPPER_CASE` for anchors to distinguish them from standard keys.
|
||||
- **Standardization** — Use anchors to enforce consistent structure across repeated YAML blocks within a file.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Standard YAML Syntax
|
||||
|
||||
This page introduces the standard YAML syntax used throughout this documentation.
|
||||
It is not a full YAML tutorial.
|
||||
It provides the foundational concepts needed to understand and work with the examples in the YAML Configuration section.
|
||||
|
||||
The YAML Composer builds on top of standard YAML 1.2 and adds its own processing features such as custom tags and preprocessing behavior.
|
||||
This page focuses on the core YAML syntax that those features extend.
|
||||
|
||||
For more detailed information on the YAML 1.2 syntax supported in openHAB configurations, see the [YAML 1.2 specification](https://yaml.org/spec/1.2/).
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Indentation and Structure
|
||||
|
||||
YAML uses indentation to represent structure.
|
||||
|
||||
- Indentation defines the nesting level.
|
||||
- Only spaces are allowed.
|
||||
- Tabs are not permitted for indentation.
|
||||
- All items at the same level must use the same indentation.
|
||||
|
||||
openHAB recommends using **two spaces** per indentation level for consistency.
|
||||
|
||||
```yaml
|
||||
thing:
|
||||
id: myThing
|
||||
label: "Example Thing"
|
||||
```
|
||||
|
||||
Misaligned indentation is one of the most common YAML errors.
|
||||
|
||||
## Block Style vs Flow Style
|
||||
|
||||
YAML supports two ways of expressing mappings and lists.
|
||||
|
||||
### Block Style (Recommended)
|
||||
|
||||
Block style is the most readable form.
|
||||
|
||||
```yaml
|
||||
item:
|
||||
type: Switch
|
||||
label: "Lamp"
|
||||
tags:
|
||||
- Light
|
||||
- LivingRoom
|
||||
```
|
||||
|
||||
### Flow Style
|
||||
|
||||
Flow style is more compact and resembles JSON.
|
||||
It is functionally identical to block style.
|
||||
|
||||
```yaml
|
||||
item: { type: Switch, label: "Lamp", tags: [Light, LivingRoom] }
|
||||
```
|
||||
|
||||
YAML allows block and flow styles to be mixed freely.
|
||||
|
||||
## Comments
|
||||
|
||||
- Comments begin with `#`.
|
||||
- Comments continue to the end of the line.
|
||||
- Comments may appear on their own line or after a value.
|
||||
|
||||
```yaml
|
||||
# This is a comment describing the thing
|
||||
thing:
|
||||
id: myThing # Inline comment
|
||||
label: "Example Thing"
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
YAML defines three fundamental data types: **scalars**, **lists** (sequences), and **mappings**.
|
||||
|
||||
### Scalars
|
||||
|
||||
Scalars represent simple values such as strings, numbers, booleans, and nulls.
|
||||
|
||||
#### Booleans
|
||||
|
||||
In openHAB YAML files, only the unquoted literals `true`, `True`, `TRUE`, and `false`, `False`, `FALSE` are recognized as boolean values.
|
||||
`ON`, `OFF`, `Yes`, `No`, `enable`, and `disable` are parsed as plain strings.
|
||||
To specify `true` or `false` as a string, they must be enclosed in single or double quotes.
|
||||
|
||||
#### Strings
|
||||
|
||||
Strings may be written with or without quotes.
|
||||
|
||||
Unquoted strings are allowed only when the value **cannot** be interpreted as another YAML scalar type such as a number, boolean, or null.
|
||||
In practice, this means unquoted strings should contain letters or simple punctuation, but **not** values that look like numbers or booleans.
|
||||
|
||||
```yaml
|
||||
name: KitchenLight
|
||||
label: Kitchen Light
|
||||
role: admin
|
||||
```
|
||||
|
||||
If a value could be parsed as a number, boolean, or null, it must be quoted to ensure it is treated as a string.
|
||||
|
||||
```yaml
|
||||
string_number: "123"
|
||||
string_boolean: "true"
|
||||
string_null: "null"
|
||||
```
|
||||
|
||||
Quotes are required when:
|
||||
|
||||
- The string contains characters YAML may interpret as syntax.
|
||||
- The string contains leading or trailing spaces.
|
||||
- The string contains a colon followed by a space, which YAML interprets as a key/value separator.
|
||||
|
||||
```yaml
|
||||
label: "Kitchen: Main Light"
|
||||
note: " This string preserves leading spaces"
|
||||
```
|
||||
|
||||
Single‑quoted and double‑quoted strings behave differently.
|
||||
|
||||
- Single‑quoted strings treat the content literally.
|
||||
- Single‑quoted strings do not process escape sequences.
|
||||
- Single‑quoted strings are useful when you want the text to appear exactly as written.
|
||||
|
||||
```yaml
|
||||
description: 'This string contains \n and it will not be interpreted as a newline'
|
||||
```
|
||||
|
||||
- Double‑quoted strings support escape sequences.
|
||||
- Double‑quoted strings allow characters such as `\n`, `\t`, and `\"` to be interpreted.
|
||||
|
||||
```yaml
|
||||
message: "Line one\nLine two"
|
||||
```
|
||||
|
||||
- Use single quotes when you want literal text or when the string contains double quotes.
|
||||
- Use double quotes when you need escape sequences or when the string contains single quotes and requires escaping.
|
||||
|
||||
#### Multiline Strings
|
||||
|
||||
Multiline strings are used for longer descriptions or scripts.
|
||||
|
||||
Use `|` to preserve line breaks exactly as written.
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
This is a multi-line
|
||||
description.
|
||||
Line breaks are preserved.
|
||||
```
|
||||
|
||||
Use `>` to fold lines into a single paragraph.
|
||||
|
||||
```yaml
|
||||
notes: >
|
||||
This text will be folded
|
||||
into a single line,
|
||||
with spaces instead of line breaks.
|
||||
```
|
||||
|
||||
- `|` preserves line breaks.
|
||||
- `>` folds single line breaks into spaces.
|
||||
- Blank lines are preserved when using `>`.
|
||||
- Indentation under `|` or `>` must be consistent and becomes part of the string.
|
||||
|
||||
#### Numbers
|
||||
|
||||
YAML supports numeric scalar values without requiring quotes.
|
||||
A number is interpreted as numeric when it contains only digits, with an optional sign, decimal point, or exponent.
|
||||
|
||||
##### Valid Examples
|
||||
|
||||
```yaml
|
||||
positive_integer: 42
|
||||
negative_integer: -7
|
||||
decimal_number: 3.1415
|
||||
negative_decimal: -0.25
|
||||
scientific_notation: 1.2e6
|
||||
```
|
||||
|
||||
##### When to Use Quotes
|
||||
|
||||
Numbers must be quoted if you want them treated as **strings**:
|
||||
|
||||
```yaml
|
||||
string_number: "42"
|
||||
leading_zero_string: "007"
|
||||
```
|
||||
|
||||
#### Null
|
||||
|
||||
YAML supports a null value using any of the following forms:
|
||||
|
||||
```yaml
|
||||
value1: null
|
||||
value2: Null
|
||||
value3: NULL
|
||||
value4: ~
|
||||
value5:
|
||||
```
|
||||
|
||||
All of these are interpreted as `null`.
|
||||
|
||||
### Lists (Sequences)
|
||||
|
||||
Lists are introduced with a dash (`-`) at the start of each item.
|
||||
|
||||
```yaml
|
||||
tags:
|
||||
- Light
|
||||
- LivingRoom
|
||||
```
|
||||
|
||||
Flow style is also supported:
|
||||
|
||||
```yaml
|
||||
tags: [Light, LivingRoom]
|
||||
```
|
||||
|
||||
### Mappings (Key/Value Pairs)
|
||||
|
||||
Mappings define key/value structures.
|
||||
|
||||
```yaml
|
||||
stateDescription:
|
||||
config:
|
||||
pattern: "%.1f °C"
|
||||
```
|
||||
|
||||
Flow style is also supported:
|
||||
|
||||
```yaml
|
||||
stateDescription: { config: { pattern: "%.1f °C" } }
|
||||
```
|
||||
|
||||
#### How YAML Identifies a Mapping Entry
|
||||
|
||||
A mapping entry uses the pattern:
|
||||
|
||||
```yaml
|
||||
key: value
|
||||
```
|
||||
|
||||
The space after the colon is required.
|
||||
|
||||
```yaml
|
||||
text: "Alice: Hello" # Must be quoted because the colon is followed by a space
|
||||
url: http://example.com # Safe unquoted because the colon is not followed by a space
|
||||
```
|
||||
|
||||
## Anchors and Aliases
|
||||
|
||||
Anchors define reusable blocks.
|
||||
Aliases reference those blocks.
|
||||
|
||||
```yaml
|
||||
defaults: &defaultConfig
|
||||
refresh: 60
|
||||
geolocation: 1.23456, 7.890123
|
||||
|
||||
thing:
|
||||
label: "Lamp"
|
||||
config: *defaultConfig
|
||||
```
|
||||
|
||||
For more details, see [Anchors and Aliases](anchors.md).
|
||||
|
||||
## Merge Keys
|
||||
|
||||
YAML merge keys use the `<<:` syntax to merge mappings.
|
||||
|
||||
> **Note:** Although merge keys (`<<:`) are not defined in YAML 1.2, the YAML Composer includes full support for them.
|
||||
> They are a foundational mechanism used throughout the preprocessing system to combine mappings, apply defaults, and build modular configuration structures.
|
||||
|
||||
```yaml
|
||||
base: &base
|
||||
type: Switch
|
||||
category: Light
|
||||
|
||||
item:
|
||||
<<: *base
|
||||
label: "Desk Lamp"
|
||||
```
|
||||
|
||||
Effective structure:
|
||||
|
||||
```yaml
|
||||
item:
|
||||
type: Switch
|
||||
category: Light
|
||||
label: "Desk Lamp"
|
||||
```
|
||||
|
||||
For more details, see [Merge Keys](merge-keys.md).
|
||||
|
||||
## YAML Tags (Brief Introduction)
|
||||
|
||||
YAML supports tags, which change how a value is interpreted.
|
||||
Some tags are built in, and YAML Composer adds several **custom tags** such as `!include`, `!if`, and `!remove` to enable enhanced features.
|
||||
|
||||
A tag can be applied to a simple scalar:
|
||||
|
||||
```yaml
|
||||
value: !include file.inc.yaml
|
||||
```
|
||||
|
||||
A tag can also be applied to a mapping.
|
||||
In that form, it applies to everything inside the mapping:
|
||||
|
||||
```yaml
|
||||
thing: !if
|
||||
if: ${thing_enabled}
|
||||
then:
|
||||
label: Room Light
|
||||
```
|
||||
|
||||
You will see these custom tags throughout the enhanced YAML configuration.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using tab characters instead of spaces for indentation.
|
||||
- Using inconsistent indentation.
|
||||
- Misaligning list items.
|
||||
- Omitting quotes around strings containing special characters.
|
||||
- Incorrectly nesting mappings.
|
||||
- Incorrect indentation under `|` or `>` for multiline strings.
|
||||
|
||||
If a YAML file fails to load, indentation, quoting, or multiline formatting issues are often the cause.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Conditionals (!if)
|
||||
|
||||
The `!if` tag performs logical branching during the **preprocessing phase**.
|
||||
|
||||
> **Note:** Conditions are evaluated **once** when the YAML file is loaded.
|
||||
> These are not runtime rules.
|
||||
> They do not react to live state changes in openHAB.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## When to Use `!if`
|
||||
|
||||
Use the `!if` tag to adapt your configuration based on the **Resolution Context** (variables defined in the file, injected via `!include` or `!insert`, or [environment globals](variables.md#env-to-access-environment-variables)).
|
||||
|
||||
- **Conditional Snippets**: choose between alternative configuration blocks or values.
|
||||
- **Optional Properties**: conditionally merge in additional settings using [merge keys (<<)](merge-keys.md).
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
The `!if` tag supports two forms: a **Mapping Form** for simple logic and a **Sequence Form** for multiple branches.
|
||||
|
||||
The `if:` and `elseif:` keys are treated as **implicit expressions**.
|
||||
You do not need to wrap the expression in `${...}`.
|
||||
|
||||
### Mapping Form (Simple)
|
||||
|
||||
Use this for simple if/else decisions.
|
||||
|
||||
```yaml
|
||||
example: !if
|
||||
if: env == 'prod'
|
||||
then: "secure-server-url"
|
||||
else: "localhost"
|
||||
```
|
||||
|
||||
| Key | Description | Required |
|
||||
|:-------|:------------------------------------------------------------------------------------------------------------------------|:---------|
|
||||
| `if` | The expression to evaluate. | Yes |
|
||||
| `then` | The value to return if **truthy** ([see rules below](#truthiness-rules)). Can be a scalar, map, list, or any valid tag. | Yes |
|
||||
| `else` | The value to return if **falsy**. | No |
|
||||
|
||||
### Sequence Form (Multiple Branches)
|
||||
|
||||
Use this for multiple ordered conditions.
|
||||
|
||||
Evaluates conditions in order and stops at the first **truthy** match.
|
||||
If no condition matches and no `else` is provided, the tag resolves to `null`.
|
||||
|
||||
```yaml
|
||||
environment_type: !if
|
||||
- if: hardware_version >= 2
|
||||
then: "high-power-mode"
|
||||
- elseif: battery_powered
|
||||
then: "eco-mode"
|
||||
- else: "standard-mode"
|
||||
```
|
||||
|
||||
## Expression Evaluation
|
||||
|
||||
The `if:` key follows a specific order of operations.
|
||||
|
||||
### 1. Bare Expressions (Recommended)
|
||||
|
||||
The string is evaluated directly as an expression against the available variables.
|
||||
|
||||
```yaml
|
||||
if: count > 10 and status == 'ALARM'
|
||||
```
|
||||
|
||||
::: tip
|
||||
The expression can be quoted when it contains characters that YAML would otherwise misinterpret, such as `:` or `#`.
|
||||
:::
|
||||
|
||||
### 2. Using substitution pattern (Advanced — Double Evaluation)
|
||||
|
||||
If you use a substitution pattern inside an `if:` key, the substitution engine runs **first** to resolve `${...}` patterns.
|
||||
The resulting string is then evaluated as an expression.
|
||||
This is useful for building logic strings from variables.
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
operator: ">"
|
||||
|
||||
test: !if
|
||||
if: 75 ${operator} 50
|
||||
# Step 1: ${operator} resolves to ">"
|
||||
# Step 2: The if expression resolves to "75 > 50"
|
||||
# Step 3: expression evaluates to true
|
||||
then: "High"
|
||||
```
|
||||
|
||||
## Truthiness Rules
|
||||
|
||||
When a value is used in a conditional, it is first evaluated and then interpreted as either **truthy** or **falsy**.
|
||||
|
||||
The following values are considered **falsy**:
|
||||
|
||||
- `false`
|
||||
- `null`
|
||||
- `0` or `0.0`
|
||||
- empty strings (`""`)
|
||||
- empty lists (`[]`)
|
||||
- empty maps (`{}`)
|
||||
|
||||
All other values are **truthy**.
|
||||
This includes any non‑empty string, any non‑zero number, and any non‑empty collection.
|
||||
|
||||
### Short-Circuiting (Lazy Evaluation)
|
||||
|
||||
Only the active branch is processed.
|
||||
Tags such as `!include` inside inactive branches are ignored.
|
||||
An `!include` in an inactive branch is never loaded and will not cause errors if the file does not exist.
|
||||
|
||||
## Advanced Integration
|
||||
|
||||
### Nesting and Composition
|
||||
|
||||
The `!if` tag is fully recursive.
|
||||
You can nest `!if` tags within the `then` or `else` blocks to create complex decision trees.
|
||||
|
||||
```yaml
|
||||
status: !if
|
||||
if: device_online
|
||||
then: !if
|
||||
if: battery_level < 20
|
||||
then: "online-low-battery"
|
||||
else: "online-healthy"
|
||||
else: "offline"
|
||||
```
|
||||
|
||||
### Conditional Merging (Mixins)
|
||||
|
||||
Use `!if` with the YAML merge key (`<<`) to conditionally mix in sets of properties.
|
||||
|
||||
```yaml
|
||||
server_config:
|
||||
port: 8080
|
||||
<<: !if
|
||||
if: is_prod
|
||||
then:
|
||||
ssl_enabled: true
|
||||
strict_security: true
|
||||
```
|
||||
|
||||
### Using !include and !insert
|
||||
|
||||
You can return entire files or templates by using `!include` or `!insert` inside a branch.
|
||||
Only the tag in the active branch is processed.
|
||||
|
||||
```yaml
|
||||
network_settings: !if
|
||||
if: wifi_enabled
|
||||
then: !include wifi-config.inc.yaml
|
||||
else: !insert ethernet-template
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Expression vs String Literal**: `if: production` checks for a variable named `production`.
|
||||
To check for the literal string, quote it: `if: env == 'production'`.
|
||||
1. **Omitting `else`**: If no condition matches and there is no `else`, the result is `null`.
|
||||
1. **Invalid YAML**: Even inactive branches must be syntactically valid YAML.
|
||||
|
||||
See [Expression Syntax](variables.md#expression-syntax) for more details.
|
||||
@@ -0,0 +1,291 @@
|
||||
# Including Other Files
|
||||
|
||||
`!include` inserts the referenced file or structure exactly at the position where the include appears.
|
||||
|
||||
YAML Composer supports including external YAML files to facilitate modular, reusable, and maintainable configurations.
|
||||
This is especially useful for modular reuse, creating device [packages](packages.md), or separating concerns across multiple files.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Syntax Options
|
||||
|
||||
### Short Form
|
||||
|
||||
```yaml
|
||||
keyname: !include filename.inc.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
keyname: !include filename.inc.yaml?arg1=value1&flag
|
||||
```
|
||||
|
||||
The short form allows you to reference another file directly after the `!include` tag.
|
||||
Optional arguments may be appended using URL‑style query syntax:
|
||||
|
||||
- `arg1=value1` assigns a specific value
|
||||
- `flag` (a value‑less argument) is interpreted as `true`
|
||||
- Multiple arguments are separated with `&`
|
||||
- The file name, argument names, and argument values are URL‑decoded.
|
||||
If any of them contain characters such as `+`, `%`, or other reserved symbols, make sure to URL‑encode them.
|
||||
|
||||
This form is ideal when you only need to include a file and pass a small number of simple parameters.
|
||||
|
||||
### Long Form (supports variables)
|
||||
|
||||
```yaml
|
||||
# Block style (multi-line)
|
||||
keyname: !include
|
||||
file: filename.inc.yaml
|
||||
vars:
|
||||
var1: value1
|
||||
var2: value2
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Flow style (single-line)
|
||||
keyname: !include { file: filename.inc.yaml, vars: { var1: value1, var2: value2 } }
|
||||
```
|
||||
|
||||
The `vars:` mapping is layered on top of the file’s current variables to form the evaluation context.
|
||||
In the long form, the `vars:` section is optional.
|
||||
|
||||
::: tip Passing Existing Variables to Included Files
|
||||
|
||||
The `vars:` section of an `!include` directive can contain literal values or references to **existing variables**.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
keyname: !include
|
||||
file: xx.inc.yaml
|
||||
vars:
|
||||
var1: ${mainvar}
|
||||
```
|
||||
|
||||
The include file can now refer to `var1` without relying on the variable names used in the main file.
|
||||
This is especially useful when the same include file is shared across multiple configurations that may use different variable names.
|
||||
|
||||
:::
|
||||
|
||||
The contents of the include file are inserted as the value for the given key.
|
||||
This means that the top-level keys in the include file become sub-keys of the key in the main file.
|
||||
|
||||
## Variable Resolution Order
|
||||
|
||||
Understanding how variables interact across files is important when using includes, especially when include files define their own defaults.
|
||||
|
||||
When include files are involved, variables can originate from multiple sources.
|
||||
Their values are resolved according to the following order, from highest priority to lowest priority.
|
||||
|
||||
1. Inline `vars` in `!include` directives
|
||||
1. Global `variables` defined in the main file
|
||||
1. Local `variables` defined inside the include file
|
||||
|
||||
Variables provided in the `vars` section of an `!include` directive are visible only within the included file, but they override both global and local variables.
|
||||
|
||||
Local variables defined inside an include file can act as default values when they are not provided in the `!include` directive.
|
||||
This allows include files to be reused with different parameters while keeping sensible defaults inside the file itself.
|
||||
|
||||
**Example:**
|
||||
|
||||
`main.yaml`:
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
broker: mqtt:broker:main
|
||||
|
||||
things:
|
||||
mqtt:topic:livingroom-window: !include
|
||||
file: mqtt_contact.inc.yaml
|
||||
vars:
|
||||
label: Living Room Window
|
||||
id: livingroom-window
|
||||
|
||||
mqtt:topic:bedroom-window: !include
|
||||
file: mqtt_contact.inc.yaml
|
||||
vars:
|
||||
label: Bedroom Window
|
||||
id: bedroom-window
|
||||
broker: mqtt:broker:external # override the global broker variable
|
||||
```
|
||||
|
||||
`mqtt_contact.inc.yaml`:
|
||||
|
||||
```yaml
|
||||
bridge: ${broker}
|
||||
label: ${label}
|
||||
config:
|
||||
availabilityTopic: ${id}/availability
|
||||
payloadAvailable: online
|
||||
payloadNotAvailable: offline
|
||||
```
|
||||
|
||||
Resulting configuration:
|
||||
|
||||
```yaml
|
||||
things:
|
||||
mqtt:topic:livingroom-window:
|
||||
bridge: mqtt:broker:main
|
||||
label: Living Room Window
|
||||
config:
|
||||
availabilityTopic: livingroom-window/availability
|
||||
payloadAvailable: online
|
||||
payloadNotAvailable: offline
|
||||
|
||||
mqtt:topic:bedroom-window:
|
||||
bridge: mqtt:broker:external
|
||||
label: Bedroom Window
|
||||
config:
|
||||
availabilityTopic: bedroom-window/availability
|
||||
payloadAvailable: online
|
||||
payloadNotAvailable: offline
|
||||
```
|
||||
|
||||
## File Naming & Reload Behavior
|
||||
|
||||
### Include File Naming
|
||||
|
||||
Include files should use a dedicated extension, either `.inc.yaml` or `.inc.yml`.
|
||||
Files with a normal `.yaml` extension are treated as **main configuration files** and must contain the full [top‑level structure]({{base}}/configuration/yaml/) required by the YAML configuration schema.
|
||||
|
||||
In contrast, `.inc.yaml` files are recognized as include fragments and are only processed when referenced through `!include`.
|
||||
|
||||
::: tip Note
|
||||
Include fragments and main files are intentionally treated as different roles.
|
||||
Using the same file as both a compiled main file and an included fragment is not supported.
|
||||
:::
|
||||
|
||||
### Path Resolution
|
||||
|
||||
Include file paths may be written as absolute paths or as paths relative to the current file.
|
||||
YAML Composer also supports two shorthand prefixes that simplify referencing files inside the configuration directory.
|
||||
|
||||
#### Shorthand Prefixes
|
||||
|
||||
| Shorthand | Resolves To | Meaning |
|
||||
|---------------------|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
|
||||
| `@/path`<br>`@path` | `${OPENHAB_CONF}/path` | The openHAB configuration root. Use when referencing files anywhere under `OPENHAB_CONF`. The slash after `@` is optional. |
|
||||
| `$/path`<br>`$path` | `${OPENHAB_CONF}/yamlcomposer/path` | The source directory for your extended-syntax YAML files. The slash after `$` is optional. |
|
||||
|
||||
##### `@/path` → `${OPENHAB_CONF}/path`
|
||||
|
||||
A leading `@` resolves directly to the openHAB configuration root (`OPENHAB_CONF`).
|
||||
|
||||
```yaml
|
||||
key: !include "@/yaml/includes/device.inc.yaml"
|
||||
# Resolves to: ${OPENHAB_CONF}/yaml/includes/device.inc.yaml
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Paths beginning with `@` **must be quoted** (e.g., `"@/path"`), because YAML plain scalars cannot begin with `@`.
|
||||
- The slash after `@` is **optional**: `"@yaml/includes/device.inc.yaml"` works the same.
|
||||
- Use `@` when you want to reference files anywhere under the configuration root without writing long absolute paths.
|
||||
|
||||
##### `$/path` → `${OPENHAB_CONF}/yamlcomposer/path`
|
||||
|
||||
A leading `$` resolves to `OPENHAB_CONF/yamlcomposer`.
|
||||
|
||||
**Example directory layout:**
|
||||
|
||||
```sh
|
||||
OPENHAB_CONF/
|
||||
yamlcomposer/
|
||||
shared.inc.yaml
|
||||
lights/
|
||||
kitchen/
|
||||
main.yaml
|
||||
```
|
||||
|
||||
If `main.yaml` contains:
|
||||
|
||||
```yaml
|
||||
key: !include "$/shared.inc.yaml"
|
||||
```
|
||||
|
||||
Then `$` resolves to:
|
||||
|
||||
```sh
|
||||
${OPENHAB_CONF}/yamlcomposer
|
||||
```
|
||||
|
||||
So the final resolved path becomes:
|
||||
|
||||
```sh
|
||||
${OPENHAB_CONF}/yamlcomposer/shared.inc.yaml
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- The slash after `$` is **optional**: `"$shared.inc.yaml"` works the same.
|
||||
- It avoids brittle paths like `../../../shared.inc.yaml`.
|
||||
|
||||
#### Relative Paths
|
||||
|
||||
If the path does not begin with `/`, `@`, or `$` it is interpreted as a path **relative to the directory of the including file**.
|
||||
You may use `.` and `..` to refer to the current and parent directory.
|
||||
|
||||
**Example directory layout:**
|
||||
|
||||
```sh
|
||||
yamlcomposer/
|
||||
parent.inc.yaml
|
||||
main/
|
||||
main.yaml
|
||||
shared.inc.yaml
|
||||
common/
|
||||
defaults.inc.yaml
|
||||
```
|
||||
|
||||
**Same directory:**
|
||||
|
||||
```yaml
|
||||
key: !include "shared.inc.yaml"
|
||||
# Resolves to: yamlcomposer/main/shared.inc.yaml
|
||||
```
|
||||
|
||||
**Navigate downward (into a subdirectory):**
|
||||
|
||||
```yaml
|
||||
key: !include "common/defaults.inc.yaml"
|
||||
# Resolves to: yamlcomposer/main/common/defaults.inc.yaml
|
||||
```
|
||||
|
||||
**Navigate upward:**
|
||||
|
||||
```yaml
|
||||
key: !include "../parent.inc.yaml"
|
||||
# Resolves to: yamlcomposer/parent.inc.yaml
|
||||
```
|
||||
|
||||
Relative paths always resolve from the directory containing the including file.
|
||||
|
||||
#### Using Variable Substitutions
|
||||
|
||||
You can use variable substitution patterns in the file name to construct paths dynamically.
|
||||
This includes normal variables, [predefined variables](variables.md#predefined-variables), and environment variables exposed through `ENV`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
keyname: !include
|
||||
file: ${OPENHAB_CONF}/packages/hue-light.pkg.yaml
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
It may be helpful to store include files in a dedicated subdirectory.
|
||||
These files can be referenced using relative paths, the `@` or `$` shorthands, or full absolute paths.
|
||||
Choose whichever style best matches your preference.
|
||||
|
||||
### Nested Includes
|
||||
|
||||
Include files may themselves contain `!include` directives.
|
||||
This allows configurations to be composed from multiple layers:
|
||||
|
||||
main.yaml → a.inc.yaml → b.inc.yaml → …
|
||||
|
||||
### Reload Behavior
|
||||
|
||||
When an include file changes, YAML Composer reloads the main files that reference it rather than attempting to load the include file directly.
|
||||
Reloads occur only if the include file is located within `${OPENHAB_CONF}/yamlcomposer/`.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Merge Keys
|
||||
|
||||
Merge keys (`<<`) let you combine mappings defined directly in a mapping with other mappings defined elsewhere, such as:
|
||||
|
||||
- [Anchors](anchors.md)
|
||||
- [!include](include.md) (External files)
|
||||
- [!insert](templates.md) (In-file Templates)
|
||||
- [!if](conditionals.md) (Conditionals)
|
||||
- [Variables](variables.md)
|
||||
|
||||
They promote reusability and avoid repetition by letting you define common mappings once and update them in a single place.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## What Merge Keys Do
|
||||
|
||||
A merge key takes one or more source mappings and merges their key–value pairs into the current mapping.
|
||||
The source mappings may be written inline, referenced through an anchor, or loaded from another file.
|
||||
The merged values behave exactly as if they were written inline.
|
||||
Local fields may add to or override the merged content.
|
||||
|
||||
## Merge Rules
|
||||
|
||||
- Only mappings can be merged.
|
||||
- Merge keys may appear multiple times or as a list.
|
||||
- Merge order matters because keys from earlier mappings override keys from later ones.
|
||||
- Local keys always override merged keys, even when defined after a merge key.
|
||||
- Merges are shallow, and nested mappings are replaced rather than combined.
|
||||
|
||||
### Example: Multiple Merges and Precedence
|
||||
|
||||
```yaml
|
||||
.defaults: &DEFAULTS
|
||||
icon: default
|
||||
autoupdate: false
|
||||
|
||||
.metadata: &LIGHTS
|
||||
tags: [Light]
|
||||
icon: light
|
||||
|
||||
items:
|
||||
Lamp:
|
||||
<<: [*LIGHTS, *DEFAULTS]
|
||||
label: Desk Lamp
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```yaml
|
||||
items:
|
||||
Lamp:
|
||||
label: Desk Lamp
|
||||
icon: light # from LIGHTS (earlier mapping wins)
|
||||
autoupdate: false
|
||||
tags: [Light]
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Merge keys only merge mappings, not lists.
|
||||
- Merge keys cannot modify scalar values.
|
||||
- Merge keys cannot be used inside sequences unless the sequence elements are mappings.
|
||||
|
||||
## Integrating with Other Features
|
||||
|
||||
### Anchors
|
||||
|
||||
[Anchors](anchors.md) define reusable structures whose content can be inserted into the current mapping via an alias.
|
||||
Merge keys then combine that anchored content with any local fields in the mapping.
|
||||
Anchors are a standard YAML feature for sharing data within a single file.
|
||||
Variables or templates are often preferred for more complex needs.
|
||||
|
||||
```yaml
|
||||
items:
|
||||
Light1: &LIGHT_BASE
|
||||
type: Switch
|
||||
icon: light
|
||||
autoupdate: false
|
||||
label: Light One
|
||||
|
||||
Light2:
|
||||
<<: *LIGHT_BASE
|
||||
label: Light Two
|
||||
```
|
||||
|
||||
See also: [Using Hidden Keys for Anchors](anchors.md#using-hidden-keys-for-anchors).
|
||||
|
||||
### `!include`
|
||||
|
||||
The [!include](include.md) tag loads the contents of another file so it can be inserted into the current mapping.
|
||||
Merge keys then combine the included content with any local fields in the mapping.
|
||||
|
||||
**Benefits over anchors:**
|
||||
|
||||
- Included files can be parameterized, allowing the same structure to be reused with different values.
|
||||
- Included files can be shared across many different YAML files, whereas anchors are limited to a single file.
|
||||
|
||||
```yaml
|
||||
# light-common.inc.yaml
|
||||
power:
|
||||
type: switch
|
||||
stateTopic: ${id}/power
|
||||
availability:
|
||||
type: contact
|
||||
stateTopic: ${id}/availability
|
||||
```
|
||||
|
||||
```yaml
|
||||
# light-color.inc.yaml
|
||||
color:
|
||||
type: color
|
||||
stateTopic: ${id}/color
|
||||
```
|
||||
|
||||
```yaml
|
||||
# main.yaml
|
||||
things:
|
||||
mqtt:topic:living-room-light:
|
||||
channels:
|
||||
<<: !include { file: light-common.inc.yaml, vars: { id: living-room-light } }
|
||||
<<: !include { file: light-color.inc.yaml, vars: { id: living-room-light } }
|
||||
```
|
||||
|
||||
### Templates (`!insert`)
|
||||
|
||||
The [!insert](templates.md) tag inserts the contents of an in-file template into the current mapping.
|
||||
Merge keys then combine the template’s fields with any local fields in that mapping.
|
||||
|
||||
Templates can be parameterized when used in a merge key.
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
common_channels:
|
||||
power:
|
||||
type: switch
|
||||
stateTopic: ${id}/power
|
||||
availability:
|
||||
type: contact
|
||||
stateTopic: ${id}/availability
|
||||
|
||||
color_channel:
|
||||
color:
|
||||
type: color
|
||||
stateTopic: ${id}/color
|
||||
|
||||
things:
|
||||
mqtt:topic:living-room-light:
|
||||
channels:
|
||||
<<: !insert { template: common_channels, vars: { id: living-room-light } }
|
||||
<<: !insert { template: color_channel, vars: { id: living-room-light } }
|
||||
```
|
||||
|
||||
### Conditionals (`!if`)
|
||||
|
||||
The [!if](conditionals.md) tag allows you to choose between different mappings or return `null` to skip the merge entirely.
|
||||
The engine resolves tags before the merge, so the merge key receives the final mapping.
|
||||
|
||||
```yaml
|
||||
# Merge extra settings only for production
|
||||
server:
|
||||
port: 8080
|
||||
<<: !if
|
||||
if: is_prod
|
||||
then: { ssl: true, cache: true }
|
||||
```
|
||||
|
||||
### Substitution
|
||||
|
||||
Merge keys can combine content produced dynamically from an expression.
|
||||
This enables inline conditional merging using Python-style expressions.
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
has_color: true
|
||||
color_channel:
|
||||
color:
|
||||
type: color
|
||||
|
||||
things:
|
||||
mqtt:topic:light:
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
# Returns color_channel map if true
|
||||
<<: ${color_channel if has_color}
|
||||
```
|
||||
|
||||
::: tip Hints
|
||||
|
||||
- Merge keys only merge mappings, so the expression must resolve to a map.
|
||||
- Avoid compound patterns such as `${foo}${bar}` or `x${foo}` because they are interpreted as literal strings and cannot be merged.
|
||||
- Merging `null` or an empty map is a no-op, effectively omitting the merge key.
|
||||
|
||||
:::
|
||||
|
||||
## Related Topics
|
||||
|
||||
For details on how variables are resolved and how substitution interacts with YAML structures, see [Variables & Substitution — Return Types & Coercion](variables.md#return-types--coercion).
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use merge keys to define reusable base templates.
|
||||
- Keep anchors inside hidden keys to avoid clutter.
|
||||
- Use includes or packages to centralize shared structures.
|
||||
- Prefer simple, predictable structures for reusable blocks.
|
||||
@@ -0,0 +1,450 @@
|
||||
# Packages
|
||||
|
||||
Packages provide a way to bundle multiple related YAML sections into a reusable, parameterized unit.
|
||||
Unlike fragment-level insertion, a package expands into full top‑level sections such as `things:` or `items:`.
|
||||
These sections may come from an external file or a same‑file template.
|
||||
They are then merged into the current configuration.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Purposes
|
||||
|
||||
- **Logical Grouping:**
|
||||
Packages allow a **Thing** and its related **Items**, channels, and metadata to be defined together in one file, representing a complete, self‑contained device definition.
|
||||
|
||||
- **Reuse with Different Parameters:**
|
||||
Through variable substitution, a single package can be instantiated multiple times with different values.
|
||||
This makes it easy to define many similar devices such as sensors, lights, or switches from one shared template.
|
||||
|
||||
## Package Syntax and Structure
|
||||
|
||||
Packages are declared in the main YAML file under the top‑level `packages:` section.
|
||||
Each entry defines a package ID and the source from which the package content is obtained.
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
<package_id>: <package_source>
|
||||
<another_package_id>: <package_source>
|
||||
...
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **Package ID:**
|
||||
A package ID is a unique identifier for the package.
|
||||
It may include spaces.
|
||||
The special variable `${package_id}` resolves to this key inside the package content.
|
||||
|
||||
- **Package Source:**
|
||||
A package can be created from either of the following sources:
|
||||
|
||||
- **`!include` (external file):**
|
||||
Loads a separate YAML file and applies the package’s variable context to it.
|
||||
See the [!include syntax options](include.md#syntax-options).
|
||||
|
||||
- **`!insert` (same‑file template):**
|
||||
Expands a template defined under the main file’s `templates:` section.
|
||||
See the [!insert syntax options](templates.md#syntax-options).
|
||||
|
||||
Both forms support parameterization through `vars:` and participate fully in package merging.
|
||||
|
||||
## Package Source Contents
|
||||
|
||||
- **Top‑Level Sections:**
|
||||
Package sources contain any combination of top‑level keys such as `things:` and `items:`.
|
||||
|
||||
- **Uniqueness:**
|
||||
Because package sources can be referenced multiple times, use variable substitutions such as `${package_id}` and unique `vars:` variables for entity UIDs in each invocation to avoid collisions.
|
||||
|
||||
- **Deep Nesting:**
|
||||
A package source may itself include other packages.
|
||||
When a package references another package via `packages:` inside an included file, it creates an inheritance chain.
|
||||
Properties merge downward sequentially from the deepest core file out to the final main file.
|
||||
|
||||
## Package Example
|
||||
|
||||
`main.yaml`:
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
broker: mqtt:broker:main
|
||||
|
||||
packages:
|
||||
livingroom-light: !include
|
||||
file: package/mqtt-light.inc.yaml
|
||||
vars:
|
||||
name: Living_Room_Light
|
||||
label: Living Room Light
|
||||
|
||||
bedroom-light: !include
|
||||
file: package/mqtt-light.inc.yaml
|
||||
vars:
|
||||
name: Bed_Room_Light
|
||||
label: Bed Room Light
|
||||
```
|
||||
|
||||
`package/mqtt-light.inc.yaml`:
|
||||
|
||||
```yaml
|
||||
things:
|
||||
mqtt:topic:${package_id}:
|
||||
bridge: ${broker}
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: ${package_id}/state
|
||||
commandTopic: ${package_id}/set/state
|
||||
# ... other channels (brightness, color)
|
||||
|
||||
items:
|
||||
${name}_Power:
|
||||
type: Switch
|
||||
label: ${label} Power
|
||||
channel: mqtt:topic:${package_id}:power
|
||||
# ... more items for the light, e.g. brightness, color, etc.
|
||||
```
|
||||
|
||||
Resulting YAML structure:
|
||||
|
||||
```yaml
|
||||
things:
|
||||
mqtt:topic:livingroom-light:
|
||||
bridge: mqtt:broker:main
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: livingroom-light/state
|
||||
commandTopic: livingroom-light/set/state
|
||||
mqtt:topic:bedroom-light:
|
||||
bridge: mqtt:broker:main
|
||||
channels:
|
||||
power:
|
||||
type: switch
|
||||
config:
|
||||
stateTopic: bedroom-light/state
|
||||
commandTopic: bedroom-light/set/state
|
||||
|
||||
items:
|
||||
Living_Room_Light_Power:
|
||||
type: Switch
|
||||
label: Living Room Light Power
|
||||
channel: mqtt:topic:livingroom-light:power
|
||||
Bed_Room_Light_Power:
|
||||
type: Switch
|
||||
label: Bed Room Light Power
|
||||
channel: mqtt:topic:bedroom-light:power
|
||||
```
|
||||
|
||||
## Merge Behavior
|
||||
|
||||
### Final Top-Level Sections
|
||||
|
||||
A final top‑level section is the fully expanded `things:`, `items:`, or other top‑level section of the configuration that openHAB receives after all packages, templates, includes, and merges have been applied.
|
||||
Entries defined directly under these sections can merge with package‑generated entries when their identifiers match.
|
||||
Entries remain independent when their identifiers do not match.
|
||||
|
||||
::: tip Note
|
||||
The following example uses `!insert`, but the same merge rules apply to packages sourced from `!include`.
|
||||
:::
|
||||
|
||||
When a package is expanded, its contents are merged into the main YAML structure.
|
||||
You may customize the resulting structure by overriding, adding, or removing elements defined in the package.
|
||||
This is done by redefining the elements you want to customize in the main file.
|
||||
These redefinitions appear in the final top‑level section.
|
||||
|
||||
### Default Merge Behavior
|
||||
|
||||
Source YAML File:
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
number_item:
|
||||
items:
|
||||
${package_id}_Item:
|
||||
type: Number
|
||||
label: Package Label
|
||||
tags: [Measurement]
|
||||
metadata:
|
||||
stateDescription:
|
||||
config:
|
||||
min: 1
|
||||
pattern: '%.3f'
|
||||
widget:
|
||||
value: oh-card
|
||||
|
||||
packages:
|
||||
Number: !insert number_item
|
||||
|
||||
# This is the final top‑level `items:` section of the configuration
|
||||
# The packages will merge into this section
|
||||
items:
|
||||
Number_Item:
|
||||
label: Power Draw
|
||||
dimension: Power
|
||||
tags: [Power]
|
||||
metadata:
|
||||
stateDescription:
|
||||
config:
|
||||
max: 10
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```yaml
|
||||
items:
|
||||
Number_Item:
|
||||
type: Number
|
||||
label: Power Draw
|
||||
dimension: Power
|
||||
tags:
|
||||
- Measurement
|
||||
- Power
|
||||
metadata:
|
||||
stateDescription:
|
||||
config:
|
||||
min: 1
|
||||
max: 10
|
||||
pattern: '%.3f'
|
||||
widget:
|
||||
value: oh-card
|
||||
```
|
||||
|
||||
The way keys interact depends on their data type.
|
||||
|
||||
| Data Type | Behavior | Description |
|
||||
|-----------|-----------|----------------------------------------------------------------------------------------------------------|
|
||||
| Scalar | Overwrite | A scalar in the final top‑level section replaces the scalar defined at the same path inside the package. |
|
||||
| Map | Merge | Maps are merged key by key, recursively. |
|
||||
| List | Merge | Lists are concatenated (package values first) and de-duplicated. |
|
||||
|
||||
### Automatic Removal of Empty Values
|
||||
|
||||
During merging, empty structures are automatically stripped from the final configuration.
|
||||
Empty maps (`{}`) and lists (`[]`) as well as map keys whose value is `null` or an empty string are removed.
|
||||
This keeps the resulting configuration clean and allows packages to define catch‑all defaults.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
icon: null # default to avoid unknown‑variable warnings
|
||||
|
||||
icon: ${icon}
|
||||
```
|
||||
|
||||
Because `icon` evaluates to `null`, the entire `icon:` key is removed from the merged output unless the including file overrides it.
|
||||
|
||||
### How Package Merging Differs from YAML Merge Keys
|
||||
|
||||
Mappings from packages are merged recursively with the corresponding mappings in the final top‑level section.
|
||||
This differs from standard YAML merge keys, which perform only shallow merges.
|
||||
|
||||
**Merge Key (shallow merge):**
|
||||
|
||||
```yaml
|
||||
# merge key:
|
||||
targetkey:
|
||||
foo:
|
||||
bar:
|
||||
boo: baz
|
||||
<<: # merge `foo` into `targetkey`
|
||||
foo:
|
||||
bar:
|
||||
boo: waldo
|
||||
goo: fy
|
||||
qux: quux
|
||||
```
|
||||
|
||||
```yaml
|
||||
# result — the merge key's foo mapping
|
||||
# is ignored because foo already exists in main
|
||||
targetkey:
|
||||
foo:
|
||||
bar:
|
||||
boo: baz
|
||||
```
|
||||
|
||||
**Package Merging (recursive merge):**
|
||||
|
||||
```yaml
|
||||
# main file
|
||||
targetkey:
|
||||
foo:
|
||||
bar:
|
||||
boo: baz
|
||||
|
||||
packages:
|
||||
anyid: !include packagefile.inc.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# packagefile.inc.yaml
|
||||
targetkey:
|
||||
foo:
|
||||
bar:
|
||||
boo: waldo
|
||||
goo: fy
|
||||
qux: quux
|
||||
```
|
||||
|
||||
```yaml
|
||||
# result:
|
||||
targetkey:
|
||||
foo:
|
||||
bar:
|
||||
boo: baz # main file overrides matching keys
|
||||
goo: fy # but includes additional keys...
|
||||
qux: quux # from the package
|
||||
```
|
||||
|
||||
recursive merging allows customization at any depth in the mapping.
|
||||
|
||||
### Controlling Package Merge Behavior with Tags
|
||||
|
||||
Use these special YAML tags to explicitly override the default recursive merge behavior.
|
||||
These directives can be declared at any point in the configuration pipeline—either within the **main YAML file** or deeply embedded inside an intermediate **include file** acting as a middle-tier package.
|
||||
|
||||
#### 1. The `!replace` Tag
|
||||
|
||||
The `!replace` tag forces an absolute replacement for maps or lists that would otherwise merge.
|
||||
This acts as a destructive splice, completely discarding any existing inherited data structure beneath that key from upstream sources and starting fresh with the new layout provided.
|
||||
|
||||
#### 2. The `!remove` Tag
|
||||
|
||||
The `!remove` tag cleanly deletes the corresponding key and its entire sub-tree from the configuration hierarchy.
|
||||
It is ideal for pruning specific components, metadata keys, or configurations exposed by a baseline package that do not apply to the current downstream context.
|
||||
|
||||
---
|
||||
|
||||
### Example: Nested Package Overrides
|
||||
|
||||
When packages include other packages, a middle-tier file can surgically modify or strip elements declared by the core baseline configuration before it ever reaches the main composition file.
|
||||
|
||||
#### Core Configuration (`nested-items.inc.yaml`)
|
||||
|
||||
Provides a core item layout with deep metadata fields:
|
||||
|
||||
```yaml
|
||||
items:
|
||||
target_item:
|
||||
label: base_label
|
||||
tags:
|
||||
- from_nested
|
||||
metadata:
|
||||
stateDescription:
|
||||
value: from_nested
|
||||
category:
|
||||
value: from_nested
|
||||
untouched_item:
|
||||
label: untouched_from_nested
|
||||
```
|
||||
|
||||
#### Middle-Tier Override Package (`pkg-with-overrides.inc.yaml`)
|
||||
|
||||
This file consumes the core configuration via `!include`, but applies `!replace` and `!remove` tags to selectively adjust properties:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
nested: !include nested-items.inc.yaml
|
||||
items:
|
||||
target_item:
|
||||
tags: !replace # 1. Overwrite the list, discarding 'from_nested'
|
||||
- from_outer
|
||||
metadata:
|
||||
stateDescription: !remove # 2. Entirely purge this sub-tree key
|
||||
category: !replace # 3. Swap out the map structure with a fresh one
|
||||
value: from_outer
|
||||
config:
|
||||
origin: nested_package_override
|
||||
```
|
||||
|
||||
#### Root Configuration (`main.yaml`)
|
||||
|
||||
Loads the middle-tier package:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
p1: !include pkg-with-overrides.inc.yaml
|
||||
```
|
||||
|
||||
#### Final Merged Output
|
||||
|
||||
When evaluated by the preprocessor, the downstream overrides successfully neutralize the deep properties inherited from the original package file.
|
||||
The `stateDescription` block is erased, lists and maps are replaced cleanly, and unrelated siblings are passed through untouched:
|
||||
|
||||
```yaml
|
||||
items:
|
||||
target_item:
|
||||
label: base_label
|
||||
tags:
|
||||
- from_outer
|
||||
metadata:
|
||||
category:
|
||||
value: from_outer
|
||||
config:
|
||||
origin: nested_package_override
|
||||
untouched_item:
|
||||
label: untouched_from_nested
|
||||
```
|
||||
|
||||
::: tip Usage Notes
|
||||
|
||||
- `!replace` and `!remove` are evaluated locally within each package layer before that package's content is returned and merged into the caller.
|
||||
This allows intermediate include files to cleanly intercept, prune, or completely reset configurations inherited from deeper core packages.
|
||||
- They can be declared in the root configuration file or anywhere down an inheritance chain of nested package includes.
|
||||
- `!remove` target references apply to entire map keys; individual array elements within a list cannot be targeted for independent removal.
|
||||
- Use `!replace` on an array to drop inherited elements and start an array list with entirely new contents.
|
||||
|
||||
:::
|
||||
|
||||
## Strategic Use of Package IDs
|
||||
|
||||
Choose a **Package ID** that can also serve as a Thing UID fragment, Item name, or similar identifier.
|
||||
This avoids defining extra variables in your package source and lets you derive related identifiers directly from `${package_id}`.
|
||||
|
||||
You can override `${package_id}` in the `vars:` block if needed.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
# main file
|
||||
packages:
|
||||
Living_Room_Light: !include light.inc.yaml
|
||||
Kitchen_Light: !include light.inc.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# light.inc.yaml package source
|
||||
variables:
|
||||
id: ${package_id|lower|replace('_', '-')}
|
||||
thing_uid: "mqtt:topic:${id}"
|
||||
item_name: ${package_id}
|
||||
label: ${package_id|replace('_', ' ')}
|
||||
```
|
||||
|
||||
**Resulting variables:**
|
||||
|
||||
| Variable | Living_Room_Light | Kitchen_Light |
|
||||
|-----------------|--------------------------------|----------------------------|
|
||||
| `${package_id}` | `Living_Room_Light` | `Kitchen_Light` |
|
||||
| `${id}` | `living-room-light` | `kitchen-light` |
|
||||
| `${thing_uid}` | `mqtt:topic:living-room-light` | `mqtt:topic:kitchen-light` |
|
||||
| `${item_name}` | `Living_Room_Light` | `Kitchen_Light` |
|
||||
| `${label}` | `Living Room Light` | `Kitchen Light` |
|
||||
|
||||
## Limitation: Top-Level Merge Keys in `packages:`
|
||||
|
||||
Top-level YAML merge keys (for example `<<:`) inside the `packages:` map are not supported.
|
||||
Each package must be declared explicitly as a direct key under `packages:`.
|
||||
|
||||
The following pattern is **not supported**:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
<<: !include common-packages.yaml
|
||||
```
|
||||
|
||||
In this form, the merge key tries to inject package declarations into `packages:` itself.
|
||||
The composer does not expand package declarations through top-level merge keys.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Templates
|
||||
|
||||
Templates let you define reusable blocks of YAML directly within the same file.
|
||||
They are stored under the top‑level `templates:` section and can be expanded using the `!insert` tag.
|
||||
|
||||
Templates behave similarly to `!include` in how they are evaluated — including variable scoping and per‑invocation isolation — but they are defined locally rather than in external files.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## What Templates Are
|
||||
|
||||
A template is a named YAML node stored under the top‑level `templates:` section.
|
||||
A template may contain **any YAML value**:
|
||||
|
||||
- a mapping
|
||||
- a sequence
|
||||
- a scalar
|
||||
|
||||
The keys under `templates:` (`simple_greeting`, `greeting`, `channel_block`, `color_list`) are **template names**.
|
||||
They identify each template and are not part of the template’s content.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
simple_greeting: Hello!
|
||||
greeting: Hello, ${name}!
|
||||
channel_block:
|
||||
power:
|
||||
type: switch
|
||||
stateTopic: ${id}/power
|
||||
color_list:
|
||||
- red
|
||||
- green
|
||||
- blue
|
||||
```
|
||||
|
||||
::: tip Important!
|
||||
|
||||
Templates are not evaluated when defined.
|
||||
They are evaluated **each time** they are expanded with `!insert`.
|
||||
Therefore, a template may contain references to variables that do not yet exist; those variables are supplied later during the `!insert` invocation.
|
||||
|
||||
:::
|
||||
|
||||
## Syntax Options
|
||||
|
||||
`!insert` expands a template at the point where it appears.
|
||||
It supports two forms:
|
||||
|
||||
### Short Form
|
||||
|
||||
```yaml
|
||||
message: !insert simple_greeting
|
||||
```
|
||||
|
||||
```yaml
|
||||
message: !insert simple_greeting?arg1=value1&flag
|
||||
```
|
||||
|
||||
The short form allows you to reference a template directly after the `!insert` tag.
|
||||
Optional arguments may be appended using URL‑style query syntax:
|
||||
|
||||
- `arg1=value1` assigns a specific value
|
||||
- `flag` (a value‑less argument) is interpreted as `true`
|
||||
- Multiple arguments are separated with `&`
|
||||
- The template name, argument names, and argument values are URL‑decoded.
|
||||
If any of them contain characters such as `+`, `%`, or other reserved symbols, make sure to URL‑encode them.
|
||||
|
||||
Use the no‑argument form when you simply want to insert a template as‑is.
|
||||
Use the query‑parameter form when you want to pass a small number of simple parameters.
|
||||
|
||||
### Long Form (supports variables)
|
||||
|
||||
```yaml
|
||||
# Block style (multi-line)
|
||||
message: !insert
|
||||
template: greeting
|
||||
vars:
|
||||
name: "Jimmy"
|
||||
|
||||
# Or flow style (single-line)
|
||||
message: !insert { template: greeting, vars: { name: "Jimmy" } }
|
||||
```
|
||||
|
||||
The `vars:` mapping is layered on top of the file’s current variables to form the evaluation context.
|
||||
In the long form, the `vars:` section is optional.
|
||||
|
||||
## How Template Evaluation Works
|
||||
|
||||
Each `!insert` call is evaluated in its **own isolated context**:
|
||||
|
||||
- It begins with all variables defined in the current file.
|
||||
- If this file was loaded via `!include`, it also inherits variables from parent files.
|
||||
- Variables provided through `vars:` override or extend this context.
|
||||
- The template body is evaluated using this combined set of variables.
|
||||
- The resulting value — scalar, list, or mapping — replaces the `!insert` node.
|
||||
|
||||
### Example: Scalar Template
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
greeting: Hello, ${name}!
|
||||
|
||||
message: !insert { template: greeting, vars: { name: "Alice" } }
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```yaml
|
||||
message: Hello, Alice!
|
||||
```
|
||||
|
||||
### Example: Template Producing a Mapping
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
channel_block:
|
||||
power:
|
||||
type: switch
|
||||
stateTopic: ${id}/power
|
||||
|
||||
thing:
|
||||
channels: !insert { template: channel_block, vars: { id: living-room-light } }
|
||||
```
|
||||
|
||||
### Example: Template as a List Item
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
color_entry: ${color}
|
||||
|
||||
colors:
|
||||
- !insert { template: color_entry, vars: { color: red } }
|
||||
- !insert { template: color_entry, vars: { color: green } }
|
||||
- !insert { template: color_entry, vars: { color: blue } }
|
||||
```
|
||||
|
||||
### Example: Template Producing a Sequence
|
||||
|
||||
```yaml
|
||||
templates:
|
||||
rgb:
|
||||
- red
|
||||
- green
|
||||
- blue
|
||||
|
||||
palette: !insert rgb
|
||||
```
|
||||
|
||||
## Similarities with `!include`
|
||||
|
||||
Templates and includes share the same evaluation model:
|
||||
|
||||
- Each invocation is evaluated in its own isolated context.
|
||||
- The context includes:
|
||||
- variables defined in the current file
|
||||
- variables inherited from parent files (if this file was loaded via `!include`)
|
||||
- variables provided through `vars:` for this specific invocation
|
||||
- Variable resolution inside the template uses this combined context.
|
||||
|
||||
This section is intentionally mechanical — it explains _how_ templates behave, not _when_ to use them.
|
||||
|
||||
## Template Scope and Variable Resolution
|
||||
|
||||
Inside a template:
|
||||
|
||||
- Interpolation happens only when the template is inserted.
|
||||
- `${var}` resolves using the combined variable context described above.
|
||||
- Each `!insert` call is isolated — variables from one invocation do not leak into another.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Templates must be defined under the top‑level `templates:` section.
|
||||
- Templates cannot be shared across files (use `!include` for that).
|
||||
- Template names must be unique within the file.
|
||||
- A template may produce any YAML value, but the resulting value must be valid for the location where it is inserted (scalar, list item, or mapping field).
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Keep template names descriptive and consistent.
|
||||
- Prefer the block style when passing multiple variables for readability.
|
||||
- Use the composed output in `OPENHAB_CONF/yaml/composed/` to verify template expansion.
|
||||
|
||||
---
|
||||
|
||||
In‑file templates provide a flexible, parameterizable way to structure reusable YAML within a single file.
|
||||
They complement `!include` and anchors, offering a clean mechanism for building consistent configurations.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Variables & Substitution
|
||||
|
||||
Variables allow you to define reusable values and substitute them throughout your YAML configuration.
|
||||
They provide the flexibility needed for complex templates and reduce hard-coded values.
|
||||
These variables work consistently across both the current file and any included packages.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## Variable Definition
|
||||
|
||||
Variables are defined within a top-level `variables:` section.
|
||||
It is recommended to place this section at the beginning of the file for better organization.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
# Scalar variables
|
||||
expire: 5m
|
||||
label: Living Room
|
||||
|
||||
# Map variable
|
||||
mqtt:
|
||||
broker: mqtt:broker:mybroker
|
||||
|
||||
# List variable
|
||||
rooms:
|
||||
- Kitchen
|
||||
- Bedroom
|
||||
```
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
### Default Substitution Behavior
|
||||
|
||||
Substitution enables dynamic value construction instead of static hard-coding.
|
||||
Whenever a scalar contains a `${...}` pattern, the preprocessor evaluates the expression and replaces the pattern with the result.
|
||||
This makes it easy to construct labels, identifiers, and paths from defined variables.
|
||||
Substitution also applies to YAML keys to allow identifiers to be built dynamically.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
room: Kitchen
|
||||
light_id: Kitchen_Light
|
||||
|
||||
items:
|
||||
${light_id}:
|
||||
label: ${room} Light
|
||||
```
|
||||
|
||||
**Resulting Output:**
|
||||
|
||||
```yaml
|
||||
items:
|
||||
Kitchen_Light:
|
||||
label: Kitchen Light
|
||||
```
|
||||
|
||||
### Return Types & Coercion
|
||||
|
||||
The output type depends on how the substitution is structured within the YAML scalar.
|
||||
|
||||
#### Type Preservation (Single Expression)
|
||||
|
||||
If a YAML value consists **entirely** of a single substitution pattern, the resulting object preserves the original Java type returned by the expression.
|
||||
This allows you to inject complex structures like booleans, lists, or maps directly.
|
||||
|
||||
```yaml
|
||||
is_active: ${status == 'ON'} # Becomes a real Boolean
|
||||
target_rooms: ${rooms} # Becomes a real List
|
||||
connection: "${mqtt_config_map}" # Becomes a real Map
|
||||
```
|
||||
|
||||
#### String Coercion (Mixed Content)
|
||||
|
||||
If the substitution pattern is combined with any other text, or if multiple patterns are used together, the entire value is coerced into a **String**.
|
||||
|
||||
```yaml
|
||||
description: "Status is ${status}" # String: "Status is ON"
|
||||
concatenated: "${10}${20}" # String: "1020"
|
||||
room_name: "${room} " # String: "Kitchen " (includes space)
|
||||
```
|
||||
|
||||
| Syntax Pattern | Resulting Type | Example Output |
|
||||
|:---------------|:---------------|:------------------------|
|
||||
| `${expr}` | **Preserved** | `[Item1, Item2]` (List) |
|
||||
| `"${expr}"` | **Preserved** | `true` (Boolean) |
|
||||
| `Text ${expr}` | **String** | `"Count: 5"` (String) |
|
||||
| `${ex1}${ex2}` | **String** | `"1020"` (String) |
|
||||
|
||||
### The `!literal` Tag and `!sub` Escape Hatch
|
||||
|
||||
The `!literal` tag disables substitution recursively for a specific YAML node.
|
||||
This is useful when you need to preserve the `${...}` syntax as literal text.
|
||||
Inside a `!literal` section, you can use the `!sub` tag to re-enable substitution for a specific child node.
|
||||
When these tags overlap, the innermost tag always controls the final behavior.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
top: !literal
|
||||
foo: ${LITERAL}
|
||||
bar:
|
||||
baz: ${LITERAL}
|
||||
quux: !sub ${substituted}
|
||||
grault: ${LITERAL}
|
||||
```
|
||||
|
||||
## Expression Syntax
|
||||
|
||||
The Expression syntax is based on the [Jinja expression](https://jinja.palletsprojects.com/en/stable/templates/#expressions) language.
|
||||
Only expressions inside `${...}` are supported; template blocks like `{% if %}` or `{% for %}` are not available.
|
||||
|
||||
### Variable References
|
||||
|
||||
1. `label`: Refers to a scalar variable.
|
||||
1. `rooms[0]`: Refers to the first element of a list.
|
||||
1. `mqtt.broker` or `mqtt['broker']`: Refers to a map subkey.
|
||||
1. `mqtt[key]`: Resolves the key dynamically using the value of the `key` variable.
|
||||
|
||||
### Operations & Concatenation
|
||||
|
||||
An expression can include string, arithmetic, and boolean operations.
|
||||
|
||||
1. **String Concatenation**: Use the `~` operator (e.g., `"Room " ~ index`).
|
||||
1. **Coercion**: The `~` operator is the preferred way to join values because it automatically converts non-string operands into strings.
|
||||
1. **Automatic Joining**: Adjacent literal text and substitution patterns are automatically joined without operators (e.g., `value: "Hello ${username}"`).
|
||||
|
||||
> **Note:** Referencing an undefined variable resolves to `null`, logs a warning, and results in an empty string if used in a string context.
|
||||
|
||||
### List Concatenation with `+`
|
||||
|
||||
Jinja’s `+` operator supports list concatenation.
|
||||
If one side is a list and the other is a scalar, the scalar is automatically wrapped into a single-element list.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
groups: [Group1, Group2]
|
||||
location: SemanticLocationGroup
|
||||
|
||||
effective_groups: ${ groups + location }
|
||||
# Result: [Group1, Group2, SemanticLocationGroup]
|
||||
```
|
||||
|
||||
### Built-in Filters
|
||||
|
||||
Filters are applied using the `variable|filter` syntax and can be chained.
|
||||
|
||||
#### Text Transformation
|
||||
|
||||
| Filter | Description |
|
||||
|:-------------|:------------------------------------------------------------|
|
||||
| `capitalize` | Capitalize a value. |
|
||||
| `title` | Return a titlecased version. |
|
||||
| `lower` | Convert a value to lowercase. |
|
||||
| `upper` | Convert a value to uppercase. |
|
||||
| `replace` | Replace a substring. |
|
||||
| `trim` | Strip leading and trailing characters (default whitespace). |
|
||||
|
||||
#### Formatting
|
||||
|
||||
| Filter | Description |
|
||||
|:---------|:----------------------------------------------|
|
||||
| `format` | Apply values to a printf-style format string. |
|
||||
| `round` | Round a number to an optional precision. |
|
||||
| `int` | Convert a value into an integer. |
|
||||
|
||||
#### Collection Helpers
|
||||
|
||||
| Filter | Description |
|
||||
|:---------|:---------------------------------------|
|
||||
| `first` | Return the first item of a list. |
|
||||
| `length` | Return the length of a list or string. |
|
||||
|
||||
#### Fallbacks
|
||||
|
||||
| Filter | Description |
|
||||
|:----------|:--------------------------------------------------------------|
|
||||
| `default` | Return a default value if the variable is empty or undefined. |
|
||||
|
||||
**Default Example:**
|
||||
|
||||
```yaml
|
||||
label: ${room_label | default('Kitchen')}
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
|
||||
| Filter | Description |
|
||||
|:--------|:------------------------------------------------------------------------------------|
|
||||
| `label` | Converts identifiers (camelCase, snake_case) into human-friendly titles. |
|
||||
| `dig` | Safely navigates deep maps; returns `null` instead of an error if a key is missing. |
|
||||
|
||||
**`dig` Example:**
|
||||
|
||||
```yaml
|
||||
# Dot notation and mixed access supported
|
||||
user: ${ infrastructure | dig('config.login.user') }
|
||||
host: ${ VARS | dig('config', 'servers', 1, 'host') | default('localhost') }
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Predefined Variables
|
||||
|
||||
The Composer injects environmental and file-system context automatically.
|
||||
These variables can be interpolated just like regular ones and are helpful when constructing paths for directives.
|
||||
|
||||
| Variable | Description |
|
||||
|:-------------------|:------------------------------------------------------------------------|
|
||||
| `OPENHAB_CONF` | Absolute path to openHAB's main configuration directory. |
|
||||
| `OPENHAB_USERDATA` | Absolute path to openHAB's userdata directory. |
|
||||
| `__FILE__` | Absolute path to the current file. |
|
||||
| `__FILE_NAME__` | Filename portion without the extension or leading path. |
|
||||
| `__FILE_EXT__` | File extension portion of the current file name. |
|
||||
| `__DIRECTORY__` | Directory portion of the current file. |
|
||||
| `__DIR__` | Alias for `__DIRECTORY__`. |
|
||||
| `package_id` | Automatically resolved to the Package ID within included package files. |
|
||||
|
||||
### Handling Reserved Keywords
|
||||
|
||||
If a variable name is a Jinja keyword (like `and`, `or`, `if`), access it via the `VARS` dictionary.
|
||||
This also works for variable names containing characters like hyphens that are invalid in direct references.
|
||||
|
||||
```yaml
|
||||
foo: ${VARS['and']}
|
||||
```
|
||||
|
||||
### ENV to Access Environment Variables
|
||||
|
||||
A special variable `ENV` exposes a map of system environment variables.
|
||||
This is especially useful for configurations running within Docker containers.
|
||||
|
||||
```yaml
|
||||
mode: ${ENV.OPENHAB_MODE} # Resolves to the environment value
|
||||
```
|
||||
|
||||
### Calling Java Methods
|
||||
|
||||
Variables retain their Java types, allowing you to call standard methods directly.
|
||||
|
||||
Common types you may encounter include:
|
||||
|
||||
- [String](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html)
|
||||
- [Integer](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Integer.html)
|
||||
- [Double](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Double.html)
|
||||
- [Boolean](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Boolean.html)
|
||||
- [Map](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Map.html)
|
||||
- [List](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html)
|
||||
|
||||
This is useful for logic beyond built-in filters, such as complex regex replacements.
|
||||
|
||||
```yaml
|
||||
# Use Java String methods for complex logic
|
||||
is_sensor: ${ device_id.startsWith("sensor_") }
|
||||
clean_id: ${ device_id.replaceAll("ABC", "XYZ") }
|
||||
```
|
||||
|
||||
### Custom Pattern Delimiters
|
||||
|
||||
If your content conflicts with `${...}`, you can define custom delimiters using a named pattern.
|
||||
This allows the same pattern to be used consistently across all included files.
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
jinja: "{{..}}"
|
||||
|
||||
foo: !sub:jinja "Hello {{ username }}!"
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Unquoted Operators**: Expressions with operators should be quoted so YAML doesn't misinterpret characters.
|
||||
1. **Reserved Names**: Avoid naming variables using keywords like `true`, `false`, `null`, `in`, or `if`.
|
||||
1. **`+` vs `~`**: Use `~` for strings to avoid type mismatch errors and use `+` for numbers or lists.
|
||||
1. **Jinja Blocks**: Template blocks such as `{% for %}` are not supported; use inline `if` expressions instead.
|
||||
1. **Whitespace Sensitivity**: Spaces outside of quotes inside `${ ... }` are ignored, but spaces in quoted strings are preserved.
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.io.yamlcomposer</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: IO :: YAML Composer</name>
|
||||
|
||||
<properties>
|
||||
<bnd.importpackage>com.sun.jdi;resolution:=optional,com.sun.jdi.connect;resolution:=optional,com.sun.jdi.event;resolution:=optional,com.sun.jdi.request;resolution:=optional,com.sun.tools.attach;resolution:=optional,jdk.internal.module;resolution:=optional,sun.misc;resolution:=optional,*</bnd.importpackage>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.snakeyaml</groupId>
|
||||
<artifactId>snakeyaml-engine</artifactId>
|
||||
<version>3.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.osgiify</groupId>
|
||||
<artifactId>com.hubspot.jinjava.jinjava</artifactId>
|
||||
<version>2.7.4</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.re2j</groupId>
|
||||
<artifactId>re2j</artifactId>
|
||||
<version>1.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>33.5.0-jre</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.osgiify</groupId>
|
||||
<artifactId>com.hubspot.immutables.immutables-exceptions</artifactId>
|
||||
<version>1.9</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.20.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.15.3</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-net</groupId>
|
||||
<artifactId>commons-net</artifactId>
|
||||
<version>${commons.net.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.obermuhlner</groupId>
|
||||
<artifactId>big-math</artifactId>
|
||||
<version>2.3.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.googlecode.java-ipv6</groupId>
|
||||
<artifactId>java-ipv6</artifactId>
|
||||
<version>0.17</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.findbugs</groupId>
|
||||
<artifactId>jsr305</artifactId>
|
||||
<version>3.0.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.javassist</groupId>
|
||||
<artifactId>javassist</artifactId>
|
||||
<version>3.30.2-GA</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jdk8</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.io.yamlcomposer-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-misc-yamlcomposer" description="YAML Composer" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<feature dependency="true">openhab.tp-commons-net</feature>
|
||||
<bundle dependency="true">mvn:org.snakeyaml/snakeyaml-engine/3.0.1</bundle>
|
||||
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.jinjava.jinjava/2.7.4</bundle>
|
||||
<bundle dependency="true">mvn:org.openhab.osgiify/com.google.re2j.re2j/1.2</bundle>
|
||||
<bundle dependency="true">mvn:ch.obermuhlner/big-math/2.3.2</bundle>
|
||||
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.immutables.immutables-exceptions/1.9</bundle>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.io.yamlcomposer/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.helpers.MessageFormatter;
|
||||
|
||||
/**
|
||||
* A wrapper/decorator for SLF4J loggers that intercepts warnings for
|
||||
* consolidation through {@link LogSession} while passing other
|
||||
* log levels through immediately.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BufferedLogger {
|
||||
private final Logger delegate;
|
||||
private final LogSession session;
|
||||
|
||||
public BufferedLogger(Logger delegate, LogSession session) {
|
||||
this.delegate = delegate;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
public LogSession getLogSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts warnings and sends them to the session for count consolidation.
|
||||
*/
|
||||
public void warn(String template, Object... args) {
|
||||
String resolvedMessage = Objects.requireNonNull(MessageFormatter.arrayFormat(template, args).getMessage());
|
||||
session.trackWarning(delegate, resolvedMessage);
|
||||
}
|
||||
|
||||
// Pass-through methods for immediate logging
|
||||
public void info(String format, Object... args) {
|
||||
delegate.info(format, args);
|
||||
}
|
||||
|
||||
public void debug(String format, Object... args) {
|
||||
delegate.debug(format, args);
|
||||
}
|
||||
|
||||
public void trace(String format, Object... args) {
|
||||
delegate.trace(format, args);
|
||||
}
|
||||
|
||||
public void error(String format, Object... args) {
|
||||
delegate.error(format, args);
|
||||
}
|
||||
|
||||
// Simple string overloads
|
||||
public void info(String msg) {
|
||||
delegate.info(msg);
|
||||
}
|
||||
|
||||
public void debug(String msg) {
|
||||
delegate.debug(msg);
|
||||
}
|
||||
|
||||
public void trace(String msg) {
|
||||
delegate.trace(msg);
|
||||
}
|
||||
|
||||
public void error(String msg) {
|
||||
delegate.error(msg);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.OpenHAB;
|
||||
|
||||
/**
|
||||
* Static configuration constants and system path resolution for the YAML Composer.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class ComposerConfig {
|
||||
|
||||
private static final Path DEFAULT_CONFIG_ROOT = normalizeAbsolute(Path.of(OpenHAB.getConfigFolder()));
|
||||
private static final Path DEFAULT_USERDATA_ROOT = normalizeAbsolute(Path.of(OpenHAB.getUserDataFolder()));
|
||||
|
||||
private static volatile Path configRootOverride = DEFAULT_CONFIG_ROOT;
|
||||
private static volatile Path userDataRootOverride = DEFAULT_USERDATA_ROOT;
|
||||
|
||||
public static final Path SOURCE_ROOT_DIRECTORY = Path.of("yamlcomposer");
|
||||
public static final Path OUTPUT_ROOT_DIRECTORY = Path.of("yaml", "composed");
|
||||
|
||||
// Special section keys
|
||||
public static final String TEMPLATES_KEY = "templates";
|
||||
public static final String VARIABLES_KEY = "variables";
|
||||
public static final String PACKAGES_KEY = "packages";
|
||||
|
||||
// Preprocessing limits
|
||||
public static final int MAX_INCLUDE_DEPTH = 100;
|
||||
|
||||
private ComposerConfig() {
|
||||
// Static utility class
|
||||
}
|
||||
|
||||
public static Path configRoot() {
|
||||
return configRootOverride;
|
||||
}
|
||||
|
||||
public static Path userDataRoot() {
|
||||
return userDataRootOverride;
|
||||
}
|
||||
|
||||
public static Path sourceRoot() {
|
||||
return configRoot().resolve(SOURCE_ROOT_DIRECTORY);
|
||||
}
|
||||
|
||||
public static Path outputRoot() {
|
||||
return configRoot().resolve(OUTPUT_ROOT_DIRECTORY);
|
||||
}
|
||||
|
||||
static void setRootsForTesting(Path configRoot, Path userDataRoot) {
|
||||
configRootOverride = normalizeAbsolute(configRoot);
|
||||
userDataRootOverride = normalizeAbsolute(userDataRoot);
|
||||
}
|
||||
|
||||
static void resetRootsForTesting() {
|
||||
configRootOverride = DEFAULT_CONFIG_ROOT;
|
||||
userDataRootOverride = DEFAULT_USERDATA_ROOT;
|
||||
}
|
||||
|
||||
private static Path normalizeAbsolute(Path path) {
|
||||
return path.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the output path for a generated YAML file.
|
||||
*
|
||||
* @param sourcePath source file's absolute path
|
||||
* @return the path where the generated output should be written
|
||||
*/
|
||||
public static Path resolveOutputPath(Path sourcePath) {
|
||||
Path relativeToSourceRoot = sourceRoot().relativize(sourcePath);
|
||||
return outputRoot().resolve(relativeToSourceRoot);
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.openhab.io.yamlcomposer.internal.constructors.ModelConstructor;
|
||||
import org.snakeyaml.engine.v2.api.Dump;
|
||||
import org.snakeyaml.engine.v2.api.DumpSettings;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
import org.snakeyaml.engine.v2.common.FlowStyle;
|
||||
import org.snakeyaml.engine.v2.resolver.ScalarResolver;
|
||||
import org.snakeyaml.engine.v2.schema.CoreSchema;
|
||||
import org.snakeyaml.engine.v2.schema.Schema;
|
||||
|
||||
/**
|
||||
* Pure utility functions for YAML preprocessing.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
final class ComposerUtils {
|
||||
private static final String GENERATED_HEADER = """
|
||||
# ==============================================================================
|
||||
# Generated by openHAB %s (%s) YAML Composer, DO NOT EDIT
|
||||
# Source: %s
|
||||
# Generated: %s
|
||||
# ==============================================================================
|
||||
""";
|
||||
|
||||
private ComposerUtils() {
|
||||
// Static utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes top-level keys that start with a dot from the given map.
|
||||
*
|
||||
* @param dataMap the map to process
|
||||
*/
|
||||
static void removeHiddenKeys(Map<?, ?> dataMap) {
|
||||
dataMap.keySet().removeIf(key -> key instanceof String keyStr && keyStr.startsWith("."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the fully processed YAML representation as it would be seen by openHAB after preprocessing
|
||||
* to a compiled output file.
|
||||
*
|
||||
* @param dataMap the resulting data map to dump
|
||||
* @param sourcePath the absolute path of the source file being processed
|
||||
* @throws IOException if writing to the file fails
|
||||
*/
|
||||
static void writeCompiledOutput(Object dataMap, Path sourcePath, Path outputPath) throws IOException {
|
||||
|
||||
Path outputDir = outputPath.getParent();
|
||||
if (outputDir != null) {
|
||||
Files.createDirectories(outputDir);
|
||||
}
|
||||
|
||||
DumpSettings dumpSettings = DumpSettings.builder() //
|
||||
.setDefaultFlowStyle(FlowStyle.BLOCK) //
|
||||
.setIndentWithIndicator(true) //
|
||||
.setIndicatorIndent(2) //
|
||||
.setMultiLineFlow(true) //
|
||||
.build();
|
||||
Dump yaml = new Dump(dumpSettings);
|
||||
Object unaliasedData = breakAliases(dataMap);
|
||||
String compiledYaml = yaml.dumpToString(unaliasedData);
|
||||
compiledYaml = formatYamlSpacing(compiledYaml);
|
||||
|
||||
Path sourcePathRelative = ComposerConfig.configRoot().relativize(sourcePath);
|
||||
compiledYaml = GENERATED_HEADER.formatted(OpenHAB.getVersion(), OpenHAB.buildString(), sourcePathRelative,
|
||||
ZonedDateTime.now()) + "\n" + compiledYaml;
|
||||
|
||||
Files.writeString(outputPath, compiledYaml, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SnakeYAML Engine load instance with the custom composer constructor and resolver.
|
||||
*
|
||||
* @param sourcePath the source path of the YAML file for error reporting in the constructor
|
||||
* @return a new Load instance
|
||||
*/
|
||||
private static Load createYamlLoader(String sourcePath) {
|
||||
Schema schema = new CoreSchema() {
|
||||
@Override
|
||||
public ScalarResolver getScalarResolver() {
|
||||
return new ModelResolver();
|
||||
}
|
||||
};
|
||||
|
||||
LoadSettings loadSettings = LoadSettings.builder() //
|
||||
.setLabel(sourcePath) //
|
||||
.setAllowDuplicateKeys(false) //
|
||||
.setUseMarks(true) //
|
||||
.setSchema(schema) //
|
||||
.build();
|
||||
return new Load(loadSettings, new ModelConstructor(loadSettings, sourcePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads YAML content from the given byte array using a Yaml instance configured with the custom constructor.
|
||||
*
|
||||
* @param fileBytes the YAML content as a byte array
|
||||
* @param sourcePath the source path of the YAML file for error reporting in the constructor
|
||||
* @return the loaded YAML object
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
static @Nullable Object loadYaml(byte[] fileBytes, Path sourcePath) throws IOException {
|
||||
Load loader = createYamlLoader(sourcePath.toString());
|
||||
return loader.loadFromInputStream(new ByteArrayInputStream(fileBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a deep-copy of the common YAML structure objects (maps, lists, sets)
|
||||
* such that no container instance is shared more than once in the resulting
|
||||
* graph. This removes object identity sharing that SnakeYAML would otherwise
|
||||
* emit as anchors/aliases when dumping.
|
||||
*
|
||||
* @param root the loaded YAML object (may be Map, List, Set, scalar, or custom)
|
||||
* @return a deep-copied object graph with sharing broken
|
||||
*/
|
||||
private static @Nullable Object breakAliases(@Nullable Object root) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
return cloneNode(root, new IdentityHashMap<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processes a YAML string to inject empty lines for improved readability.
|
||||
*
|
||||
* <p>
|
||||
* This method applies two specific spacing rules:
|
||||
* <ul>
|
||||
* <li><strong>Level 0:</strong> Inserts a newline before every top-level key (except the first line).</li>
|
||||
* <li><strong>Level 2:</strong> Inserts a newline between sibling nodes indented by exactly two spaces.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The logic ensures that the first child of a section remains "glued" to its parent header,
|
||||
* and it safely handles both Map keys (e.g., {@code Key:}) and List items (e.g., {@code - Item}).
|
||||
*
|
||||
* @param yaml The raw YAML string to be formatted.
|
||||
* @return A formatted YAML string with injected "breathing room."
|
||||
*/
|
||||
private static @Nullable String formatYamlSpacing(@Nullable String yaml) {
|
||||
if (yaml == null || yaml.isEmpty()) {
|
||||
return yaml;
|
||||
}
|
||||
|
||||
String[] lines = yaml.split("\n");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
boolean hasSeenLevel0 = false;
|
||||
boolean hasSeenLevel2 = false;
|
||||
|
||||
for (String line : lines) {
|
||||
// --- Level 0 Logic (Sections) ---
|
||||
// Matches lines starting with a character (not space, not comment)
|
||||
if (!line.startsWith(" ") && !line.startsWith("#")) {
|
||||
if (hasSeenLevel0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
hasSeenLevel0 = true;
|
||||
hasSeenLevel2 = false; // Reset level 2 tracker for the new section
|
||||
}
|
||||
|
||||
// --- Level 2 Logic (Items or List Elements) ---
|
||||
// Matches exactly 2 spaces followed by a non-whitespace character.
|
||||
// This targets siblings within a top-level section.
|
||||
else if (line.matches("^ {2}[^ \\t].*")) {
|
||||
if (hasSeenLevel2) {
|
||||
sb.append("\n");
|
||||
}
|
||||
hasSeenLevel2 = true;
|
||||
}
|
||||
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clones a YAML node, breaking object identity to prevent SnakeYAML from emitting anchors/aliases.
|
||||
*
|
||||
* @param node The YAML node to clone.
|
||||
* @param stack The recursion stack used to handle cycles.
|
||||
* @return A deep-cloned YAML node with object identity broken.
|
||||
*/
|
||||
private static @Nullable Object cloneNode(@Nullable Object node, IdentityHashMap<Object, Object> stack) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
// Scalars and non-container objects are returned as-is
|
||||
if (!(node instanceof Map) && !(node instanceof List) && !(node instanceof Set)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// If the node is already on the current recursion stack, return the
|
||||
// partially-built copy to handle cycles. We do NOT reuse completed
|
||||
// copies for separate occurrences — that would preserve sharing and
|
||||
// produce aliases.
|
||||
if (stack.containsKey(node)) {
|
||||
return stack.get(node);
|
||||
}
|
||||
|
||||
if (node instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<@Nullable Object, @Nullable Object> original = (Map<@Nullable Object, @Nullable Object>) node;
|
||||
Map<@Nullable Object, @Nullable Object> copy = new LinkedHashMap<>(original.size());
|
||||
stack.put(node, copy);
|
||||
for (Map.Entry<@Nullable Object, @Nullable Object> e : original.entrySet()) {
|
||||
Object k = e.getKey();
|
||||
Object v = e.getValue();
|
||||
Object newKey = cloneNode(k, stack);
|
||||
Object newVal = cloneNode(v, stack);
|
||||
copy.put(newKey, newVal);
|
||||
}
|
||||
// Remove from stack so later separate occurrences are cloned again
|
||||
stack.remove(node);
|
||||
return copy;
|
||||
}
|
||||
|
||||
if (node instanceof List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<@Nullable Object> original = (List<@Nullable Object>) node;
|
||||
List<@Nullable Object> copy = new ArrayList<>(original.size());
|
||||
stack.put(node, copy);
|
||||
for (@Nullable
|
||||
Object item : original) {
|
||||
copy.add(cloneNode(item, stack));
|
||||
}
|
||||
stack.remove(node);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Set (preserve insertion order if possible)
|
||||
if (node instanceof Set) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Set<@Nullable Object> original = (Set<@Nullable Object>) node;
|
||||
Set<@Nullable Object> copy = new LinkedHashSet<>(original.size());
|
||||
stack.put(node, copy);
|
||||
for (Object item : original) {
|
||||
copy.add(cloneNode(item, stack));
|
||||
}
|
||||
stack.remove(node);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Fallback: return node as-is
|
||||
return node;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link IncludeRegistry} manages a bidirectional association between
|
||||
* main files and the include files they reference.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class IncludeRegistry {
|
||||
private final Map<Path, Set<Path>> mainToIncludes = new HashMap<>();
|
||||
private final Map<Path, Set<Path>> includeToMains = new HashMap<>();
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public synchronized void registerInclude(Path mainFile, Path include) {
|
||||
mainToIncludes.computeIfAbsent(mainFile, key -> new HashSet<>()).add(include);
|
||||
includeToMains.computeIfAbsent(include, key -> new HashSet<>()).add(mainFile);
|
||||
}
|
||||
|
||||
public synchronized void removeMain(Path mainFile) {
|
||||
Set<Path> includes = mainToIncludes.remove(mainFile);
|
||||
if (includes == null) {
|
||||
return;
|
||||
}
|
||||
includes.forEach(include -> {
|
||||
Set<Path> mains = includeToMains.get(include);
|
||||
if (mains != null) {
|
||||
mains.remove(mainFile);
|
||||
if (mains.isEmpty()) {
|
||||
includeToMains.remove(include);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized Set<Path> getMainsForInclude(Path include) {
|
||||
return new HashSet<>(includeToMains.getOrDefault(include, Set.of()));
|
||||
}
|
||||
|
||||
public synchronized Set<Path> getIncludesForMain(Path mainFile) {
|
||||
return new HashSet<>(mainToIncludes.getOrDefault(mainFile, Set.of()));
|
||||
}
|
||||
|
||||
public synchronized boolean hasInclude(Path include) {
|
||||
Set<Path> mains = includeToMains.get(include);
|
||||
return mains != null && !mains.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized void clear() {
|
||||
mainToIncludes.clear();
|
||||
includeToMains.clear();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Coordinates log aggregation across multiple classes and instances during a single
|
||||
* processing session.
|
||||
* <p>
|
||||
* It collects warnings and their counts, then flushes them with a summary suffix
|
||||
* (e.g., " (3 times)") when the session is closed. This prevents log flooding
|
||||
* during recursive operations like YAML !includes.
|
||||
* </p>
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class LogSession implements AutoCloseable {
|
||||
private final Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
private final Map<String, Logger> loggers = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Records a warning message for later consolidation.
|
||||
*/
|
||||
public void trackWarning(Logger logger, String resolvedMessage) {
|
||||
loggers.putIfAbsent(resolvedMessage, logger);
|
||||
counts.merge(resolvedMessage, 1, Integer::sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an unmodifiable list of tracked warning messages in the order they were recorded.
|
||||
* Primarily used for verification in unit tests.
|
||||
*/
|
||||
List<String> getTrackedWarnings() {
|
||||
return List.copyOf(counts.keySet());
|
||||
}
|
||||
|
||||
public int getTotalWarningCount() {
|
||||
return counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues all buffered warnings to their respective loggers with occurrence counts.
|
||||
*/
|
||||
public void flush() {
|
||||
counts.forEach((msg, count) -> {
|
||||
String output = msg;
|
||||
if (count > 1) {
|
||||
String separator = msg.contains("\n") ? System.lineSeparator() : " ";
|
||||
output = msg + separator + "(" + count + " times)";
|
||||
}
|
||||
Objects.requireNonNull(loggers.get(msg)).warn(output);
|
||||
});
|
||||
counts.clear();
|
||||
loggers.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.io.yamlcomposer.internal.constructors.ModelConstructor;
|
||||
import org.snakeyaml.engine.v2.nodes.Tag;
|
||||
import org.snakeyaml.engine.v2.resolver.CoreScalarResolver;
|
||||
|
||||
/**
|
||||
* A custom scalar resolver for SnakeYAML Engine that shifts structural processing
|
||||
* from the engine's native layer to our internal composer layer.
|
||||
*
|
||||
* <p>
|
||||
* This implementation modifies the default SnakeYAML Engine resolution in two key ways:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Custom Merge Key Handling:</b> It disables the default merge key ({@code <<})
|
||||
* resolution. Instead, it registers a custom implicit resolver that maps the
|
||||
* merge key to a unique tag. This allows the composer to handle object
|
||||
* merging manually, providing more control over the transformation lifecycle.</li>
|
||||
*
|
||||
* <li><b>Collision Prevention:</b> It opts out of the default {@code ENV_TAG}
|
||||
* resolver. This ensures that the standard YAML environment variable syntax
|
||||
* ({@code ${VAR}}) does not interfere with our custom substitution syntax.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class ModelResolver extends CoreScalarResolver {
|
||||
ModelResolver() {
|
||||
super(false);
|
||||
addImplicitResolver(ModelConstructor.DEFERRED_MERGE_TAG, MERGE, "<");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public void addImplicitResolver(Tag tag, Pattern regexp, String first) {
|
||||
if (!Tag.ENV_TAG.equals(tag)) {
|
||||
super.addImplicitResolver(tag, regexp, first);
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.expression.ExpressionEvaluator;
|
||||
|
||||
/**
|
||||
* The {@link StringInterpolator} provides utility methods for performing
|
||||
* variable substitution and expression evaluation in YAML processing.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class StringInterpolator {
|
||||
static final String DEFAULT_BEGIN = "${";
|
||||
static final String DEFAULT_END = "}";
|
||||
|
||||
/**
|
||||
* Matches ${...} interpolation patterns in YAML strings.
|
||||
*
|
||||
* Allows nested quotes like default('{foo}') without prematurely stopping at
|
||||
* the inner '}'
|
||||
*
|
||||
* Examples:
|
||||
* - ${name} : Simple variable substitution
|
||||
* - ${ name|capitalize } : Apply capitalize filter
|
||||
* - ${ name|default('value') } : Use default if variable is not set
|
||||
* - ${ name|upper } : Convert to uppercase
|
||||
*/
|
||||
public static final Pattern DEFAULT_SUBSTITUTION_PATTERN = Pattern.compile("""
|
||||
\\$\\{
|
||||
(?<content>
|
||||
(?:
|
||||
"[^"]*" |
|
||||
'[^']*' |
|
||||
\\{[^}]*?\\} |
|
||||
[^}]
|
||||
)*?
|
||||
)
|
||||
\\}
|
||||
""", Pattern.COMMENTS | Pattern.DOTALL);
|
||||
|
||||
private StringInterpolator() {
|
||||
// Utility class, no instances
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a substitution pattern from begin and end delimiters.
|
||||
*
|
||||
* @param begin the opening delimiter (e.g., "${")
|
||||
* @param end the closing delimiter (e.g., "}")
|
||||
* @return the compiled pattern
|
||||
*/
|
||||
public static Pattern compileSubstitutionPattern(String begin, String end) {
|
||||
if (DEFAULT_BEGIN.equals(begin) && DEFAULT_END.equals(end)) {
|
||||
return DEFAULT_SUBSTITUTION_PATTERN;
|
||||
}
|
||||
String quotedBegin = Pattern.quote(begin);
|
||||
String quotedEnd = Pattern.quote(end);
|
||||
// Allow quoted segments to contain the end delimiter; otherwise stop at the
|
||||
// first closing delimiter.
|
||||
String content = """
|
||||
"[^"]*"|'[^']*'|\\{[^}]*?\\}|(?:(?!%s).)
|
||||
""".formatted(quotedEnd).strip();
|
||||
String regex = quotedBegin + "(?<content>(" + content + ")*?)" + quotedEnd;
|
||||
return Pattern.compile(regex, Pattern.DOTALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a substitution pattern from a pattern specification string with format
|
||||
* {@code <begin>..<end>}.
|
||||
*
|
||||
* @param patternSpec the pattern specification, e.g. {@code $[[..]]}
|
||||
* @return the compiled pattern, or {@code null} when the specification is invalid
|
||||
*/
|
||||
public static @Nullable Pattern compilePatternSpec(String patternSpec) {
|
||||
int separator = patternSpec.indexOf("..");
|
||||
if (separator <= 0 || separator >= patternSpec.length() - 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String begin = patternSpec.substring(0, separator);
|
||||
String end = patternSpec.substring(separator + 2);
|
||||
if (begin.isEmpty() || end.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return compileSubstitutionPattern(begin, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a variable value, performing substitution if needed.
|
||||
* Returns the native object type if the entire value is a single placeholder,
|
||||
* otherwise returns a string with interpolated values.
|
||||
*
|
||||
* @param value the raw string value that may contain placeholder patterns to substitute
|
||||
* @param pattern the substitution pattern to use
|
||||
* @param variables the variable map
|
||||
* @param logSession the logging session
|
||||
* @param sourceLocation description of the source location for logging
|
||||
* @return the evaluated value
|
||||
*/
|
||||
public static @Nullable Object interpolate(String value, Pattern pattern, Map<String, @Nullable Object> variables,
|
||||
LogSession logSession, String sourceLocation) {
|
||||
Matcher matcher = pattern.matcher(value);
|
||||
if (!matcher.find()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If the whole value is exactly one ${...}, evaluate it and return the native object.
|
||||
// This preserves non-string types (maps, lists, numbers, booleans)
|
||||
// when the value is a plain placeholder.
|
||||
if (matcher.matches()) {
|
||||
String content = Objects.requireNonNull(matcher.group("content"));
|
||||
return evaluateExpression(content, variables, logSession, sourceLocation);
|
||||
}
|
||||
|
||||
// Evaluate the expressions inside the ${...} patterns and replace them in the string.
|
||||
String interpolated = matcher.replaceAll(match -> {
|
||||
String content = Objects.requireNonNull(match.group("content"));
|
||||
Object result = evaluateExpression(content, variables, logSession, sourceLocation);
|
||||
String rendered = result != null ? result.toString() : "";
|
||||
return Matcher.quoteReplacement(rendered);
|
||||
});
|
||||
|
||||
return interpolated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a Jinjava expression.
|
||||
*
|
||||
* When an error occurs during evaluation, and null is returned.
|
||||
* A warning is logged and consolidated through logSession
|
||||
*
|
||||
* @param expression the expression to evaluate without the ${} delimiters
|
||||
* @param variables the variable context for evaluation
|
||||
* @param logSession the logging session for warnings and errors during evaluation
|
||||
* @param sourceLocation description of the source location for logging
|
||||
* @return the evaluated result
|
||||
*/
|
||||
public static @Nullable Object evaluateExpression(String expression, Map<String, @Nullable Object> variables,
|
||||
LogSession logSession, String sourceLocation) {
|
||||
Object rendered = ExpressionEvaluator.renderObject(expression, variables, logSession, sourceLocation);
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.core.PackageProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.core.ProcessingPhase;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.core.SourceLocator;
|
||||
import org.openhab.io.yamlcomposer.internal.core.TemplateLoader;
|
||||
import org.openhab.io.yamlcomposer.internal.core.VariableLoader;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.IfProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.IncludeProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.InsertProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.RemoveProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.ReplaceProcessor;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.SubstitutionProcessor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.snakeyaml.engine.v2.exceptions.Mark;
|
||||
import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException;
|
||||
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
|
||||
|
||||
/**
|
||||
* The {@link YamlComposer} is a utility class to load YAML files
|
||||
* and process them into a final YAML structure that openHAB can use.
|
||||
*
|
||||
* The following features are supported:
|
||||
*
|
||||
* <ul>
|
||||
* <li>YAML Anchors and aliases.
|
||||
* <li>YAML Merge keys (<code><<</code>) to allow merging of maps with override semantics.
|
||||
* <li>Variable substitution and interpolation using <code>${var}</code> syntax.
|
||||
* <li>Conditional evaluation using <code>!if</code> tag with simple boolean logic.
|
||||
* <li><code>!include</code> tag for including other YAML files.
|
||||
* <li><code>!insert</code> tag for inserting template content with local variable substitution.
|
||||
* <li>Combining elements using packages.
|
||||
* </ul>
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class YamlComposer {
|
||||
private static final Logger RAW_LOGGER = LoggerFactory.getLogger(YamlComposer.class);
|
||||
|
||||
private final BufferedLogger logger;
|
||||
|
||||
public static record CacheEntry(byte[] bytes, long mtime) {
|
||||
}
|
||||
|
||||
private final Path absolutePath;
|
||||
private final Path relativePath;
|
||||
|
||||
private final Map<String, @Nullable Object> variables;
|
||||
private final Map<Object, @Nullable Object> templates;
|
||||
|
||||
private final Set<Path> includeStack;
|
||||
private final ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache;
|
||||
|
||||
private final RecursiveTransformer recursiveTransformer;
|
||||
|
||||
/**
|
||||
* Constructs a YamlComposer for the given file path and context.
|
||||
*
|
||||
* @param path the file path for resolving relative includes
|
||||
* @param variables initial variable context
|
||||
* @param includeStack current include stack for circular reference detection
|
||||
* @param includeCallback callback invoked for each included file
|
||||
* @param logSession the log session for warning consolidation
|
||||
* @param includeCache the cache for included files
|
||||
* @throws YamlEngineException if a circular include is detected or if the maximum include depth is exceeded
|
||||
*/
|
||||
public YamlComposer(Path path, Map<String, @Nullable Object> variables, Set<Path> includeStack,
|
||||
Consumer<Path> includeCallback, LogSession logSession,
|
||||
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache) {
|
||||
this.absolutePath = Objects.requireNonNull(path.toAbsolutePath().normalize());
|
||||
this.relativePath = ComposerConfig.configRoot().relativize(absolutePath);
|
||||
this.logger = new BufferedLogger(RAW_LOGGER, logSession);
|
||||
this.variables = new HashMap<>(variables);
|
||||
this.includeCache = includeCache;
|
||||
this.templates = new HashMap<>();
|
||||
|
||||
// Validate circular inclusion and depth before processing
|
||||
Set<Path> newIncludeStack = new LinkedHashSet<>(includeStack);
|
||||
if (!newIncludeStack.add(absolutePath)) {
|
||||
@SuppressWarnings("null")
|
||||
String includeStackChain = newIncludeStack.stream().map(Path::toString).collect(Collectors.joining(" -> "));
|
||||
throw new YamlEngineException(
|
||||
"Circular inclusion detected: %s -> %s".formatted(includeStackChain, absolutePath));
|
||||
}
|
||||
|
||||
if (newIncludeStack.size() > ComposerConfig.MAX_INCLUDE_DEPTH) {
|
||||
throw new YamlEngineException("Maximum include depth (" + ComposerConfig.MAX_INCLUDE_DEPTH + ") exceeded");
|
||||
}
|
||||
|
||||
this.includeStack = newIncludeStack;
|
||||
|
||||
this.recursiveTransformer = new RecursiveTransformer(this.variables);
|
||||
|
||||
this.recursiveTransformer.register(new SubstitutionProcessor(logger));
|
||||
this.recursiveTransformer.register(new IfProcessor(logger));
|
||||
this.recursiveTransformer.register(
|
||||
new IncludeProcessor(absolutePath.getParent(), newIncludeStack, includeCallback, includeCache, logger));
|
||||
this.recursiveTransformer.register(new InsertProcessor(templates, logger));
|
||||
this.recursiveTransformer.register(new RemoveProcessor());
|
||||
this.recursiveTransformer.register(new ReplaceProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a YAML file from the given {@link Path} and processes it through the
|
||||
* full composer pipeline.
|
||||
* <p>
|
||||
* This is the main entry point for the YAML Composer. It reads the file,
|
||||
* parses the YAML, and applies all supported composer features.
|
||||
* <p>
|
||||
* The {@code includeCallback} is invoked for each file referenced via an
|
||||
* include directive, allowing the caller to track include usage so it can
|
||||
* refresh models when included files change.
|
||||
* <p>
|
||||
* The returned value is the fully evaluated Java object representation of the
|
||||
* YAML document after all the processing steps have been applied.
|
||||
*
|
||||
* @param path the path to the YAML file to load and process; also used as
|
||||
* the base directory for resolving relative includes
|
||||
* @param includeCallback a callback invoked for each included file
|
||||
* @return the processed Java object representation of the YAML file
|
||||
* @throws IOException if the file cannot be read or if processing fails
|
||||
*/
|
||||
public static @Nullable Object load(Path path, Consumer<Path> includeCallback) throws IOException {
|
||||
// Create a LogSession autocloseable object. It consolidates warnings and duplicates.
|
||||
// Upon exit, any warnings will be logged.
|
||||
try (LogSession session = new LogSession()) {
|
||||
ConcurrentHashMap<Path, @Nullable CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
return load(path, includeCallback, session, cache);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to allow passing in a LogSession so we can manage it externally in tests.
|
||||
*
|
||||
* @param path the file path for resolving relative includes
|
||||
* @param includeCallback callback invoked for each included file
|
||||
* @param logSession the LogSession to use for logging warnings during loading
|
||||
* @param includeCache the cache for included files to optimize repeated loads
|
||||
* @return the processed Java object representation of the YAML file
|
||||
* @throws IOException if there is an error reading or processing the YAML
|
||||
*/
|
||||
static @Nullable Object load(Path path, Consumer<Path> includeCallback, LogSession logSession,
|
||||
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache) throws IOException {
|
||||
Path absolutePath = path.toAbsolutePath().normalize();
|
||||
Path relativePath = ComposerConfig.configRoot().relativize(absolutePath);
|
||||
try {
|
||||
YamlComposer composer = new YamlComposer(absolutePath, Map.of(), Set.of(), includeCallback, logSession,
|
||||
includeCache);
|
||||
Object result = composer.load();
|
||||
|
||||
// Print a summary of warnings before the LogSession outputs all the warnings.
|
||||
int totalWarnings = logSession.getTotalWarningCount();
|
||||
if (totalWarnings > 0) {
|
||||
int unique = logSession.getTrackedWarnings().size();
|
||||
String issuesLabel = (unique == 1) ? "unique issue" : "unique issues";
|
||||
String warningLabel = (totalWarnings == 1) ? "warning" : "warnings";
|
||||
|
||||
RAW_LOGGER.warn("YAML Composer {}: Preprocessing completed with {} {} ({} {}).", relativePath,
|
||||
totalWarnings, warningLabel, unique, issuesLabel);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (MarkedYamlEngineException e) {
|
||||
String errorMsg = e.getMessage();
|
||||
Mark mark = e.getProblemMark().orElse(null);
|
||||
if (mark != null) {
|
||||
String location = "%d:%d".formatted(mark.getLine() + 1, mark.getColumn() + 1);
|
||||
String errorClass = e.getClass().getSimpleName();
|
||||
errorMsg = "\n%s:%s %s %s".formatted(relativePath, location, errorClass, e.getMessage());
|
||||
}
|
||||
throw new IOException(errorMsg, e);
|
||||
} catch (YamlEngineException e) {
|
||||
throw new IOException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal load method that performs the actual loading and processing of the YAML file.
|
||||
*
|
||||
* @return the processed Java object representation of the YAML file
|
||||
* @throws IOException if there is an error reading the YAML file
|
||||
* @throws YamlEngineException if there is an error during YAML parsing or processing
|
||||
*/
|
||||
public @Nullable Object load() throws IOException, YamlEngineException {
|
||||
logger.debug("Loading file({}): {} with given vars {}", includeStack.size(), absolutePath, variables);
|
||||
|
||||
// Phase 1: Parse YAML and initialize helper objects
|
||||
byte[] yamlBytes = readYamlBytes();
|
||||
SourceLocator locator = new SourceLocator(yamlBytes);
|
||||
|
||||
// Phase 2: set up initial variables
|
||||
VariableLoader variableLoader = new VariableLoader(variables, absolutePath, recursiveTransformer, logger);
|
||||
variableLoader.setSpecialVariables();
|
||||
|
||||
// Phase 3: load and parse YAML
|
||||
Object yamlObj = ComposerUtils.loadYaml(yamlBytes, relativePath);
|
||||
if (!(yamlObj instanceof Map<?, ?>)) {
|
||||
yamlObj = recursiveTransformer.transform(yamlObj, ProcessingPhase.STANDARD);
|
||||
|
||||
if (!(yamlObj instanceof Map<?, ?>)) {
|
||||
return yamlObj;
|
||||
}
|
||||
}
|
||||
|
||||
Map<?, ?> yamlMap = (Map<?, ?>) yamlObj;
|
||||
|
||||
// Phase 4: extract variables and templates
|
||||
Object variablesSection = removeByScalarKey(yamlMap, ComposerConfig.VARIABLES_KEY);
|
||||
variableLoader.extractVariables(variablesSection, locator);
|
||||
|
||||
// Extract templates because we want to defer substitutions in the templates
|
||||
// until the template is instantiated with !insert, so that the variable context
|
||||
// includes any variables passed in the !insert directive.
|
||||
Object templatesSection = removeByScalarKey(yamlMap, ComposerConfig.TEMPLATES_KEY);
|
||||
new TemplateLoader(logger, relativePath, templates, recursiveTransformer, locator)
|
||||
.extractTemplates(templatesSection);
|
||||
|
||||
// Phase 5: extract/remove packages from the main data because we want to
|
||||
// inject the package_id into each package context
|
||||
@Nullable
|
||||
Object packagesObj = removeByScalarKey(yamlMap, ComposerConfig.PACKAGES_KEY);
|
||||
|
||||
// Phase 6: Resolve merge keys and process substitutions, conditionals, includes and inserts
|
||||
// in a single pass so that merge keys can merge data produced by includes/inserts.
|
||||
yamlMap = recursiveTransformer.transform(yamlMap, ProcessingPhase.STANDARD);
|
||||
|
||||
// Phase 7: process and merge packages
|
||||
new PackageProcessor(logger, absolutePath, relativePath, locator, recursiveTransformer).mergePackages(yamlMap,
|
||||
packagesObj);
|
||||
|
||||
// Phase 8: process override placeholders (!replace, !remove)
|
||||
yamlMap = recursiveTransformer.transform(yamlMap, ProcessingPhase.PACKAGE_OVERRIDES);
|
||||
|
||||
// Phase 9: final cleanup and optional compiled output
|
||||
ComposerUtils.removeHiddenKeys(yamlMap);
|
||||
|
||||
return yamlMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the YAML file bytes with caching based on file modification time to optimize repeated loads of the same
|
||||
* file. The bytes are cached in {@link #includeCache}.
|
||||
*
|
||||
* @return the YAML file bytes
|
||||
* @throws IOException if there is an error reading the file
|
||||
*/
|
||||
private byte[] readYamlBytes() throws IOException {
|
||||
CacheEntry cached = includeCache.get(absolutePath);
|
||||
long currentMtime = Files.getLastModifiedTime(absolutePath).toMillis();
|
||||
|
||||
if (cached != null && cached.mtime == currentMtime) {
|
||||
return cached.bytes;
|
||||
}
|
||||
|
||||
byte[] yamlBytes = Files.readAllBytes(absolutePath);
|
||||
includeCache.put(absolutePath, new CacheEntry(yamlBytes, currentMtime));
|
||||
return yamlBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given file name is an include file based on its extension.
|
||||
* An include file ends with .inc.yml or .inc.yaml.
|
||||
*
|
||||
* @param fileName the name of the file to check
|
||||
* @return true if it's an include file, false otherwise
|
||||
*/
|
||||
public static boolean isIncludeFile(String fileName) {
|
||||
return fileName.endsWith(".inc.yml") || fileName.endsWith(".inc.yaml");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given file name is a Yaml file based on its extension.
|
||||
* A Yaml file ends with .yml or .yaml.
|
||||
*
|
||||
* @param fileName the name of the file to check
|
||||
* @return true if it's a Yaml file, false otherwise
|
||||
*/
|
||||
public static boolean isYamlFile(String fileName) {
|
||||
return fileName.endsWith(".yml") || fileName.endsWith(".yaml");
|
||||
}
|
||||
|
||||
private static @Nullable Object removeByScalarKey(Map<?, ?> map, String key) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<@Nullable Object, @Nullable Object> mutableMap = (Map<@Nullable Object, @Nullable Object>) map;
|
||||
|
||||
for (Iterator<Map.Entry<@Nullable Object, @Nullable Object>> iterator = mutableMap.entrySet()
|
||||
.iterator(); iterator.hasNext();) {
|
||||
Map.Entry<@Nullable Object, @Nullable Object> entry = iterator.next();
|
||||
@Nullable
|
||||
Object entryKey = entry.getKey();
|
||||
|
||||
if (entryKey == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean keyMatches = switch (entryKey) {
|
||||
case String s -> key.equals(s);
|
||||
case SubstitutionPlaceholder p -> key.equals(p.value());
|
||||
default -> false;
|
||||
};
|
||||
|
||||
if (keyMatches) {
|
||||
Object value = entry.getValue();
|
||||
iterator.remove();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Path getAbsolutePath() {
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
public Path getRelativePath() {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.service.WatchService;
|
||||
import org.openhab.core.service.WatchService.Kind;
|
||||
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;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Watches OPENHAB_CONF/yamlcomposer and writes composed YAML files to OPENHAB_CONF/yaml/composed.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(immediate = true, service = YamlComposerWatchService.class)
|
||||
public class YamlComposerWatchService implements WatchService.WatchEventListener {
|
||||
private static final Path WATCHED_SOURCE_PATH = ComposerConfig.SOURCE_ROOT_DIRECTORY;
|
||||
|
||||
private static final String OUTPUT_ROOT_README = """
|
||||
This directory contains YAML files generated by the YAML Composer add-on.
|
||||
Do not edit these files manually, as they will be overwritten by the composer.
|
||||
Instead, edit the source YAML files in the 'yamlcomposer' directory and let the composer generate the output here.
|
||||
""";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(YamlComposerWatchService.class);
|
||||
private final IncludeRegistry includeRegistry = new IncludeRegistry();
|
||||
private final WatchService watchService;
|
||||
|
||||
@Activate
|
||||
public YamlComposerWatchService(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) {
|
||||
this.watchService = watchService;
|
||||
|
||||
try {
|
||||
Path outputRoot = ComposerConfig.outputRoot();
|
||||
boolean brandNew = !Files.exists(outputRoot);
|
||||
|
||||
Files.createDirectories(ComposerConfig.sourceRoot());
|
||||
Files.createDirectories(outputRoot);
|
||||
|
||||
if (brandNew) {
|
||||
Files.writeString(outputRoot.resolve("readme.txt"), OUTPUT_ROOT_README);
|
||||
}
|
||||
processDirectory(ComposerConfig.sourceRoot());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Cannot prepare yaml composer directories: {}", e.getMessage());
|
||||
}
|
||||
|
||||
watchService.registerListener(this, WATCHED_SOURCE_PATH);
|
||||
}
|
||||
|
||||
@Deactivate
|
||||
public void deactivate() {
|
||||
watchService.unregisterListener(this);
|
||||
includeRegistry.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void processWatchEvent(Kind kind, Path fullPath) {
|
||||
Path sourcePath = fullPath;
|
||||
if (kind != Kind.DELETE && (Files.isDirectory(sourcePath) || !Files.isReadable(sourcePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (includeRegistry.hasInclude(sourcePath)) {
|
||||
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
|
||||
logger.info("YAML Composer: detected change in included file '{}', recompiling affected main files",
|
||||
relativeSourcePath);
|
||||
Set<Path> dependingMains = includeRegistry.getMainsForInclude(sourcePath);
|
||||
dependingMains.forEach(mainFilePath -> processWatchEvent(Kind.MODIFY, mainFilePath));
|
||||
return;
|
||||
}
|
||||
|
||||
String fileName = sourcePath.getFileName().toString();
|
||||
if (!YamlComposer.isYamlFile(fileName) || YamlComposer.isIncludeFile(fileName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
includeRegistry.removeMain(sourcePath);
|
||||
|
||||
Path outputPath = ComposerConfig.resolveOutputPath(sourcePath);
|
||||
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
|
||||
Path relativeOutputPath = ComposerConfig.configRoot().relativize(outputPath);
|
||||
if (kind == Kind.DELETE) {
|
||||
try {
|
||||
Files.deleteIfExists(outputPath);
|
||||
logger.info("YAML Composer: DELETED generated file '{}'", relativeOutputPath);
|
||||
} catch (IOException e) {
|
||||
logger.warn("YAML Composer: failed to delete generated file '{}': {}", relativeOutputPath,
|
||||
e.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Object yamlObject = YamlComposer.load(sourcePath,
|
||||
includePath -> includeRegistry.registerInclude(sourcePath, includePath));
|
||||
if (yamlObject == null) {
|
||||
logger.warn("YAML Composer produced no output when processing '{}'", relativeSourcePath);
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Path> includePaths = includeRegistry.getIncludesForMain(sourcePath);
|
||||
if (!isOutputUpToDate(sourcePath, includePaths, outputPath)) {
|
||||
ComposerUtils.writeCompiledOutput(yamlObject, sourcePath, outputPath);
|
||||
logger.info("YAML Composer: {} -> {}", relativeSourcePath, relativeOutputPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("YAML Composer failed to process '{}': {}", relativeSourcePath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isOutputUpToDate(Path sourcePath, Set<Path> includePaths, Path outputPath) {
|
||||
if (!Files.exists(outputPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
long outputMtime = Files.getLastModifiedTime(outputPath).toMillis();
|
||||
long sourceMtime = Files.getLastModifiedTime(sourcePath).toMillis();
|
||||
if (outputMtime <= sourceMtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Path includePath : includePaths) {
|
||||
long includeMtime = Files.getLastModifiedTime(includePath).toMillis();
|
||||
if (outputMtime <= includeMtime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
logger.debug("Failed to compare mtime for '{}' and '{}': {}", outputPath, sourcePath, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void processDirectory(Path sourceDirectory) {
|
||||
try {
|
||||
Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(@Nullable Path sourcePath, @Nullable BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
|
||||
if (sourcePath == null || attrs == null || !attrs.isRegularFile()) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
String fileName = sourcePath.getFileName().toString();
|
||||
|
||||
if (YamlComposer.isYamlFile(fileName) && !YamlComposer.isIncludeFile(fileName)) {
|
||||
processWatchEvent(Kind.CREATE, sourcePath);
|
||||
}
|
||||
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(@Nullable Path sourcePath, @Nullable IOException exc)
|
||||
throws IOException {
|
||||
if (sourcePath != null && exc != null) {
|
||||
Path relativeSourcePath = ComposerConfig.configRoot().relativize(sourcePath);
|
||||
logger.warn("Failed to process {}: {}", relativeSourcePath, exc.getMessage());
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not list YAML source files in '{}': {}", sourceDirectory, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.constructors;
|
||||
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
|
||||
import org.snakeyaml.engine.v2.api.ConstructNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
|
||||
/**
|
||||
* The {@link ConstructInterpolablePlaceholder} is a generic constructor used to create
|
||||
* placeholder objects for nodes whose value can be interpolated by {@link YamlComposer}.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class ConstructInterpolablePlaceholder<T extends InterpolablePlaceholder<T>> implements ConstructNode {
|
||||
private final BiFunction<@Nullable Object, String, T> creator;
|
||||
private final ModelConstructor constructor;
|
||||
|
||||
ConstructInterpolablePlaceholder(ModelConstructor constructor, BiFunction<@Nullable Object, String, T> creator) {
|
||||
this.constructor = constructor;
|
||||
this.creator = creator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object construct(@Nullable Node node) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = constructor.constructByType(node);
|
||||
return creator.apply(value, constructor.getLocation(node));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.constructors;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.snakeyaml.engine.v2.api.ConstructNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
|
||||
/**
|
||||
* The {@link ConstructLiteral} is a constructor for the <code>!literal</code> tag.
|
||||
*
|
||||
* It just needs to resolve our custom tag and construct the underlying node
|
||||
* as usual.
|
||||
*
|
||||
* We don't need a special placeholder since the tracking of substitution state
|
||||
* is done in {@link ModelConstructor#constructObject(Node)}.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class ConstructLiteral implements ConstructNode {
|
||||
protected final ModelConstructor constructor;
|
||||
|
||||
ConstructLiteral(ModelConstructor constructor) {
|
||||
this.constructor = constructor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public @Nullable Object construct(Node node) {
|
||||
return constructor.constructByType(Objects.requireNonNull(node));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.constructors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.snakeyaml.engine.v2.api.ConstructNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
import org.snakeyaml.engine.v2.nodes.ScalarNode;
|
||||
|
||||
/**
|
||||
* The {@link ConstructStr} is the constructor used for STR tag which
|
||||
* may create a {@link org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder}, depending on the
|
||||
* current
|
||||
* substitution stack.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class ConstructStr implements ConstructNode {
|
||||
private final ModelConstructor constructor;
|
||||
|
||||
ConstructStr(ModelConstructor constructor) {
|
||||
this.constructor = constructor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public Object construct(Node node) {
|
||||
return constructor.constructScalarOrSubstitution((ScalarNode) node);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.constructors;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.snakeyaml.engine.v2.api.ConstructNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
import org.snakeyaml.engine.v2.nodes.Tag;
|
||||
|
||||
/**
|
||||
* The {@link ConstructSub} is the constructor used on the <code>!sub</code> tag
|
||||
* to keep track of custom substitution patterns.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class ConstructSub implements ConstructNode {
|
||||
private static final String SUB_TAG_PREFIX = "sub:";
|
||||
|
||||
ModelConstructor constructor;
|
||||
|
||||
ConstructSub(ModelConstructor constructor) {
|
||||
this.constructor = constructor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public Object construct(Node node) {
|
||||
String patternName = extractPatternName(Objects.requireNonNull(node.getTag()));
|
||||
constructor.trackPatternName(patternName);
|
||||
return constructor.constructByType(node);
|
||||
}
|
||||
|
||||
private @Nullable String extractPatternName(Tag tag) {
|
||||
String suffix = extractSubTagSuffix(tag);
|
||||
if (suffix == null || suffix.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
private @Nullable String extractSubTagSuffix(Tag tag) {
|
||||
String tagValue = tag.getValue();
|
||||
|
||||
// The tag value doesn't always start with !, for example
|
||||
// when using the verbatim tag syntax like !<sub:pattern>
|
||||
// the value will be "sub:pattern" without the leading "!"
|
||||
// see https://yaml.org/spec/1.2.2/#verbatim-tags
|
||||
if (tagValue.startsWith("!")) {
|
||||
tagValue = tagValue.substring(1);
|
||||
}
|
||||
|
||||
if (!tagValue.startsWith(SUB_TAG_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tagValue.substring(SUB_TAG_PREFIX.length());
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.constructors;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
import org.snakeyaml.engine.v2.api.ConstructNode;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
import org.snakeyaml.engine.v2.constructor.StandardConstructor;
|
||||
import org.snakeyaml.engine.v2.exceptions.Mark;
|
||||
import org.snakeyaml.engine.v2.nodes.MappingNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
import org.snakeyaml.engine.v2.nodes.ScalarNode;
|
||||
import org.snakeyaml.engine.v2.nodes.SequenceNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Tag;
|
||||
|
||||
/**
|
||||
* Extends SnakeYAML Engine's {@link StandardConstructor} to add support for the
|
||||
* composer's custom YAML tags and model‑transformation features.
|
||||
*
|
||||
* <p>
|
||||
* The {@code ModelConstructor} handles all extended tags, including:
|
||||
* <ul>
|
||||
* <li><code>!sub</code> - variable interpolation</li>
|
||||
* <li><code>!literal</code> - disable interpolation for a value</li>
|
||||
* <li><code>!if</code> - conditional evaluation</li>
|
||||
* <li><code>!include</code> - include external YAML files</li>
|
||||
* <li><code>!insert</code> - insert templates with variable context</li>
|
||||
* <li><code>!replace</code> - replace parts of the model within a package</li>
|
||||
* <li><code>!remove</code> - remove parts of the model within a package</li>
|
||||
* </ul>
|
||||
*
|
||||
* These extensions allow the composer to construct a fully evaluated
|
||||
* in‑memory model before further processing or consumption.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ModelConstructor extends StandardConstructor {
|
||||
static final String SUB_TAG = "sub";
|
||||
|
||||
private static final Tag LITERAL_TAG = new Tag("!literal");
|
||||
private static final Tag IF_TAG = new Tag("!if");
|
||||
private static final Tag INCLUDE_TAG = new Tag("!include");
|
||||
private static final Tag REPLACE_TAG = new Tag("!replace");
|
||||
private static final Tag REMOVE_TAG = new Tag("!remove");
|
||||
private static final Tag INSERT_TAG = new Tag("!insert");
|
||||
|
||||
// A replacement for the built in Tag.MERGE because we want to
|
||||
// do the merge key processing at the composer level
|
||||
public static final Tag DEFERRED_MERGE_TAG = new Tag("!deferred-merge");
|
||||
|
||||
String sourcePath;
|
||||
final Deque<Boolean> substitutionStack = new ArrayDeque<>();
|
||||
final Deque<@Nullable String> substitutionPatternNameStack = new LinkedList<>();
|
||||
|
||||
private final ConstructSub constructSub;
|
||||
|
||||
public ModelConstructor(LoadSettings settings, String sourcePath) {
|
||||
super(settings);
|
||||
this.sourcePath = sourcePath;
|
||||
this.substitutionStack.push(true);
|
||||
this.substitutionPatternNameStack.push(null);
|
||||
|
||||
this.constructSub = new ConstructSub(this);
|
||||
this.tagConstructors.put(LITERAL_TAG, new ConstructLiteral(this));
|
||||
|
||||
this.tagConstructors.put(Tag.STR, new ConstructStr(this));
|
||||
|
||||
this.tagConstructors.put(IF_TAG, new ConstructInterpolablePlaceholder<IfPlaceholder>(this, IfPlaceholder::new));
|
||||
this.tagConstructors.put(DEFERRED_MERGE_TAG,
|
||||
new ConstructInterpolablePlaceholder<MergeKeyPlaceholder>(this, MergeKeyPlaceholder::new));
|
||||
this.tagConstructors.put(INCLUDE_TAG,
|
||||
new ConstructInterpolablePlaceholder<IncludePlaceholder>(this, IncludePlaceholder::new));
|
||||
this.tagConstructors.put(INSERT_TAG,
|
||||
new ConstructInterpolablePlaceholder<InsertPlaceholder>(this, InsertPlaceholder::new));
|
||||
this.tagConstructors.put(REPLACE_TAG,
|
||||
new ConstructInterpolablePlaceholder<ReplacePlaceholder>(this, ReplacePlaceholder::new));
|
||||
this.tagConstructors.put(REMOVE_TAG,
|
||||
new ConstructInterpolablePlaceholder<RemovePlaceholder>(this, RemovePlaceholder::new));
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
@SuppressWarnings("null")
|
||||
protected Optional<ConstructNode> findConstructorFor(Node node) {
|
||||
if (isSubstitutionTag(node.getTag())) {
|
||||
return Optional.of(constructSub);
|
||||
}
|
||||
return super.findConstructorFor(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a string representation of the node's location for logging purposes.
|
||||
*
|
||||
* @param node the YAML node to get the location of
|
||||
* @return a string describing the source location of the node, including file path and line/column if available
|
||||
*/
|
||||
String getLocation(Node node) {
|
||||
String location = "";
|
||||
Mark startMark = node.getStartMark().orElse(null);
|
||||
if (startMark != null) {
|
||||
location = ":%d:%d".formatted(startMark.getLine() + 1, startMark.getColumn() + 1);
|
||||
}
|
||||
return this.sourcePath + location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default construction method that routes to the appropriate construct method
|
||||
* based on the node type.
|
||||
*
|
||||
* Use this instead of constructObject() to avoid an infinite recursion when
|
||||
* constructing a node on a custom tag.
|
||||
*
|
||||
* @param node the node to construct
|
||||
* @return the constructed object
|
||||
*/
|
||||
protected @Nullable Object constructByType(Node node) {
|
||||
return switch (node) {
|
||||
case MappingNode mappingNode -> constructMapping(mappingNode);
|
||||
case SequenceNode sequenceNode -> constructSequence(sequenceNode);
|
||||
case ScalarNode scalarNode -> constructScalarOrSubstitution(scalarNode);
|
||||
default -> constructObject(node);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a scalar node, potentially as a SubstitutionPlaceholder
|
||||
* if the current substitution state is enabled.
|
||||
*
|
||||
* @param scalarNode the scalar node to construct
|
||||
* @return
|
||||
*/
|
||||
@SuppressWarnings("null") // The stacks and SnakeYAML methods shouldn't return null
|
||||
protected @Nullable Object constructScalarOrSubstitution(ScalarNode scalarNode) {
|
||||
Tag tag = scalarNode.getTag();
|
||||
String value = constructScalar(scalarNode);
|
||||
boolean enabled = substitutionStack.peek();
|
||||
if (enabled || isSubstitutionTag(tag)) {
|
||||
String patternName = substitutionPatternNameStack.peek();
|
||||
String location = getLocation(scalarNode);
|
||||
return new SubstitutionPlaceholder(value, patternName, location);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept constructObject to keep track of the current substitution state.
|
||||
*
|
||||
* @param node the node to construct
|
||||
* @return the constructed object
|
||||
*/
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
protected @Nullable Object constructObject(Node node) {
|
||||
Tag tag = Objects.requireNonNull(node.getTag());
|
||||
boolean parent = Objects.requireNonNull(substitutionStack.peek());
|
||||
boolean enabled = resolveSubstitution(tag, parent);
|
||||
substitutionStack.push(enabled);
|
||||
substitutionPatternNameStack.push(substitutionPatternNameStack.peek());
|
||||
try {
|
||||
return super.constructObject(node);
|
||||
} finally {
|
||||
substitutionPatternNameStack.pop();
|
||||
substitutionStack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean resolveSubstitution(Tag tag, boolean parent) {
|
||||
if (LITERAL_TAG.equals(tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSubstitutionTag(tag)) {
|
||||
return true;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
static boolean isSubstitutionTag(Tag tag) {
|
||||
String value = tag.getValue();
|
||||
if (value.startsWith("!")) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
return value.startsWith(SUB_TAG);
|
||||
}
|
||||
|
||||
protected void trackPatternName(@Nullable String patternName) {
|
||||
substitutionPatternNameStack.pop();
|
||||
substitutionPatternNameStack.push(patternName);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
|
||||
/**
|
||||
* Processor for handling 'packages' in YAML models.
|
||||
*
|
||||
* Merges package definitions into the main data structure, injecting the package ID
|
||||
* into any included or inserted fragments.
|
||||
*
|
||||
* This is slightly different to the other processors as it operates on the overall
|
||||
* data structure rather than merely performing resolutions of a particular placeholder.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class PackageProcessor {
|
||||
private static final String PACKAGES_KEY = "packages";
|
||||
private static final String PACKAGE_ID_VAR = "package_id";
|
||||
|
||||
private final BufferedLogger logger;
|
||||
private final Path absolutePath;
|
||||
private final Path relativePath;
|
||||
private final SourceLocator sourceLocator;
|
||||
private final RecursiveTransformer recursiveTransformer;
|
||||
|
||||
public PackageProcessor(BufferedLogger logger, Path absolutePath, Path relativePath, SourceLocator sourceLocator,
|
||||
RecursiveTransformer recursiveTransformer) {
|
||||
this.logger = logger;
|
||||
this.absolutePath = absolutePath;
|
||||
this.relativePath = relativePath;
|
||||
this.sourceLocator = sourceLocator;
|
||||
this.recursiveTransformer = recursiveTransformer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that accepts the raw 'packages' section value and
|
||||
* applies merging if it's a map, otherwise logs the same warning used by
|
||||
* the composer.
|
||||
*
|
||||
* @param yamlMap the root YAML map to merge packages into
|
||||
* @param packagesObj the raw 'packages' section value to process and merge, or null if not present
|
||||
*/
|
||||
public void mergePackages(Map<?, ?> yamlMap, @Nullable Object packagesObj) {
|
||||
if (packagesObj instanceof Map<?, ?> packagesMap) {
|
||||
mergePackages(yamlMap, packagesMap);
|
||||
logger.debug("Merged packages into data in {}: {}", absolutePath, yamlMap);
|
||||
} else if (packagesObj != null) {
|
||||
var position = sourceLocator.findPosition(PACKAGES_KEY);
|
||||
logger.warn("{}:{} The 'packages' section is not a map", relativePath, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge packages map into the main data map
|
||||
*/
|
||||
private void mergePackages(Map<?, ?> mainData, Map<?, ?> packages) {
|
||||
packages.forEach((pkgKey, pkg) -> {
|
||||
Object pkgKeyObj = recursiveTransformer.transform(pkgKey);
|
||||
if (pkgKeyObj == null) {
|
||||
var position = sourceLocator.findPosition(PACKAGES_KEY);
|
||||
logger.warn("{}:{} package key resolved to null; skipping package entry", relativePath, position);
|
||||
return;
|
||||
}
|
||||
String packageId = String.valueOf(pkgKeyObj);
|
||||
|
||||
// Inject `package_id` into the package context for use in
|
||||
// !include and !insert within the package definition
|
||||
Map<String, @Nullable Object> packageIdMap = Map.of(PACKAGE_ID_VAR, packageId);
|
||||
RecursiveTransformer packageTransformer = recursiveTransformer.withOverrideVariables(packageIdMap);
|
||||
Object resolvedPkg = packageTransformer.transform(pkg, ProcessingPhase.STANDARD);
|
||||
|
||||
resolvedPkg = stripEmptyMapsAndLists(resolvedPkg);
|
||||
if (!(resolvedPkg instanceof Map<?, ?> packageMap)) {
|
||||
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
|
||||
logger.warn("{}:{} package '{}' resolved to {} instead of a Map", relativePath, position, packageId,
|
||||
resolvedPkg == null ? "null" : resolvedPkg.getClass().getSimpleName());
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Merging package '{}' {} into main data: {}", packageId, packageMap, mainData);
|
||||
mergeElements(packageId, mainData, packageMap);
|
||||
});
|
||||
}
|
||||
|
||||
private void mergeElements(String packageId, Map<?, ?> mainData, Map<?, ?> packageData) {
|
||||
packageData.forEach((packageKey, packageValue) -> {
|
||||
if (mainData.containsKey(packageKey)) {
|
||||
Object mainValue = mainData.get(packageKey);
|
||||
if (mainValue instanceof ReplacePlaceholder || mainValue instanceof SubstitutionPlaceholder) {
|
||||
return; // Ignore the package value - we'll process these later
|
||||
}
|
||||
if (mainValue instanceof Map<?, ?> mainValueMap) {
|
||||
if (packageValue instanceof Map<?, ?> packageValueMap) {
|
||||
mergeElements(packageId, mainValueMap, packageValueMap);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<@Nullable Object, Object> rawMainData = (Map<@Nullable Object, Object>) mainData;
|
||||
rawMainData.put(packageKey, mainValueMap);
|
||||
return;
|
||||
} else {
|
||||
// Type mismatch - keep main value
|
||||
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
|
||||
logger.warn(
|
||||
"{}:{} Type mismatch when merging package ID '{}' for key '{}': main is Map, package is {}; keeping main value",
|
||||
relativePath, position, packageId, packageKey,
|
||||
packageValue == null ? "null" : packageValue.getClass().getSimpleName());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mainValue instanceof List<?> mainValueList) {
|
||||
if (packageValue instanceof List<?> pkgValueList) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<@Nullable Object, Object> rawMainData = (Map<@Nullable Object, Object>) mainData;
|
||||
rawMainData.put(packageKey,
|
||||
Stream.concat(pkgValueList.stream(), mainValueList.stream()).distinct().toList());
|
||||
return;
|
||||
} else {
|
||||
// Type mismatch - keep main value
|
||||
var position = sourceLocator.findPosition(PACKAGES_KEY, packageId);
|
||||
logger.warn(
|
||||
"{}:{} Type mismatch when merging package ID '{}' for key '{}': main is List, package is {}; keeping main value",
|
||||
relativePath, position, packageId, packageKey,
|
||||
packageValue == null ? "null" : packageValue.getClass().getSimpleName());
|
||||
return;
|
||||
}
|
||||
}
|
||||
// For non-map/non-list values, keep the main value ignoring the package value
|
||||
} else {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<@Nullable Object, @Nullable Object> rawMainData = (Map<@Nullable Object, @Nullable Object>) mainData;
|
||||
rawMainData.put(packageKey, packageValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static @Nullable Object stripEmptyMapsAndLists(@Nullable Object data) {
|
||||
if (data == null || data instanceof String s && s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (data instanceof Map<?, ?> map) {
|
||||
var result = new LinkedHashMap<Object, Object>();
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
Object key = e.getKey();
|
||||
Object value = stripEmptyMapsAndLists(e.getValue());
|
||||
if (value != null) {
|
||||
result.put(key, value);
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? null : result;
|
||||
} else if (data instanceof List<?> list) {
|
||||
var result = new java.util.ArrayList<Object>(list.size());
|
||||
for (Object item : list) {
|
||||
Object value = stripEmptyMapsAndLists(item);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
|
||||
/**
|
||||
* Defines the different processing phases for the YAML composer.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial Contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ProcessingPhase {
|
||||
public static final Set<Class<? extends Placeholder>> SUBSTITUTION = //
|
||||
Set.of(SubstitutionPlaceholder.class);
|
||||
public static final Set<Class<? extends Placeholder>> INCLUDES = //
|
||||
Set.of(IncludePlaceholder.class);
|
||||
public static final Set<Class<? extends Placeholder>> STANDARD = //
|
||||
Set.of(SubstitutionPlaceholder.class, IfPlaceholder.class, IncludePlaceholder.class,
|
||||
InsertPlaceholder.class);
|
||||
public static final Set<Class<? extends Placeholder>> PACKAGE_OVERRIDES = //
|
||||
Set.of(RemovePlaceholder.class, ReplacePlaceholder.class);
|
||||
|
||||
private ProcessingPhase() {
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.util.AbstractMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor;
|
||||
|
||||
/**
|
||||
* The {@link RecursiveTransformer} traverses a YAML data tree, applies merge keys logic,
|
||||
* and transforms placeholders into the final values by invoking registered handlers
|
||||
* for their respective types.
|
||||
*
|
||||
* It holds a variables map that can be overridden for nested transformations,
|
||||
* allowing for context-specific variable values during processing.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RecursiveTransformer {
|
||||
|
||||
private final Map<Class<? extends Placeholder>, PlaceholderProcessor<?>> handlers = new LinkedHashMap<>();
|
||||
private final Map<String, @Nullable Object> variables;
|
||||
|
||||
public RecursiveTransformer(Map<String, @Nullable Object> variables) {
|
||||
this.variables = variables;
|
||||
}
|
||||
|
||||
public Map<String, @Nullable Object> getVariables() {
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RecursiveTransformer with the same handlers but a new variables map that
|
||||
* includes the given overrides.
|
||||
*
|
||||
* The new combined variables map will be used for all placeholder processing within
|
||||
* the nested structure, allowing for isolated modifications without impacting the original context.
|
||||
*
|
||||
* @param overrideVariables additional variables to include in the new transformer's context,
|
||||
* which will override any existing variables with the same keys
|
||||
* @return a new RecursiveTransformer instance with the combined variables and the same handlers
|
||||
*/
|
||||
public RecursiveTransformer withOverrideVariables(Map<String, @Nullable Object> overrideVariables) {
|
||||
Map<String, @Nullable Object> combinedVariables = new HashMap<>(variables);
|
||||
combinedVariables.putAll(overrideVariables);
|
||||
RecursiveTransformer copy = new RecursiveTransformer(combinedVariables);
|
||||
copy.handlers.putAll(this.handlers);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific placeholder type.
|
||||
*
|
||||
* @param handler the processor that can transform the placeholder
|
||||
*/
|
||||
public void register(PlaceholderProcessor<?> handler) {
|
||||
handlers.put(handler.getPlaceholderType(), handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default: transforms the entire tree using all registered handlers.
|
||||
* Placeholders are transformed if their class matches any registered handler.
|
||||
*
|
||||
* This is the most common usage, but the other overloads allow for more control
|
||||
* and optimization by restricting which handlers are applied.
|
||||
*
|
||||
* @param data the YAML data tree to transform
|
||||
* @return the transformed data tree with placeholders transformed
|
||||
*/
|
||||
public @Nullable Object transform(@Nullable Object data) {
|
||||
return transformWithVisited(data, handlers.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the data tree but only applies handlers for the specified placeholder classes.
|
||||
*
|
||||
* <p>
|
||||
* Use this when you want to restrict transformation to a subset of placeholder
|
||||
* classes.
|
||||
*
|
||||
* @param data the YAML data tree to transform
|
||||
* @param allowedTypes the set of placeholder classes to transform
|
||||
* @return the transformed data tree with the given placeholders transformed
|
||||
*/
|
||||
public @Nullable Object transform(@Nullable Object data, Set<Class<? extends Placeholder>> allowedTypes) {
|
||||
return transformWithVisited(data, allowedTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload for transforming a Map container.
|
||||
*
|
||||
* <p>
|
||||
* Transforms the given map and transforms any placeholders found within keys and values.
|
||||
* This is equivalent to calling {@link #transform(Object, Set)} with the full set of
|
||||
* registered placeholder handler types.
|
||||
*
|
||||
* @param data the map to transform
|
||||
* @return the transformed map
|
||||
*/
|
||||
public Map<Object, @Nullable Object> transform(Map<?, ?> data) {
|
||||
return transform(data, handlers.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given map but only applies handlers for the specified placeholder classes.
|
||||
*
|
||||
* <p>
|
||||
* Keys and values are transformed recursively. If the overall transformed result is not a
|
||||
* {@link Map} (for example, if a placeholder handler returned a non-container), an
|
||||
* {@link IllegalStateException} is thrown.
|
||||
*
|
||||
* @param data the map to transform
|
||||
* @param allowedTypes the set of placeholder classes to transform
|
||||
* @return the transformed map
|
||||
* @throws IllegalStateException if the transformed result is not a Map
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<Object, @Nullable Object> transform(Map<?, ?> data, Set<Class<? extends Placeholder>> allowedTypes) {
|
||||
Object transformed = transformWithVisited(data, allowedTypes);
|
||||
if (transformed instanceof Map<?, ?> map) {
|
||||
return (Map<Object, @Nullable Object>) map;
|
||||
}
|
||||
throw new IllegalStateException("Expected transformed result to be a Map but was: "
|
||||
+ (transformed == null ? "null" : transformed.getClass()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Central helper to start transformation with a fresh visited map.
|
||||
*/
|
||||
private @Nullable Object transformWithVisited(@Nullable Object data,
|
||||
Set<Class<? extends Placeholder>> allowedTypes) {
|
||||
return transformInternal(data, allowedTypes, new IdentityHashMap<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual tree traversal.
|
||||
*/
|
||||
private @Nullable Object transformInternal(@Nullable Object data, Set<Class<? extends Placeholder>> allowedTypes,
|
||||
IdentityHashMap<Object, Object> visited) {
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Class<?> clazz = data.getClass();
|
||||
|
||||
// Handle cyclic references for containers: if we've already started transforming
|
||||
// this container, return the placeholder/result to avoid infinite recursion.
|
||||
if ((data instanceof Map<?, ?> || data instanceof List<?>)) {
|
||||
if (visited.containsKey(data)) {
|
||||
return visited.get(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve placeholder value (arguments) first before transforming the placeholder itself
|
||||
// So that e.g. !include ${filename} gets the real argument value to transform
|
||||
if (data instanceof InterpolablePlaceholder interpolable) {
|
||||
Object transformedValue;
|
||||
|
||||
if (interpolable.eagerArgumentProcessing()) {
|
||||
// Eagerly transform arguments using all registered handlers
|
||||
transformedValue = transform(interpolable.value());
|
||||
} else {
|
||||
// Only perform substitutions in arguments
|
||||
// for !if conditions, don't transform placeholders (e.g. !include) in the unselected branch
|
||||
transformedValue = transform(interpolable.value(), ProcessingPhase.SUBSTITUTION);
|
||||
}
|
||||
data = interpolable.withValue(transformedValue);
|
||||
}
|
||||
|
||||
if (data instanceof Placeholder placeholder && allowedTypes.contains(clazz)) {
|
||||
// Use the override callback if provided, otherwise look up in registry
|
||||
PlaceholderProcessor<?> handler = handlers.get(clazz);
|
||||
|
||||
if (handler != null) {
|
||||
// Execute and recurse
|
||||
Object result = invokeHandler(handler, placeholder);
|
||||
return transformInternal(result, allowedTypes, visited);
|
||||
}
|
||||
}
|
||||
|
||||
if (data instanceof Map<?, ?> map) {
|
||||
return resolveMap(map, allowedTypes, visited);
|
||||
}
|
||||
|
||||
if (data instanceof List<?> list) {
|
||||
return resolveList(list, allowedTypes, visited);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private @Nullable <T extends Placeholder> Object invokeHandler(PlaceholderProcessor<?> handler,
|
||||
Placeholder placeholder) {
|
||||
return ((PlaceholderProcessor<T>) handler).process((T) placeholder, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a map by transforming its keys and values, applying placeholder handlers as needed,
|
||||
* and handling special cases like merge keys and removal signals.
|
||||
*
|
||||
* @param rawMap the original map to transform
|
||||
* @param allowedTypes the set of placeholder classes to transform
|
||||
* @return the transformed map with placeholders transformed, or the original map if no changes were made
|
||||
*/
|
||||
private Object resolveMap(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes,
|
||||
IdentityHashMap<Object, Object> visited) {
|
||||
// Always create a new map for transformed results to simplify cycle handling
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
|
||||
|
||||
Map<Object, @Nullable Object> result = new LinkedHashMap<>(map.size());
|
||||
// Register in visited before transforming entries to handle self-references
|
||||
visited.put(rawMap, result);
|
||||
|
||||
List<Map.Entry<Object, @Nullable Object>> mergeEntries = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<Object, @Nullable Object> entry : map.entrySet()) {
|
||||
Object oldKey = entry.getKey();
|
||||
Object oldVal = entry.getValue();
|
||||
|
||||
Object newKey = transformInternal(oldKey, allowedTypes, visited);
|
||||
Object newVal = transformInternal(oldVal, allowedTypes, visited);
|
||||
|
||||
// Dropping null keys or removal signals
|
||||
if (shouldRemoveEntry(newKey, newVal, oldVal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newKey = Objects.requireNonNull(newKey); // null keys should have been filtered out in shouldRemoveEntry
|
||||
|
||||
if (newKey instanceof MergeKeyPlaceholder mkp) {
|
||||
mergeEntries.add(new AbstractMap.SimpleEntry<>(mkp, newVal));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("<<".equals(newKey)) {
|
||||
mergeEntries.add(new AbstractMap.SimpleEntry<>(newKey, newVal));
|
||||
continue;
|
||||
}
|
||||
|
||||
result.put(newKey, newVal);
|
||||
}
|
||||
|
||||
resolveMergeKeys(result, allowedTypes, mergeEntries, visited);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean shouldRemoveEntry(@Nullable Object newKey, @Nullable Object newVal, @Nullable Object oldVal) {
|
||||
return newKey == null //
|
||||
|| newKey == RemovalSignal.REMOVE //
|
||||
|| newVal == RemovalSignal.REMOVE //
|
||||
|| (newVal == null && oldVal != null);
|
||||
}
|
||||
|
||||
public void resolveMergeKeys(Map<?, ?> rawMap) {
|
||||
resolveMergeKeys(rawMap, Set.of());
|
||||
}
|
||||
|
||||
public void resolveMergeKeys(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
|
||||
|
||||
// First: handle YAML merge keys (<<: ...). We collect merge-key entries, transform their
|
||||
// values fully, merge into this container, and remove the merge-key entries before
|
||||
// performing the normal per-entry transformation below.
|
||||
List<Map.Entry<Object, @Nullable Object>> mergeEntries = new ArrayList<>();
|
||||
for (Map.Entry<Object, @Nullable Object> entry : map.entrySet()) {
|
||||
if (entry.getKey() instanceof MergeKeyPlaceholder || "<<".equals(entry.getKey())) {
|
||||
mergeEntries.add(new AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
resolveMergeKeys(map, allowedTypes, mergeEntries, new IdentityHashMap<>());
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "unchecked" })
|
||||
private void resolveMergeKeys(Map<?, ?> rawMap, Set<Class<? extends Placeholder>> allowedTypes,
|
||||
List<Map.Entry<Object, @Nullable Object>> mergeEntries, IdentityHashMap<Object, Object> visited) {
|
||||
|
||||
Map<Object, @Nullable Object> map = (Map<Object, @Nullable Object>) rawMap;
|
||||
|
||||
if (!mergeEntries.isEmpty()) {
|
||||
for (var mergeEntry : mergeEntries) {
|
||||
@Nullable
|
||||
Object rawVal = mergeEntry.getValue();
|
||||
@Nullable
|
||||
Object transformedVal = transformInternal(rawVal, allowedTypes, visited);
|
||||
|
||||
if (transformedVal instanceof Map<?, ?> fromMap) {
|
||||
mergeMap((Map<Object, @Nullable Object>) fromMap, map);
|
||||
} else if (transformedVal instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
mergeMap((Map<Object, @Nullable Object>) m, map);
|
||||
}
|
||||
}
|
||||
} else if (transformedVal == null) {
|
||||
if (rawVal instanceof SubstitutionPlaceholder || rawVal instanceof IfPlaceholder) {
|
||||
// nothing to merge
|
||||
}
|
||||
}
|
||||
|
||||
map.remove(mergeEntry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeMap(Map<Object, @Nullable Object> from, Map<Object, @Nullable Object> to) {
|
||||
for (Map.Entry<Object, @Nullable Object> entry : from.entrySet()) {
|
||||
Object key = entry.getKey();
|
||||
if (!to.containsKey(key)) {
|
||||
to.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Object resolveList(List<?> list, Set<Class<? extends Placeholder>> allowedTypes,
|
||||
IdentityHashMap<Object, Object> visited) {
|
||||
// Always produce a new list and register it to handle cycles
|
||||
List<@Nullable Object> result = new ArrayList<>(list.size());
|
||||
visited.put(list, result);
|
||||
|
||||
for (Object oldItem : list) {
|
||||
Object newItem = transformInternal(oldItem, allowedTypes, visited);
|
||||
if (newItem != RemovalSignal.REMOVE && newItem != null) {
|
||||
result.add(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* A signal returned by {@link org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor}s to indicate that
|
||||
* the entry
|
||||
* currently being processed should be <strong>intentionally removed</strong> from its
|
||||
* parent container (Map or List).
|
||||
* <p>
|
||||
* This enum provides an <em>explicit</em> way to signal removal, distinguishing it from
|
||||
* error-based removal that occurs when a processor returns {@code null}.
|
||||
*
|
||||
* <h2>Usage Guidelines</h2>
|
||||
* <p>
|
||||
* <strong>Use {@code RemovalSignal.REMOVE} when:</strong>
|
||||
* <ul>
|
||||
* <li>The placeholder's purpose is to explicitly remove an entry (e.g., {@code !remove} directive)</li>
|
||||
* <li>The removal is intentional and by design, not due to an error condition</li>
|
||||
* <li>You want to make the removal intent clear in the code</li>
|
||||
* </ul>
|
||||
*
|
||||
* <strong>Use {@code null} when:</strong>
|
||||
* <ul>
|
||||
* <li>Processing failed due to an error (missing parameter, invalid input, resource not found)</li>
|
||||
* <li>You've logged a warning describing the error before returning {@code null}</li>
|
||||
* <li>The entry should be removed as error recovery, not as intended functionality</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Technical Details</h2>
|
||||
* <p>
|
||||
* The {@link RecursiveTransformer} treats both {@code RemovalSignal.REMOVE} and {@code null}
|
||||
* identically in terms of behavior (both cause entry removal), but the semantic distinction
|
||||
* helps developers understand whether removal was intentional or due to error recovery.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
* @see org.openhab.io.yamlcomposer.internal.processors.PlaceholderProcessor#process
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public enum RemovalSignal {
|
||||
/**
|
||||
* Signals that the current entry should be intentionally removed from its parent container.
|
||||
* <p>
|
||||
* This is the recommended way to explicitly indicate removal as part of normal processing,
|
||||
* as opposed to returning {@code null} which should be reserved for error cases.
|
||||
*/
|
||||
REMOVE;
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Scanner;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Helper to find positions in YAML source for logging.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SourceLocator {
|
||||
private final byte[] yamlBytes;
|
||||
|
||||
public SourceLocator(byte[] yamlBytes) {
|
||||
this.yamlBytes = yamlBytes;
|
||||
}
|
||||
|
||||
public record FilePosition(int line, int column) {
|
||||
@Override
|
||||
public String toString() {
|
||||
if (line < 0) {
|
||||
return "";
|
||||
}
|
||||
return line + ":" + column;
|
||||
}
|
||||
|
||||
public static FilePosition empty() {
|
||||
return new FilePosition(-1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the position of a given key in the YAML file.
|
||||
*
|
||||
* @param keys the sequence of keys representing the path in the YAML structure
|
||||
* @return the Position of the last key in the sequence, or (-1, -1) if not
|
||||
* found
|
||||
*/
|
||||
public FilePosition findPosition(String... keys) {
|
||||
if (keys.length == 0) {
|
||||
return FilePosition.empty();
|
||||
}
|
||||
|
||||
try (Scanner scanner = new Scanner(new ByteArrayInputStream(yamlBytes), StandardCharsets.UTF_8)) {
|
||||
int lineNumber = 1;
|
||||
int keyIndex = 0;
|
||||
|
||||
while (scanner.hasNextLine()) {
|
||||
String line = scanner.nextLine();
|
||||
int lineOffset = 0; // Tracks our horizontal position in the current line
|
||||
String searchArea = line;
|
||||
boolean foundInLine;
|
||||
|
||||
do {
|
||||
foundInLine = false;
|
||||
String targetKey = keys[keyIndex] + ":";
|
||||
int matchIndex = searchArea.indexOf(targetKey);
|
||||
|
||||
if (matchIndex != -1) {
|
||||
// Calculate the column:
|
||||
// segments already skipped + position in current segment + length of key
|
||||
int columnAtEndOfKey = lineOffset + matchIndex + targetKey.length();
|
||||
|
||||
if (keyIndex == keys.length - 1) {
|
||||
return new FilePosition(lineNumber, columnAtEndOfKey + 1); // +1 for 1-based indexing
|
||||
}
|
||||
|
||||
// Prepare for next key on the same line
|
||||
lineOffset += matchIndex + targetKey.length();
|
||||
searchArea = searchArea.substring(matchIndex + targetKey.length());
|
||||
keyIndex++;
|
||||
foundInLine = true;
|
||||
}
|
||||
} while (foundInLine && keyIndex < keys.length);
|
||||
|
||||
lineNumber++;
|
||||
}
|
||||
}
|
||||
return FilePosition.empty();
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
|
||||
|
||||
/**
|
||||
* The {@link TemplateLoader} is responsible for extracting templates from the YAML model and storing them in the
|
||||
* composer context for later use.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class TemplateLoader {
|
||||
private final BufferedLogger logger;
|
||||
private final Path relativePath;
|
||||
private final Map<Object, @Nullable Object> templates;
|
||||
private final RecursiveTransformer recursiveTransformer;
|
||||
private final SourceLocator locator;
|
||||
|
||||
public TemplateLoader(BufferedLogger logger, Path relativePath, Map<Object, @Nullable Object> templates,
|
||||
RecursiveTransformer recursiveTransformer, SourceLocator locator) {
|
||||
this.logger = logger;
|
||||
this.relativePath = relativePath;
|
||||
this.templates = templates;
|
||||
this.recursiveTransformer = recursiveTransformer;
|
||||
this.locator = locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts templates from the given map and stores them into the templates map.
|
||||
*
|
||||
* @param templatesSection the section of the YAML model containing templates
|
||||
*/
|
||||
public void extractTemplates(@Nullable Object templatesSection) {
|
||||
if (templatesSection instanceof java.util.Map<?, ?> templatesMap) {
|
||||
templatesMap.keySet().removeIf(Objects::isNull);
|
||||
recursiveTransformer.resolveMergeKeys(templatesMap, ProcessingPhase.STANDARD);
|
||||
templatesMap.forEach((key, value) -> {
|
||||
// Only resolve the key so we can look up the template name in the templates map.
|
||||
// The value substitutions must NOT be resolved here!
|
||||
// They will be resolved at insertion-time using insertion context instead of the main variables.
|
||||
Object resolvedKey = recursiveTransformer.transform(key, ProcessingPhase.SUBSTITUTION);
|
||||
if (resolvedKey != null) {
|
||||
templates.put(resolvedKey, value);
|
||||
}
|
||||
});
|
||||
} else if (templatesSection != null) {
|
||||
var position = locator.findPosition(ComposerConfig.TEMPLATES_KEY);
|
||||
logger.warn("{}:{} 'templates' is not a map", relativePath, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.core;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.MergeKeyPlaceholder;
|
||||
|
||||
/**
|
||||
* The {@link VariableLoader} is responsible for extracting variable definitions from the YAML model and storing
|
||||
* them in the composer context for later use in substitutions.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class VariableLoader {
|
||||
private final Map<String, @Nullable Object> variables;
|
||||
private final Path absolutePath;
|
||||
private final RecursiveTransformer recursiveTransformer;
|
||||
private final BufferedLogger logger;
|
||||
|
||||
public VariableLoader(Map<String, @Nullable Object> variables, Path absolutePath,
|
||||
RecursiveTransformer recursiveTransformer, BufferedLogger logger) {
|
||||
this.variables = variables;
|
||||
this.absolutePath = absolutePath;
|
||||
this.recursiveTransformer = recursiveTransformer;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add special file-related variables
|
||||
*
|
||||
* These are added early so they're available in variable definitions during the
|
||||
* first pass
|
||||
* Special variables will override any user-defined variables with the same name
|
||||
*/
|
||||
public void setSpecialVariables() {
|
||||
Path fileNamePath = absolutePath.getFileName();
|
||||
String fullFileName = fileNamePath != null ? fileNamePath.toString() : "";
|
||||
int dotIndex = fullFileName.lastIndexOf(".");
|
||||
String fileName = fullFileName;
|
||||
String fileExtension = "";
|
||||
if (dotIndex > 0) {
|
||||
fileName = fullFileName.substring(0, dotIndex);
|
||||
fileExtension = fullFileName.substring(dotIndex + 1);
|
||||
}
|
||||
var parentPath = absolutePath.getParent();
|
||||
String directory = parentPath != null ? parentPath.toString() : "";
|
||||
Map<String, @Nullable Object> vars = variables;
|
||||
|
||||
vars.put("OPENHAB_CONF", ComposerConfig.configRoot().toString());
|
||||
vars.put("OPENHAB_USERDATA", ComposerConfig.userDataRoot().toString());
|
||||
vars.put("__FILE__", absolutePath.toString());
|
||||
vars.put("__FILE_NAME__", fileName);
|
||||
vars.put("__FILE_EXT__", fileExtension);
|
||||
vars.put("__DIRECTORY__", directory);
|
||||
vars.put("__DIR__", directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts variables from the given map and stores them into the context's variable map.
|
||||
*
|
||||
* Since variables can reference previously defined variables, we perform incremental resolution
|
||||
* while iterating through the variable definitions.
|
||||
*
|
||||
* @param variablesSection the section of the YAML file containing variable definitions, can be null
|
||||
* @param locator the source locator for logging purposes
|
||||
* @see ComposerConfig#VARIABLES_KEY
|
||||
*/
|
||||
public void extractVariables(@Nullable Object variablesSection, SourceLocator locator) {
|
||||
Map<String, @Nullable Object> existingVariables = variables;
|
||||
|
||||
if (variablesSection instanceof Map<?, ?> variablesMap) {
|
||||
Map<Object, @Nullable Object> mergeKeys = new LinkedHashMap<>();
|
||||
|
||||
variablesMap.forEach((key, value) -> {
|
||||
if (key instanceof MergeKeyPlaceholder) {
|
||||
mergeKeys.put(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
Object keyObj = recursiveTransformer.transform(key);
|
||||
if (keyObj == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String keyStr = String.valueOf(keyObj);
|
||||
if (!existingVariables.containsKey(keyStr)) {
|
||||
Object resolvedValue = recursiveTransformer.transform(value);
|
||||
existingVariables.put(keyStr, resolvedValue);
|
||||
}
|
||||
});
|
||||
|
||||
if (!mergeKeys.isEmpty()) {
|
||||
Map<Object, @Nullable Object> mergedVars = new LinkedHashMap<>(existingVariables);
|
||||
Map<Object, @Nullable Object> processedMergeKeys = recursiveTransformer.transform(mergeKeys);
|
||||
mergedVars.putAll(processedMergeKeys);
|
||||
recursiveTransformer.resolveMergeKeys(mergedVars, ProcessingPhase.STANDARD);
|
||||
existingVariables.clear();
|
||||
mergedVars.forEach((k, v) -> existingVariables.put(String.valueOf(k), v));
|
||||
}
|
||||
} else if (variablesSection instanceof IncludePlaceholder includePlaceholder) {
|
||||
Object includedData = recursiveTransformer.transform(includePlaceholder, ProcessingPhase.INCLUDES);
|
||||
extractVariables(includedData, locator);
|
||||
} else if (variablesSection != null) {
|
||||
var position = locator.findPosition(ComposerConfig.VARIABLES_KEY);
|
||||
Path relativePath = ComposerConfig.configRoot().relativize(absolutePath);
|
||||
logger.warn("{}:{} 'variables' is not a map", relativePath, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.expression;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.LogSession;
|
||||
import org.openhab.io.yamlcomposer.internal.expression.filters.DigFilter;
|
||||
import org.openhab.io.yamlcomposer.internal.expression.filters.LabelFilter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.hubspot.jinjava.Jinjava;
|
||||
import com.hubspot.jinjava.JinjavaConfig;
|
||||
import com.hubspot.jinjava.features.FeatureConfig;
|
||||
import com.hubspot.jinjava.features.FeatureStrategies;
|
||||
import com.hubspot.jinjava.interpret.Context;
|
||||
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
|
||||
import com.hubspot.jinjava.interpret.TemplateError;
|
||||
import com.hubspot.jinjava.util.ObjectTruthValue;
|
||||
|
||||
/**
|
||||
* Wrapper around Jinjava template engine for rendering ${...} expressions.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ExpressionEvaluator {
|
||||
|
||||
private static final JinjavaConfig CONFIG;
|
||||
private static final Jinjava JINJAVA;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ExpressionEvaluator.class);
|
||||
|
||||
static {
|
||||
@SuppressWarnings("null")
|
||||
JinjavaConfig config = JinjavaConfig.newBuilder() //
|
||||
.withFeatureConfig(FeatureConfig.newBuilder()
|
||||
.add(JinjavaInterpreter.OUTPUT_UNDEFINED_VARIABLES_ERROR, FeatureStrategies.ACTIVE).build())
|
||||
.withFailOnUnknownTokens(false) //
|
||||
.withMaxRenderDepth(1) //
|
||||
.withMaxMacroRecursionDepth(0) // We don't use macros; disallow recursion
|
||||
.withEnableRecursiveMacroCalls(false) //
|
||||
.build();
|
||||
CONFIG = config;
|
||||
JINJAVA = new Jinjava(CONFIG);
|
||||
Context context = JINJAVA.getGlobalContext();
|
||||
context.registerFilter(new LabelFilter());
|
||||
context.registerFilter(new DigFilter());
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a Jinjava expression and return the raw object result (no string coercion).
|
||||
*
|
||||
* @param expression the expression content without delimiters (e.g., "user.profile")
|
||||
* @param variables the variable context
|
||||
* @return the evaluated object in its native type
|
||||
*/
|
||||
public static @Nullable Object renderObject(String expression, Map<String, @Nullable Object> variables,
|
||||
LogSession logSession, String sourceLocation) {
|
||||
@SuppressWarnings("null")
|
||||
Context context = new Context(JINJAVA.getGlobalContext(), variables);
|
||||
context.setDynamicVariableResolver(varName -> dynamicVariableResolver(varName, variables));
|
||||
JinjavaInterpreter interpreter = new JinjavaInterpreter(JINJAVA, context, CONFIG);
|
||||
|
||||
Object result = interpreter.resolveELExpression(expression, 0);
|
||||
|
||||
@SuppressWarnings("null")
|
||||
List<TemplateError> errors = interpreter.getErrorsCopy();
|
||||
if (!errors.isEmpty()) {
|
||||
List<String> messages = errors.stream().map((e) -> {
|
||||
String msg = Objects.requireNonNullElse(e.getMessage(), "");
|
||||
if (e.getItem() == TemplateError.ErrorItem.TOKEN) {
|
||||
String missingVarName = extractVariableName(e);
|
||||
if (missingVarName != null && variables.containsKey(missingVarName)) {
|
||||
// Jinjava emits a TOKEN error when a referenced variable resolves to null.
|
||||
// If the variable key exists in our variables map (even if its value is null),
|
||||
// treat it as intentionally defined and suppress an "undefined variable" suggestion.
|
||||
return null;
|
||||
}
|
||||
String suggestion = findClosestVariableName(missingVarName, variables);
|
||||
if (suggestion != null) {
|
||||
msg += " (Did you mean '" + suggestion + "'?)";
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}).filter(Objects::nonNull).toList();
|
||||
|
||||
if (!messages.isEmpty()) {
|
||||
String combinedMessage = (messages.size() == 1) //
|
||||
? " " + messages.getFirst() //
|
||||
: "\n- " + String.join("\n- ", messages);
|
||||
|
||||
String logMsg = String.format("%s Error evaluating expression '%s':%s", sourceLocation, expression,
|
||||
combinedMessage);
|
||||
|
||||
logSession.trackWarning(LOGGER, logMsg);
|
||||
}
|
||||
}
|
||||
return normalizeType(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the truthiness of a value according to Jinjava rules.
|
||||
*
|
||||
* @param value the value to evaluate
|
||||
* @return true if the value is considered truthy, false otherwise
|
||||
*/
|
||||
public static boolean isTruthy(@Nullable Object value) {
|
||||
return ObjectTruthValue.evaluate(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic variable resolver for Jinjava to provide special variables like "VARS" and "ENV".
|
||||
*
|
||||
* @param varName the name of the variable being resolved
|
||||
* @param context the current variable context
|
||||
* @return the value of the special variable, or null if it's not a special variable
|
||||
*/
|
||||
private static @Nullable Object dynamicVariableResolver(@Nullable String varName,
|
||||
Map<String, @Nullable Object> context) {
|
||||
if ("VARS".equals(varName)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
if ("ENV".equals(varName)) {
|
||||
return System.getenv();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize types returned by Jinjava to match SnakeYAML's
|
||||
*
|
||||
* Jinjava tends to return Long for all integer numbers.
|
||||
* SnakeYAML, however, uses Integer for small integers.
|
||||
* Normalize accordingly to avoid type mismatches.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```yaml
|
||||
* native: 1 # returns a Java Integer
|
||||
* jinja: ${ 1 } # before normalization returns a Java Long
|
||||
* ```
|
||||
*
|
||||
* @param obj the object to normalize
|
||||
* @return the normalized object
|
||||
*/
|
||||
private static @Nullable Object normalizeType(@Nullable Object obj) {
|
||||
if (obj instanceof Long longValue) {
|
||||
// Check if the value fits in an Integer
|
||||
if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) {
|
||||
return Math.toIntExact(longValue);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static @Nullable String extractVariableName(TemplateError error) {
|
||||
@SuppressWarnings("null")
|
||||
Map<String, String> categoryErrors = error.getCategoryErrors();
|
||||
String variableName = null;
|
||||
if (categoryErrors != null && categoryErrors.containsKey("variable")) {
|
||||
variableName = categoryErrors.get("variable");
|
||||
}
|
||||
|
||||
if (variableName == null) {
|
||||
String message = Objects.requireNonNullElse(error.getMessage(), "");
|
||||
variableName = message.replaceAll(".*'([^']+)'.*", "$1");
|
||||
}
|
||||
return variableName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the most similar variable name using a standalone Levenshtein implementation.
|
||||
*/
|
||||
private static @Nullable String findClosestVariableName(@Nullable String missingVar,
|
||||
Map<String, @Nullable Object> variables) {
|
||||
if (missingVar == null) {
|
||||
return null;
|
||||
}
|
||||
String bestMatch = null;
|
||||
int maxDistance = 3; // Threshold: only suggest if 1 or 2 edits away
|
||||
|
||||
for (String existingVar : variables.keySet()) {
|
||||
int distance = levenshteinDistance(missingVar, existingVar, maxDistance);
|
||||
if (distance < maxDistance) {
|
||||
maxDistance = distance;
|
||||
bestMatch = existingVar;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the Levenshtein distance between two strings with an early exit if the distance exceeds the threshold.
|
||||
*
|
||||
* @param s1 The first string
|
||||
* @param s2 The second string
|
||||
* @param threshold The maximum distance to calculate before exiting early
|
||||
* @return the Levenshtein distance, or Integer.MAX_VALUE if the distance exceeds the threshold
|
||||
*/
|
||||
private static int levenshteinDistance(@Nullable String s1, @Nullable String s2, int threshold) {
|
||||
if (s1 == null || s2 == null) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
int len1 = s1.length();
|
||||
int len2 = s2.length();
|
||||
|
||||
// If length difference is greater than our threshold, skip expensive math
|
||||
if (Math.abs(len1 - len2) >= threshold) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
int[] costs = new int[len2 + 1];
|
||||
for (int i = 0; i <= len1; i++) {
|
||||
int lastValue = i;
|
||||
for (int j = 0; j <= len2; j++) {
|
||||
if (i == 0) {
|
||||
costs[j] = j;
|
||||
} else if (j > 0) {
|
||||
int newValue = costs[j - 1];
|
||||
if (s1.charAt(i - 1) != s2.charAt(j - 1)) {
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
||||
}
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
}
|
||||
if (i > 0) {
|
||||
costs[len2] = lastValue;
|
||||
}
|
||||
}
|
||||
return costs[len2];
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.expression.filters;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
|
||||
import com.hubspot.jinjava.lib.filter.Filter;
|
||||
|
||||
/**
|
||||
* Custom Jinjava filter to dig into nested maps/lists.
|
||||
* Supports negative indices for lists to access elements from the end.
|
||||
*
|
||||
* Usage: variable|dig("key1", "key2", 0) to access variable["key1"]["key2"][0]
|
||||
* Also supports dot-notation in a single argument: variable|dig("key1.key2.0")
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DigFilter implements Filter {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "dig";
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public @Nullable Object filter(@Nullable Object var, JinjavaInterpreter interpreter, String... args) {
|
||||
Object current = var;
|
||||
|
||||
// Expand any dot-notation args (e.g. "a.b.c") into separate key segments
|
||||
java.util.List<Object> keys = new java.util.ArrayList<>();
|
||||
if (args != null) {
|
||||
for (String arg : args) {
|
||||
if (arg != null && arg.contains(".")) {
|
||||
String[] parts = arg.split("\\.");
|
||||
for (String p : parts) {
|
||||
keys.add(p);
|
||||
}
|
||||
} else {
|
||||
keys.add(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Object key : keys) { // Changed to Object to be more flexible
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current instanceof Map) {
|
||||
// Java Maps (like HashMap) can have a null key
|
||||
current = ((Map<?, ?>) current).get(key);
|
||||
} else if (current instanceof List) {
|
||||
if (key == null) {
|
||||
return null; // A list index cannot be null
|
||||
}
|
||||
current = getFromList((List<?>) current, key);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private @Nullable Object getFromList(List<?> list, Object key) {
|
||||
try {
|
||||
int index;
|
||||
if (key instanceof Integer intKey) {
|
||||
index = intKey;
|
||||
} else {
|
||||
index = Integer.parseInt(key.toString());
|
||||
}
|
||||
|
||||
int size = list.size();
|
||||
if (index < 0) {
|
||||
index = size + index;
|
||||
}
|
||||
|
||||
if (index >= 0 && index < size) {
|
||||
return list.get(index);
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.expression.filters;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.util.StringUtils;
|
||||
|
||||
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
|
||||
import com.hubspot.jinjava.lib.filter.Filter;
|
||||
|
||||
/**
|
||||
* Custom Jinjava filter to convert a string to a label.
|
||||
*
|
||||
* <p>
|
||||
* It inserts spaces before capital letters, replaces '_' , ':' and '-' with spaces,
|
||||
* and converts the result to title case.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class LabelFilter implements Filter {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "label";
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public @Nullable Object filter(@Nullable Object var, JinjavaInterpreter interpreter, String... args) {
|
||||
if (var == null) {
|
||||
return null;
|
||||
}
|
||||
String input = var.toString();
|
||||
String result = input //
|
||||
.replaceAll("([a-z])([A-Z])", "$1 $2") // Insert space before capital letters
|
||||
.replaceAll("([A-Z])([A-Z][a-z])", "$1 $2") // Handle cases like "HTTPServer" -> "HTTP Server"
|
||||
.replaceAll("([A-Za-z])([0-9])", "$1 $2") // Insert space between letters and numbers
|
||||
.replaceAll("([0-9])([A-Za-z])", "$1 $2") // Insert space between numbers and letters
|
||||
.replaceAll("[_:-]+", " ") // Replace '_' , ':' and '-' with spaces
|
||||
.replaceAll("\\s+", " "); // Replace multiple spaces with a single space
|
||||
// Title case
|
||||
result = StringUtils.capitalizeByWhitespace(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link IfPlaceholder} represents an object constructed from an <code>!if</code> node
|
||||
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* @param value The constructed object of the node containing the raw argument for the if placeholder
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record IfPlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<IfPlaceholder> {
|
||||
|
||||
@Override
|
||||
public IfPlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new IfPlaceholder(newValue, location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean eagerArgumentProcessing() {
|
||||
// Do not process the arguments before resolving the conditions
|
||||
// so that !include within unmet conditions are not processed
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link IncludePlaceholder} represents an object constructed from an <code>!include</code> node
|
||||
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* @param value The constructed object of the node containing the raw argument for the include placeholder
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record IncludePlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<IncludePlaceholder> {
|
||||
|
||||
@Override
|
||||
public IncludePlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new IncludePlaceholder(newValue, location);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link InsertPlaceholder} represents an object constructed from an <code>!insert</code> node
|
||||
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* @param value The constructed object of the node containing the raw argument for the insert placeholder
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record InsertPlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<InsertPlaceholder> {
|
||||
|
||||
@Override
|
||||
public InsertPlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new InsertPlaceholder(newValue, location);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Common abstraction for placeholders to allow generic access to their
|
||||
* argument data while preserving typed, record-based implementations.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface InterpolablePlaceholder<R extends InterpolablePlaceholder<R>> extends Placeholder {
|
||||
|
||||
@Nullable
|
||||
Object value();
|
||||
|
||||
/**
|
||||
* The factory method each record must implement to allow the generic withValue()
|
||||
* method to create new instances of the correct type.
|
||||
*/
|
||||
R recreate(@Nullable Object newValue, String location);
|
||||
|
||||
/**
|
||||
* Convenience method to create a new instance of the placeholder with a new value but the same source location.
|
||||
*
|
||||
* @param newValue the new value for the placeholder
|
||||
* @return a new instance of the placeholder with the given value and the same source location
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
default R withValue(@Nullable Object newValue) {
|
||||
if (Objects.equals(this.value(), newValue)) {
|
||||
return (R) this;
|
||||
}
|
||||
// Direct method call - no reflection needed
|
||||
return recreate(newValue, this.sourceLocation());
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, we want to eagerly process the arguments of placeholders before processing the placeholder itself
|
||||
* since most of them are just simple wrappers.
|
||||
*
|
||||
* However, some placeholders like !if need to resolve their conditions first before processing their arguments
|
||||
* to avoid processing arguments that are not relevant (e.g. !include within unmet conditions).
|
||||
*
|
||||
* Implementations can override this method to change this behavior.
|
||||
*
|
||||
* @return true if the arguments should be processed before processing the placeholder, false otherwise
|
||||
*/
|
||||
default boolean eagerArgumentProcessing() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link MergeKeyPlaceholder} replaces the merge key (<<) in a mapping node
|
||||
* as a unique object, so that multiple merge keys can be processed later.
|
||||
*
|
||||
* @param value The value associated with the merge key placeholder ('<<' - not used)
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record MergeKeyPlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<MergeKeyPlaceholder> {
|
||||
|
||||
@Override
|
||||
public MergeKeyPlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new MergeKeyPlaceholder(newValue, location);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Common abstraction for placeholders.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface Placeholder {
|
||||
/**
|
||||
* Source location string for logging and diagnostics.
|
||||
*
|
||||
* @return non-null source location
|
||||
*/
|
||||
String sourceLocation();
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link RemovePlaceholder} represents an object constructed from an <code>!remove</code> node
|
||||
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* @param value The value associated with the remove placeholder (not used)
|
||||
* @param sourceLocation The source location of the remove placeholder for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record RemovePlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<RemovePlaceholder> {
|
||||
|
||||
@Override
|
||||
public RemovePlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new RemovePlaceholder(newValue, location);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link ReplacePlaceholder} represents an object constructed from a <code>!replace</code> node
|
||||
* to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* @param value The value associated with the replace placeholder
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record ReplacePlaceholder(@Nullable Object value,
|
||||
@NonNull String sourceLocation) implements InterpolablePlaceholder<ReplacePlaceholder> {
|
||||
|
||||
@Override
|
||||
public ReplacePlaceholder recreate(@Nullable Object newValue, String location) {
|
||||
return new ReplacePlaceholder(newValue, location);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.placeholders;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNull;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link SubstitutionPlaceholder} represents a deferred string interpolation constructed from a <code>!sub</code>
|
||||
* node to be processed by the {@link org.openhab.io.yamlcomposer.internal.YamlComposer}.
|
||||
*
|
||||
* <p>
|
||||
* It preserves the raw scalar value and the optional variable name that defines delimiter pattern for
|
||||
* interpolation.
|
||||
*
|
||||
* @param value The raw string value containing variable interpolation patterns
|
||||
* @param patternName The variable name containing delimiter specification in the form <begin>..<end>
|
||||
* @param sourceLocation Description of the source location for logging purposes
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@SuppressWarnings("null")
|
||||
@NonNullByDefault
|
||||
public record SubstitutionPlaceholder(String value, @Nullable String patternName,
|
||||
@NonNull String sourceLocation) implements Placeholder {
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InterpolablePlaceholder;
|
||||
|
||||
/**
|
||||
* The {@link FragmentUtils} provides common functionality
|
||||
* for dealing with {@link org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder} and
|
||||
* {@link org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder} parameters.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class FragmentUtils {
|
||||
|
||||
public static record Parameters(@Nullable String name, Map<String, @Nullable Object> varsMap) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the parameters from an IncludePlaceholder or InsertPlaceholder.
|
||||
*/
|
||||
public static @Nullable Parameters parseParameters(InterpolablePlaceholder<?> placeholder, String objectName) {
|
||||
return switch (placeholder.value()) {
|
||||
case null -> null;
|
||||
case String name -> parseStringParameters(name, objectName);
|
||||
case Map<?, ?> paramsMap -> parseMapParameters(paramsMap, objectName);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static @Nullable Parameters parseMapParameters(Map<?, ?> paramsMap, String objectName) {
|
||||
if (!(paramsMap.get(objectName) instanceof String name)) {
|
||||
return new Parameters(null, Map.of());
|
||||
}
|
||||
if (!(paramsMap.get("vars") instanceof Map<?, ?> varsMap)) {
|
||||
return new Parameters(name, Map.of());
|
||||
}
|
||||
|
||||
Map<String, @Nullable Object> vars = varsMap.entrySet().stream().collect(LinkedHashMap::new,
|
||||
(m, v) -> m.put(String.valueOf(v.getKey()), v.getValue()), LinkedHashMap::putAll);
|
||||
return new Parameters(name, vars);
|
||||
}
|
||||
|
||||
private static Parameters parseStringParameters(String input, String objectName) {
|
||||
if (input.isBlank()) {
|
||||
return new Parameters(null, Map.of());
|
||||
}
|
||||
|
||||
// 1. Separate Name and Query without substrings yet
|
||||
int queryStart = input.indexOf('?');
|
||||
|
||||
// Process the Fragment Name
|
||||
String rawName = (queryStart == -1) ? input : input.substring(0, queryStart);
|
||||
String decodedName = safeDecode(rawName.trim());
|
||||
@Nullable
|
||||
String finalName = decodedName.isEmpty() ? null : decodedName;
|
||||
|
||||
// 2. Early exit if no query
|
||||
if (queryStart == -1 || queryStart == input.length() - 1) {
|
||||
return new Parameters(finalName, Map.of());
|
||||
}
|
||||
|
||||
// 3. Parse Query String efficiently
|
||||
Map<String, @Nullable Object> vars = new LinkedHashMap<>();
|
||||
int len = input.length();
|
||||
int pos = queryStart + 1;
|
||||
|
||||
while (pos < len) {
|
||||
int nextAmp = input.indexOf('&', pos);
|
||||
int end = (nextAmp == -1) ? len : nextAmp;
|
||||
|
||||
if (end > pos) { // Ignore empty pairs like '&&'
|
||||
parsePair(input, pos, end, vars);
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
|
||||
return new Parameters(finalName, vars);
|
||||
}
|
||||
|
||||
private static void parsePair(String input, int start, int end, Map<String, @Nullable Object> vars) {
|
||||
int eq = input.indexOf('=', start);
|
||||
|
||||
// If eq is outside our current segment, there is no value
|
||||
if (eq == -1 || eq >= end) {
|
||||
String key = safeDecode(input.substring(start, end).trim());
|
||||
if (!key.isEmpty()) {
|
||||
vars.put(key, Boolean.TRUE);
|
||||
}
|
||||
} else {
|
||||
String key = safeDecode(input.substring(start, eq).trim());
|
||||
String val = safeDecode(input.substring(eq + 1, end));
|
||||
if (!key.isEmpty()) {
|
||||
vars.put(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeDecode(String str) {
|
||||
if (str.indexOf('%') == -1 && str.indexOf('+') == -1) {
|
||||
return str; // Fast path: nothing to decode
|
||||
}
|
||||
try {
|
||||
return URLDecoder.decode(str, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Parameters back to a value object for IncludePlaceholder or InsertPlaceholder.
|
||||
*/
|
||||
public static @Nullable Object toValue(@Nullable String name, Map<String, @Nullable Object> varsMap,
|
||||
String objectName) {
|
||||
if (varsMap.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
if (name == null) {
|
||||
return Map.of("vars", varsMap);
|
||||
}
|
||||
return Map.of(objectName, name, "vars", varsMap);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.StringInterpolator;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.expression.ExpressionEvaluator;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IfPlaceholder;
|
||||
|
||||
/**
|
||||
* Processor for resolving {@link IfPlaceholder} in YAML models.
|
||||
* Evaluates conditions and returns the appropriate branch value.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IfProcessor implements PlaceholderProcessor<IfPlaceholder> {
|
||||
private final BufferedLogger logger;
|
||||
|
||||
public IfProcessor(BufferedLogger logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<IfPlaceholder> getPlaceholderType() {
|
||||
return IfPlaceholder.class;
|
||||
}
|
||||
|
||||
private record Branch(@Nullable Object condition, @Nullable Object thenValue) {
|
||||
}
|
||||
|
||||
private record Logic(List<Branch> branches, @Nullable Object elseValue) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object process(IfPlaceholder ifPlaceholder, RecursiveTransformer recursiveTransformer) {
|
||||
Logic logic = switch (ifPlaceholder.value()) {
|
||||
case null -> null;
|
||||
case Map<?, ?> map -> parseFromMapDefinition(map, ifPlaceholder.sourceLocation());
|
||||
case List<?> list -> parseFromListDefinition(list, ifPlaceholder.sourceLocation());
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (logic == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Branch branch : logic.branches()) {
|
||||
Object evaluated = switch (branch.condition()) {
|
||||
case null -> "false"; // Treat null condition as false
|
||||
case String s -> StringInterpolator.evaluateExpression(s, recursiveTransformer.getVariables(),
|
||||
logger.getLogSession(), ifPlaceholder.sourceLocation());
|
||||
default -> branch.condition();
|
||||
};
|
||||
|
||||
if (ExpressionEvaluator.isTruthy(evaluated)) {
|
||||
return branch.thenValue();
|
||||
}
|
||||
}
|
||||
return logic.elseValue();
|
||||
}
|
||||
|
||||
private @Nullable Logic parseFromMapDefinition(Map<?, ?> map, String sourceLocation) {
|
||||
Branch mainBranch = createBranch(map, false, false, sourceLocation);
|
||||
if (mainBranch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object elseValue = map.get("else");
|
||||
|
||||
return new Logic(List.of(mainBranch), elseValue);
|
||||
}
|
||||
|
||||
private @Nullable Logic parseFromListDefinition(List<?> list, String sourceLocation) {
|
||||
List<Branch> branches = new ArrayList<>();
|
||||
@Nullable
|
||||
Object elseValue = null;
|
||||
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
Object item = list.get(i);
|
||||
if (!(item instanceof Map<?, ?> map)) {
|
||||
logger.warn("{} !if list item is not a mapping and will be ignored: {}.", sourceLocation, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (map.containsKey("else")) {
|
||||
elseValue = map.get("else");
|
||||
if (map.containsKey("if") || map.containsKey("elseif") || map.containsKey("then")) {
|
||||
logger.warn("{} !if sequence item at index {} should only contain 'else'. Other keys ignored.",
|
||||
sourceLocation, i);
|
||||
}
|
||||
if (i < list.size() - 1) {
|
||||
logger.warn("{} !if list has unreachable branches after the 'else' block.", sourceLocation);
|
||||
}
|
||||
break; // Else stops evaluation of further branches
|
||||
}
|
||||
|
||||
Branch branch = createBranch(map, i > 0, true, sourceLocation);
|
||||
if (branch != null) {
|
||||
branches.add(branch);
|
||||
}
|
||||
}
|
||||
|
||||
if (branches.isEmpty()) {
|
||||
logger.warn("{} !if sequence has no valid branches.", sourceLocation);
|
||||
}
|
||||
|
||||
return new Logic(branches, elseValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Branch from the given map.
|
||||
*
|
||||
* @param map the map representing the branch
|
||||
* @param isElseIf whether this branch is an "elseif" branch
|
||||
* @param isList whether this is for a list (non-root) branch
|
||||
* @return the created Branch, or null if creation failed
|
||||
*/
|
||||
private @Nullable Branch createBranch(Map<?, ?> map, boolean isElseIf, boolean isList, String sourceLocation) {
|
||||
String key = isElseIf ? "elseif" : "if";
|
||||
boolean hasConditionKey = map.containsKey(key);
|
||||
boolean hasValueKey = map.containsKey("then");
|
||||
Object rawCondition = map.get(key); // Will be null if missing OR explicitly null
|
||||
Object rawValue = map.get("then");
|
||||
|
||||
if (rawCondition == null) {
|
||||
rawCondition = "false"; // Treat explicit null as false
|
||||
}
|
||||
|
||||
// 1. Guard: Check existence and nullability
|
||||
// Condition: must exist
|
||||
// Value: must exist (can be null)
|
||||
if (!hasValueKey || !hasConditionKey) {
|
||||
String prefix = isList ? "branch " : "";
|
||||
String suffix = isList ? "Ignoring branch." : "Returning null.";
|
||||
|
||||
String reason;
|
||||
if (!hasConditionKey && !hasValueKey) {
|
||||
reason = "is empty (missing both '" + key + "' and 'then' fields)";
|
||||
} else if (!hasConditionKey) {
|
||||
reason = "is missing '" + key + "' field";
|
||||
} else {
|
||||
reason = "is missing 'then' field";
|
||||
}
|
||||
|
||||
logger.warn("{} !if {}{}. {}", sourceLocation, prefix, reason, suffix);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Success: Value exists (even if null), Condition exists and is non-null
|
||||
return new Branch(rawCondition, rawValue);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystemException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.ComposerConfig;
|
||||
import org.openhab.io.yamlcomposer.internal.YamlComposer;
|
||||
import org.openhab.io.yamlcomposer.internal.YamlComposer.CacheEntry;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.IncludePlaceholder;
|
||||
import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException;
|
||||
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
|
||||
|
||||
/**
|
||||
* Processor for resolving {@link IncludePlaceholder} in YAML models
|
||||
* into the included file content.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IncludeProcessor implements PlaceholderProcessor<IncludePlaceholder> {
|
||||
|
||||
private final BufferedLogger logger;
|
||||
private final Path basePath;
|
||||
private final Set<Path> includeStack;
|
||||
private final Consumer<Path> includeCallback;
|
||||
private final ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache;
|
||||
|
||||
/**
|
||||
* Creates a new IncludeProcessor.
|
||||
*
|
||||
* @param basePath the base path to resolve relative includes against
|
||||
* @param includeStack the stack of currently included files
|
||||
* @param includeCallback the callback to invoke when a file is included
|
||||
* @param includeCache the cache for storing included file contents
|
||||
* @param logger the logger to use for logging messages
|
||||
*/
|
||||
public IncludeProcessor(Path basePath, Set<Path> includeStack, Consumer<Path> includeCallback,
|
||||
ConcurrentHashMap<Path, @Nullable CacheEntry> includeCache, BufferedLogger logger) {
|
||||
this.logger = logger;
|
||||
this.basePath = basePath.toAbsolutePath().normalize();
|
||||
this.includeStack = includeStack;
|
||||
this.includeCallback = includeCallback;
|
||||
this.includeCache = includeCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<IncludePlaceholder> getPlaceholderType() {
|
||||
return IncludePlaceholder.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an {@link IncludePlaceholder}, loads the referenced file
|
||||
* (recursively following any nested includes), and returns the fully expanded
|
||||
* content.
|
||||
*
|
||||
* @param placeholder the placeholder to process
|
||||
* @param recursiveTransformer the recursive transformer to use
|
||||
* @return the expanded content
|
||||
*/
|
||||
@Override
|
||||
public @Nullable Object process(IncludePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
|
||||
|
||||
FragmentUtils.Parameters params = FragmentUtils.parseParameters(placeholder, "file");
|
||||
if (params == null) {
|
||||
logger.warn("{} Failed to process !include: invalid parameters", placeholder.sourceLocation());
|
||||
return null;
|
||||
}
|
||||
|
||||
Object fileNameObj = params.name();
|
||||
if (fileNameObj == null || String.valueOf(fileNameObj).isBlank()) {
|
||||
logger.warn("{} Failed to process !include: missing 'file' parameter", placeholder.sourceLocation());
|
||||
return null;
|
||||
}
|
||||
|
||||
Path includePath = resolvePathPlaceholder(String.valueOf(fileNameObj), placeholder.sourceLocation())
|
||||
.toAbsolutePath().normalize();
|
||||
Path configRoot = ComposerConfig.configRoot();
|
||||
Path includePathRelative = includePath.startsWith(configRoot) ? configRoot.relativize(includePath)
|
||||
: includePath;
|
||||
|
||||
// Handle parameters and variables
|
||||
Map<String, @Nullable Object> includeVariables = new HashMap<>(recursiveTransformer.getVariables());
|
||||
includeVariables.putAll(params.varsMap()); // params override current variables
|
||||
|
||||
try {
|
||||
YamlComposer includeComposer = new YamlComposer(includePath, includeVariables, includeStack,
|
||||
includeCallback, logger.getLogSession(), includeCache);
|
||||
includeCallback.accept(includePath);
|
||||
return includeComposer.load();
|
||||
} catch (YamlEngineException | IOException e) {
|
||||
logIncludeError(placeholder, String.valueOf(fileNameObj), includePathRelative, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the file path from the !include statement, handling any placeholders
|
||||
* and providing detailed logging if resolution fails.
|
||||
*
|
||||
* Placeholders starting with '@' are resolved relative to OPENHAB_CONF,
|
||||
* while those starting with '$' are resolved relative to OPENHAB_CONF/yamlcomposer.
|
||||
* (i.e., {@link ComposerConfig#sourceRoot()}).
|
||||
*
|
||||
* This allows for flexible referencing of files
|
||||
* within the configuration structure.
|
||||
*
|
||||
* If no placeholder prefix is used, absolute paths are kept as-is and relative paths are resolved against the
|
||||
* including file's directory.
|
||||
*
|
||||
* @param includeFileName the file name from the !include statement, which may contain placeholders
|
||||
* @param sourceLocation the source location for logging purposes
|
||||
* @return the resolved Path to the included file, attempting to resolve placeholders if present
|
||||
*/
|
||||
private Path resolvePathPlaceholder(String includeFileName, String sourceLocation) {
|
||||
char prefix = includeFileName.charAt(0);
|
||||
Path root = switch (prefix) {
|
||||
case '@' -> ComposerConfig.configRoot();
|
||||
case '$' -> ComposerConfig.sourceRoot();
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (root != null) {
|
||||
String cleanedPath = includeFileName.replaceFirst("^[@$]/*", "");
|
||||
return root.resolve(cleanedPath);
|
||||
}
|
||||
|
||||
Path includeFile = Path.of(includeFileName);
|
||||
return includeFile.isAbsolute() ? includeFile.normalize() : basePath.resolve(includeFile);
|
||||
}
|
||||
|
||||
private void logIncludeError(IncludePlaceholder p, String name, Path path, Exception e) {
|
||||
String location = (e instanceof MarkedYamlEngineException me)
|
||||
? me.getProblemMark().map(m -> "%d:%d".formatted(m.getLine() + 1, m.getColumn() + 1)).orElse(null)
|
||||
: null;
|
||||
|
||||
String pathWithLocation = (location == null) ? path.toString() : path + ":" + location;
|
||||
String msg = (e instanceof IOException ioe) ? getFriendlyMessage(ioe) : e.getMessage();
|
||||
|
||||
logger.warn("{} Failed to process !include '{}'\n{} {}", p.sourceLocation(), name, pathWithLocation, msg);
|
||||
}
|
||||
|
||||
private static @Nullable String getFriendlyMessage(Exception e) {
|
||||
if (e instanceof FileSystemException fse) {
|
||||
// If the JDK provided a specific reason string, use it
|
||||
if (fse.getReason() != null && !fse.getReason().isBlank()) {
|
||||
return fse.getReason();
|
||||
}
|
||||
|
||||
// Otherwise, use our "Sentence case" class name logic
|
||||
String name = e.getClass().getSimpleName().replace("Exception", "");
|
||||
String spaced = name.replaceAll("([a-z])([A-Z])", "$1 $2").toLowerCase().trim();
|
||||
return (spaced.isBlank()) ? "File system error"
|
||||
: Character.toUpperCase(spaced.charAt(0)) + spaced.substring(1);
|
||||
}
|
||||
return e.getMessage();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.core.ProcessingPhase;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.InsertPlaceholder;
|
||||
|
||||
/**
|
||||
* Processor for resolving {@link InsertPlaceholder} in YAML models
|
||||
* into template content with local variable substitution.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class InsertProcessor implements PlaceholderProcessor<InsertPlaceholder> {
|
||||
|
||||
private final Map<Object, @Nullable Object> templates;
|
||||
private final BufferedLogger logger;
|
||||
|
||||
/**
|
||||
* Creates a new InsertProcessor.
|
||||
*
|
||||
* @param templates the map of available templates for insertion
|
||||
* @param logger the logger to use for logging messages
|
||||
*/
|
||||
public InsertProcessor(Map<Object, @Nullable Object> templates, BufferedLogger logger) {
|
||||
this.templates = templates;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<InsertPlaceholder> getPlaceholderType() {
|
||||
return InsertPlaceholder.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an {@link InsertPlaceholder} recursively
|
||||
* following any nested inserts, and returns the fully expanded content.
|
||||
*/
|
||||
@Override
|
||||
public @Nullable Object process(InsertPlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
|
||||
FragmentUtils.Parameters params = FragmentUtils.parseParameters(placeholder, "template");
|
||||
if (params == null) {
|
||||
logger.warn("{} Failed to process !insert: invalid parameters", placeholder.sourceLocation());
|
||||
return null;
|
||||
}
|
||||
|
||||
String templateName = params.name();
|
||||
if (templateName == null || templateName.isBlank()) {
|
||||
logger.warn("{} Failed to process !insert: missing template name", placeholder.sourceLocation());
|
||||
return null;
|
||||
}
|
||||
|
||||
Object templateObj = templates.get(templateName);
|
||||
if (templateObj == null) {
|
||||
logger.warn("{} Failed to process !insert '{}': template not found", placeholder.sourceLocation(),
|
||||
templateName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The substitution placeholders in the template are resolved using the templateVariables context
|
||||
// unlike any other processing which uses the main variables map
|
||||
Map<String, @Nullable Object> templateVariables = params.varsMap();
|
||||
RecursiveTransformer localTransformer = recursiveTransformer.withOverrideVariables(templateVariables);
|
||||
// Keep package override placeholders (!remove/!replace) intact so they are only
|
||||
// applied in the dedicated PACKAGE_OVERRIDES phase.
|
||||
Object resolvedTemplate = localTransformer.transform(templateObj, ProcessingPhase.STANDARD);
|
||||
return resolvedTemplate;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.Placeholder;
|
||||
|
||||
/**
|
||||
* The {@link PlaceholderProcessor} processes placeholder instances in YAML models.
|
||||
*
|
||||
* @param <T> The type of placeholder to be processed
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface PlaceholderProcessor<T extends Placeholder> {
|
||||
|
||||
@Nullable
|
||||
Object process(T placeholder, RecursiveTransformer recursiveTransformer);
|
||||
|
||||
Class<T> getPlaceholderType();
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RemovalSignal;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.RemovePlaceholder;
|
||||
|
||||
/**
|
||||
* The {@link RemoveProcessor} processes {@link RemovePlaceholder} instances in YAML models.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RemoveProcessor implements PlaceholderProcessor<RemovePlaceholder> {
|
||||
|
||||
@Override
|
||||
public Class<RemovePlaceholder> getPlaceholderType() {
|
||||
return RemovePlaceholder.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object process(RemovePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
|
||||
return RemovalSignal.REMOVE;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.ReplacePlaceholder;
|
||||
|
||||
/**
|
||||
* The {@link ReplaceProcessor} processes {@link ReplacePlaceholder} instances in YAML models.
|
||||
* It simply returns the value contained within the placeholder.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ReplaceProcessor implements PlaceholderProcessor<ReplacePlaceholder> {
|
||||
|
||||
@Override
|
||||
public Class<ReplacePlaceholder> getPlaceholderType() {
|
||||
return ReplacePlaceholder.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object process(ReplacePlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
|
||||
return placeholder.value();
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal.processors;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.io.yamlcomposer.internal.BufferedLogger;
|
||||
import org.openhab.io.yamlcomposer.internal.StringInterpolator;
|
||||
import org.openhab.io.yamlcomposer.internal.core.RecursiveTransformer;
|
||||
import org.openhab.io.yamlcomposer.internal.placeholders.SubstitutionPlaceholder;
|
||||
|
||||
/**
|
||||
* Processor for handling variable substitutions by resolving {@code SubstitutionPlaceholder} in YAML models
|
||||
* into interpolated strings.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SubstitutionProcessor implements PlaceholderProcessor<SubstitutionPlaceholder> {
|
||||
|
||||
private final BufferedLogger logger;
|
||||
|
||||
public SubstitutionProcessor(BufferedLogger logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<SubstitutionPlaceholder> getPlaceholderType() {
|
||||
return SubstitutionPlaceholder.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve all SubstitutionPlaceholders in a value (map, list, or scalar).
|
||||
* using the RecursiveTransformer's variables as the substitution context.
|
||||
*
|
||||
* @param value The value to process
|
||||
* @return The processed value with substitutions applied
|
||||
*/
|
||||
@Override
|
||||
public @Nullable Object process(SubstitutionPlaceholder placeholder, RecursiveTransformer recursiveTransformer) {
|
||||
return process(placeholder, recursiveTransformer.getVariables());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve all SubstitutionPlaceholders in a value (map, list, or scalar)
|
||||
* using the provided context for substitutions.
|
||||
*
|
||||
* This overload is needed by {@link InsertProcessor} to apply substitutions
|
||||
* with a custom variable context when processing templates.
|
||||
*
|
||||
* @param value The value to process
|
||||
* @param context The variable context for substitutions
|
||||
* @return The processed value with substitutions applied
|
||||
*/
|
||||
public @Nullable Object process(SubstitutionPlaceholder placeholder, Map<String, @Nullable Object> context) {
|
||||
Pattern pattern = resolvePattern(placeholder, context);
|
||||
return StringInterpolator.interpolate(placeholder.value(), pattern, context, logger.getLogSession(),
|
||||
placeholder.sourceLocation());
|
||||
}
|
||||
|
||||
private Pattern resolvePattern(SubstitutionPlaceholder placeholder, Map<String, @Nullable Object> context) {
|
||||
String patternName = placeholder.patternName();
|
||||
if (patternName == null || patternName.isBlank()) {
|
||||
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
|
||||
}
|
||||
|
||||
Object rawPatternSpec = context.get(patternName);
|
||||
if (!(rawPatternSpec instanceof String patternSpec) || patternSpec.isBlank()) {
|
||||
logger.warn("{} Undefined or invalid pattern variable '{}' for !sub tag; using default pattern.",
|
||||
placeholder.sourceLocation(), patternName);
|
||||
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
|
||||
}
|
||||
|
||||
Pattern compiled = StringInterpolator.compilePatternSpec(patternSpec);
|
||||
if (compiled == null) {
|
||||
logger.warn("{} Invalid pattern specification '{}' in variable '{}' for !sub tag; using default pattern.",
|
||||
placeholder.sourceLocation(), patternSpec, patternName);
|
||||
return StringInterpolator.DEFAULT_SUBSTITUTION_PATTERN;
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="yamlcomposer" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>misc</type>
|
||||
<name>YAML Composer</name>
|
||||
<description>This add-on processes YAML with extended syntax (includes, packages, variables) and outputs fully resolved
|
||||
plain YAML for openHAB YAML Configuration.</description>
|
||||
<connection>local</connection>
|
||||
|
||||
<service-id>org.openhab.io.yamlcomposer</service-id>
|
||||
</addon:addon>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# add-on
|
||||
|
||||
addon.yamlcomposer.name = YAML Composer
|
||||
addon.yamlcomposer.description = This add-on processes YAML with extended syntax (includes, packages, variables) and outputs fully resolved plain YAML for openHAB YAML Configuration.
|
||||
+3263
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
||||
<module>org.openhab.io.metrics</module>
|
||||
<module>org.openhab.io.neeo</module>
|
||||
<module>org.openhab.io.openhabcloud</module>
|
||||
<module>org.openhab.io.yamlcomposer</module>
|
||||
<!-- transformations -->
|
||||
<module>org.openhab.transform.basicprofiles</module>
|
||||
<module>org.openhab.transform.bin2json</module>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
@@ -0,0 +1,76 @@
|
||||
-include: ../itest-common.bndrun
|
||||
|
||||
Bundle-SymbolicName: ${project.artifactId}
|
||||
Fragment-Host: org.openhab.io.yamlcomposer
|
||||
|
||||
-runrequires: \
|
||||
bnd.identity;id='org.openhab.io.yamlcomposer.tests',\
|
||||
bnd.identity;id='org.openhab.core',\
|
||||
bnd.identity;id='org.openhab.io.yamlcomposer'
|
||||
|
||||
# We would like to use the "volatile" storage only
|
||||
-runblacklist: \
|
||||
bnd.identity;id='org.openhab.core.storage.json'
|
||||
|
||||
#
|
||||
# done
|
||||
#
|
||||
-runbundles: \
|
||||
biz.aQute.tester.junit-platform;version='[7.2.3,7.2.4)',\
|
||||
ch.qos.logback.classic;version='[1.5.32,1.5.33)',\
|
||||
ch.qos.logback.core;version='[1.5.32,1.5.33)',\
|
||||
com.fasterxml.jackson.core.jackson-annotations;version='[2.21.0,2.21.1)',\
|
||||
com.fasterxml.jackson.core.jackson-core;version='[2.21.3,2.21.4)',\
|
||||
com.fasterxml.jackson.core.jackson-databind;version='[2.21.3,2.21.4)',\
|
||||
com.fasterxml.jackson.dataformat.jackson-dataformat-yaml;version='[2.21.3,2.21.4)',\
|
||||
com.google.gson;version='[2.13.2,2.13.3)',\
|
||||
com.google.guava;version='[33.6.0,33.6.1)',\
|
||||
com.google.guava.failureaccess;version='[1.0.3,1.0.4)',\
|
||||
com.sun.jna;version='[5.18.1,5.18.2)',\
|
||||
com.sun.xml.bind.jaxb-osgi;version='[2.3.9,2.3.10)',\
|
||||
io.methvin.directory-watcher;version='[0.19.1,0.19.2)',\
|
||||
jakarta.annotation-api;version='[2.1.1,2.1.2)',\
|
||||
jakarta.inject.jakarta.inject-api;version='[2.0.1,2.0.2)',\
|
||||
jakarta.xml.bind-api;version='[2.3.3,2.3.4)',\
|
||||
javax.measure.unit-api;version='[2.2.0,2.2.1)',\
|
||||
junit-jupiter-api;version='[5.14.4,5.14.5)',\
|
||||
junit-jupiter-engine;version='[5.14.4,5.14.5)',\
|
||||
junit-platform-commons;version='[1.14.4,1.14.5)',\
|
||||
junit-platform-engine;version='[1.14.4,1.14.5)',\
|
||||
junit-platform-launcher;version='[1.14.4,1.14.5)',\
|
||||
org.apache.aries.spifly.dynamic.bundle;version='[1.3.7,1.3.8)',\
|
||||
org.apache.felix.configadmin;version='[1.9.26,1.9.27)',\
|
||||
org.apache.felix.http.servlet-api;version='[1.2.0,1.2.1)',\
|
||||
org.apache.felix.scr;version='[2.2.18,2.2.19)',\
|
||||
org.apache.servicemix.specs.activation-api-1.2.1;version='[1.2.1,1.2.2)',\
|
||||
org.eclipse.equinox.event;version='[1.7.300,1.7.301)',\
|
||||
org.eclipse.jetty.http;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.io;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.security;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.server;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.servlet;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.util;version='[9.4.58,9.4.59)',\
|
||||
org.eclipse.jetty.util.ajax;version='[9.4.58,9.4.59)',\
|
||||
org.glassfish.hk2.osgi-resource-locator;version='[3.0.0,3.0.1)',\
|
||||
org.hamcrest;version='[3.0.0,3.0.1)',\
|
||||
org.objectweb.asm;version='[9.9.1,9.9.2)',\
|
||||
org.objectweb.asm.commons;version='[9.9.1,9.9.2)',\
|
||||
org.objectweb.asm.tree;version='[9.9.1,9.9.2)',\
|
||||
org.objectweb.asm.tree.analysis;version='[9.9.1,9.9.2)',\
|
||||
org.objectweb.asm.util;version='[9.9.1,9.9.2)',\
|
||||
org.openhab.core;version='[5.2.0,5.2.1)',\
|
||||
org.openhab.core.test;version='[5.2.0,5.2.1)',\
|
||||
org.openhab.io.yamlcomposer;version='[5.2.0,5.2.1)',\
|
||||
org.openhab.io.yamlcomposer.tests;version='[5.2.0,5.2.1)',\
|
||||
org.opentest4j;version='[1.3.0,1.3.1)',\
|
||||
org.ops4j.pax.logging.pax-logging-api;version='[2.3.3,2.3.4)',\
|
||||
org.osgi.service.component;version='[1.5.1,1.5.2)',\
|
||||
org.osgi.service.event;version='[1.4.1,1.4.2)',\
|
||||
org.osgi.util.function;version='[1.2.0,1.2.1)',\
|
||||
org.osgi.util.promise;version='[1.3.0,1.3.1)',\
|
||||
org.snakeyaml.engine;version='[3.0.1,3.0.2)',\
|
||||
org.yaml.snakeyaml;version='[2.5.0,2.5.1)',\
|
||||
si-quantity;version='[2.2.3,2.2.4)',\
|
||||
si-units;version='[2.2.3,2.2.4)',\
|
||||
tech.units.indriya;version='[2.2.3,2.2.4)',\
|
||||
uom-lib-common;version='[2.2.0,2.2.1)'
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.itests</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.itests</artifactId>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.io.yamlcomposer.tests</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Integration Tests :: YAML Composer IO Tests</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.io.yamlcomposer</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.io.yamlcomposer.internal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.core.service.WatchService.Kind;
|
||||
import org.openhab.core.test.java.JavaOSGiTest;
|
||||
|
||||
/**
|
||||
* OSGi integration tests for {@link YamlComposerWatchService}.
|
||||
*
|
||||
* @author Jimmy Tanagra - Initial contribution
|
||||
*/
|
||||
public class YamlComposerWatchServiceOSGiTest extends JavaOSGiTest {
|
||||
private YamlComposerWatchService watchService;
|
||||
private Path testSourceDir;
|
||||
private Path testOutputDir;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws IOException {
|
||||
watchService = getService(YamlComposerWatchService.class);
|
||||
assertNotNull(watchService);
|
||||
|
||||
testSourceDir = ComposerConfig.sourceRoot();
|
||||
testOutputDir = ComposerConfig.outputRoot();
|
||||
|
||||
deleteRecursively(testSourceDir);
|
||||
deleteRecursively(testOutputDir);
|
||||
|
||||
Files.createDirectories(testSourceDir.resolve("defs"));
|
||||
Files.createDirectories(testOutputDir);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws IOException {
|
||||
deleteRecursively(testSourceDir);
|
||||
deleteRecursively(testOutputDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGenerateOutputFromSourceAndRecompileOnIncludeChange() throws IOException {
|
||||
Path includeFile = testSourceDir.resolve("defs/items.inc.yaml");
|
||||
Path mainFile = testSourceDir.resolve("main.yaml");
|
||||
Path generatedFile = testOutputDir.resolve("main.yaml");
|
||||
|
||||
Files.writeString(includeFile, """
|
||||
- type: Switch
|
||||
name: MySwitch
|
||||
label: Label One
|
||||
""");
|
||||
Files.writeString(mainFile, """
|
||||
version: 1
|
||||
items: !include defs/items.inc.yaml
|
||||
""");
|
||||
|
||||
watchService.processWatchEvent(Kind.CREATE, mainFile);
|
||||
|
||||
waitForAssert(() -> {
|
||||
assertTrue(Files.exists(generatedFile));
|
||||
assertTrue(fileContains(generatedFile, "Label One"));
|
||||
});
|
||||
|
||||
Files.writeString(includeFile, """
|
||||
- type: Switch
|
||||
name: MySwitch
|
||||
label: Label Two
|
||||
""");
|
||||
|
||||
watchService.processWatchEvent(Kind.MODIFY, includeFile);
|
||||
|
||||
waitForAssert(() -> {
|
||||
assertTrue(Files.exists(generatedFile));
|
||||
assertTrue(fileContains(generatedFile, "Label Two"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRemoveGeneratedOutputOnSourceDelete() throws IOException {
|
||||
Path mainFile = testSourceDir.resolve("delete-me.yaml");
|
||||
Path generatedFile = testOutputDir.resolve("delete-me.yaml");
|
||||
|
||||
Files.writeString(mainFile, """
|
||||
version: 1
|
||||
items:
|
||||
- type: Switch
|
||||
name: DeleteMe
|
||||
""");
|
||||
|
||||
watchService.processWatchEvent(Kind.CREATE, mainFile);
|
||||
|
||||
waitForAssert(() -> assertTrue(Files.exists(generatedFile)));
|
||||
|
||||
watchService.processWatchEvent(Kind.DELETE, mainFile);
|
||||
|
||||
waitForAssert(() -> assertFalse(Files.exists(generatedFile)));
|
||||
}
|
||||
|
||||
private static boolean fileContains(Path file, String expectedContent) {
|
||||
try {
|
||||
return Files.readString(file).contains(expectedContent);
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("Failed to read file " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteRecursively(Path path) throws IOException {
|
||||
if (!Files.exists(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Files.walkFileTree(path, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||
Files.deleteIfExists(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
<module>org.openhab.binding.modbus.tests</module>
|
||||
<module>org.openhab.binding.mqtt.ruuvigateway.tests</module>
|
||||
<module>org.openhab.binding.ntp.tests</module>
|
||||
<module>org.openhab.io.yamlcomposer.tests</module>
|
||||
<module>org.openhab.binding.systeminfo.tests</module>
|
||||
<module>org.openhab.binding.tradfri.tests</module>
|
||||
<module>org.openhab.persistence.mapdb.tests</module>
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
<Class name="org.openhab.core.model.script.actions.Log"/>
|
||||
<Bug pattern="SLF4J_FORMAT_SHOULD_BE_CONST"/>
|
||||
</Match>
|
||||
<!-- BufferedLogger intentionally accepts dynamic SLF4J templates, so the format string cannot be constant -->
|
||||
<Match>
|
||||
<Class name="org.openhab.io.yamlcomposer.internal.BufferedLogger"/>
|
||||
<Bug pattern="SLF4J_FORMAT_SHOULD_BE_CONST"/>
|
||||
</Match>
|
||||
<Match>
|
||||
<Bug pattern="SLF4J_UNKNOWN_ARRAY"/>
|
||||
</Match>
|
||||
|
||||
Reference in New Issue
Block a user