Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bom/compile/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.20.0</version>
<version>${commons.lang3.version}</version>
<scope>compile</scope>
</dependency>

Expand Down
4 changes: 1 addition & 3 deletions bom/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
<californium.version>4.0.0-M2</californium.version>
<!-- cxf can be updated independently of karaf, only used in karaf tests -->
<cxf.version>3.6.9</cxf.version>
<jackson.annotations.version>2.21</jackson.annotations.version>
<jackson.version>2.21.0</jackson.version>
<jetty.version>9.4.58.v20250814</jetty.version>
<pax.logging.version>2.3.0</pax.logging.version>
<pax.web.version>8.0.34</pax.web.version>
Expand Down Expand Up @@ -417,7 +415,7 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.20.0</version>
<version>${commons.lang3.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.automation.module.script.ScriptDependencyTracker;
import org.openhab.core.automation.module.script.rulesupport.internal.loader.BidiSetBag;
import org.openhab.core.common.BidiSetBag;
import org.openhab.core.service.WatchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down
66 changes: 66 additions & 0 deletions bundles/org.openhab.core.model.yaml/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,71 @@
<artifactId>org.openhab.core.thing</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.jinjava.jinjava</artifactId>
<version>2.7.4</version>
</dependency>

<!--
Jinjava's transitive dependencies (Guava, Jackson, re2j, etc.)
are packaged in the openhab.tp-jinjava which is referenced
within feature.xml, so they aren't needed here for build or runtime.

However, without adding them here, the tests will fail.
And we can't just use test scope either, otherwise they'll break the compilation.
-->
<dependency>
<groupId>com.google.re2j</groupId>
<artifactId>re2j</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson.annotations.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<!-- Use the version defined in the main pom.xml -->
<version>${commons.lang3.version}</version>
</dependency>
Comment thread
jimtng marked this conversation as resolved.
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<!-- Use the version defined in the main pom.xml -->
<version>${commons.net.version}</version>
</dependency>
Comment thread
jimtng marked this conversation as resolved.
Comment thread
jimtng marked this conversation as resolved.
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.immutables.immutables-exceptions</artifactId>
<version>1.9</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
package org.openhab.core.model.yaml.internal;

import static org.openhab.core.model.yaml.YamlModelUtils.*;
import static org.openhab.core.service.WatchService.Kind.CREATE;

import java.io.IOException;
import java.io.InputStream;
Expand Down Expand Up @@ -45,6 +44,8 @@
import org.openhab.core.model.yaml.internal.items.YamlItemDTO;
import org.openhab.core.model.yaml.internal.semantics.YamlSemanticTagDTO;
import org.openhab.core.model.yaml.internal.things.YamlThingDTO;
import org.openhab.core.model.yaml.internal.util.preprocessor.IncludeRegistry;
import org.openhab.core.model.yaml.internal.util.preprocessor.YamlPreprocessor;
import org.openhab.core.service.WatchService;
import org.openhab.core.service.WatchService.Kind;
import org.osgi.service.component.annotations.Activate;
Expand Down Expand Up @@ -81,6 +82,8 @@
* @author Laurent Garnier - new parameters to retrieve errors and warnings when loading a file
* @author Laurent Garnier - Added methods addElementsToBeGenerated, generateFileFormat, createIsolatedModel and
* removeIsolatedModel
* @author Jimmy Tanagra - Added Yaml preprocessor to support variable substitutions, templates, packages, and
* include-file processing
*/
@NonNullByDefault
@Component(immediate = true)
Expand All @@ -89,6 +92,9 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
private static final String VERSION = "version";
private static final String READ_ONLY = "readOnly";
private static final Set<String> KNOWN_ELEMENTS = Set.of( //
// "version", "readOnly" are reserved keys
// "preprocessor", "variables", "templates", and "packages" are reserved elements for YamlPreprocessor.
// They are listed here so we don't use them in the future as model elements
getElementName(YamlSemanticTagDTO.class), // "tags"
getElementName(YamlThingDTO.class), // "things"
getElementName(YamlItemDTO.class) // "items"
Expand All @@ -104,6 +110,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
private final WatchService watchService;
private final Path mainWatchPath;
private final ObjectMapper objectMapper;
private final ObjectMapper nullPreservingMapper;

private final Map<String, List<YamlModelListener<?>>> elementListeners = new ConcurrentHashMap<>();
// all model nodes, ordered by model name (full path as string) and type
Expand All @@ -113,6 +120,9 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,

private int counter;

private final IncludeRegistry includeRegistry = new IncludeRegistry();
private volatile boolean initializing = true;

@Activate
public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) {
YAMLFactory yamlFactory = YAMLFactory.builder() //
Expand All @@ -122,15 +132,18 @@ public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_F
.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) // use quotes only where necessary
.enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) // use quotes for numbers stored as
// strings
.enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS).build(); // do not parse ON/OFF/... as
// booleans
.enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS) // do not parse ON/OFF/... as booleans
.build();
this.objectMapper = new ObjectMapper(yamlFactory);
objectMapper.findAndRegisterModules();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
objectMapper.setSerializationInclusion(Include.NON_NULL);
objectMapper.setDefaultPropertyInclusion(Include.NON_NULL);
objectMapper.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN);

// Create a secondary mapper that shares configuration but overrides inclusion
this.nullPreservingMapper = objectMapper.copy().setDefaultPropertyInclusion(Include.ALWAYS);

this.watchService = watchService;
this.mainWatchPath = watchService.getWatchPath();

Expand All @@ -151,7 +164,7 @@ public YamlModelRepositoryImpl(@Reference(target = WatchService.CONFIG_WATCHER_F
public FileVisitResult visitFile(@NonNullByDefault({}) Path file,
@NonNullByDefault({}) BasicFileAttributes attrs) throws IOException {
if (attrs.isRegularFile()) {
processWatchEvent(CREATE, file);
processWatchEvent(Kind.CREATE, file);
}
return FileVisitResult.CONTINUE;
}
Expand All @@ -175,30 +188,64 @@ public FileVisitResult visitFileFailed(@NonNullByDefault({}) Path file,
e.getMessage());
}
});
initializing = false;
}

@Deactivate
public void deactivate() {
watchService.unregisterListener(this);
includeRegistry.clear();
}

// The method is "synchronized" to avoid concurrent files processing
@Override
public synchronized void processWatchEvent(Kind kind, Path fullPath) {
Path relativePath = mainWatchPath.relativize(fullPath);
if (YamlPreprocessor.isGeneratedFile(relativePath)) {
return;
}

// When an include file is changed, reload all models depending on it
if (!initializing && includeRegistry.hasInclude(fullPath)) {
Set<String> dependingModels = includeRegistry.getModelsForInclude(fullPath);
String action = switch (kind) {
case CREATE -> "created";
case DELETE -> "deleted";
case MODIFY -> "modified";
default -> kind.name();
};
logger.info("An include file '{}' was {}", fullPath, action);
dependingModels.forEach(modelName -> {
Path modelPath = mainWatchPath.resolve(modelName);
processWatchEvent(Kind.MODIFY, modelPath);
});
return;
}

String modelName = relativePath.toString();
if (!modelName.endsWith(".yaml") && !modelName.endsWith(".yml")) {
logger.trace("Ignored {}", fullPath);
return;
}

if (YamlPreprocessor.isIncludeFile(modelName)) {
logger.trace("Ignored include file {}", fullPath);
return;
}

includeRegistry.removeModel(modelName);
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
boolean removeModel = true;
try {
if (kind == WatchService.Kind.DELETE) {
removeModel(modelName);
if (kind == Kind.DELETE) {
// remove model below
} else if (!Files.isHidden(fullPath) && Files.isReadable(fullPath) && !Files.isDirectory(fullPath)) {
processModelContent(modelName, kind, objectMapper.readTree(fullPath.toFile()), errors, warnings);
Object yamlObject = YamlPreprocessor.load(fullPath,
includePath -> includeRegistry.registerModelInclude(modelName, includePath));
JsonNode contentNode = nullPreservingMapper.valueToTree(yamlObject);
processModelContent(modelName, kind, contentNode, errors, warnings);
removeModel = false;
} else {
logger.trace("Ignored {}", fullPath);
}
Comment thread
jimtng marked this conversation as resolved.
Expand All @@ -211,6 +258,9 @@ public synchronized void processWatchEvent(Kind kind, Path fullPath) {
warnings.forEach(warning -> {
logger.info("YAML model {}: {}", modelName, warning);
});
if (removeModel) {
removeModel(modelName);
}
}

@SuppressWarnings({ "rawtypes", "unchecked" })
Expand Down
Original file line number Diff line number Diff line change
@@ -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.core.model.yaml.internal.util.preprocessor;

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);
}
}
Loading