From 18dd2c1d229602ab9a5582aba451e8d1b597fec4 Mon Sep 17 00:00:00 2001 From: Laurent Garnier Date: Sat, 17 Jun 2023 10:06:56 +0200 Subject: [PATCH] Add a YAML file provider for semantic tags Files in folder conf/tags are loaded by this provider. Related to #3619 Signed-off-by: Laurent Garnier --- .../internal/config/yaml/YamlSemanticTag.java | 47 ++++ .../config/yaml/YamlSemanticTagProvider.java | 125 +++++++++++ .../config/yaml/YamlSemanticTags.java | 44 ++++ .../semantics/model/yaml/YamlElement.java | 25 +++ .../core/semantics/model/yaml/YamlFile.java | 46 ++++ .../model/yaml/YamlModelListener.java | 39 ++++ .../model/yaml/YamlModelRepository.java | 200 ++++++++++++++++++ .../model/yaml/YamlParseException.java | 39 ++++ .../openhab-core/src/main/feature/feature.xml | 1 + 9 files changed, 566 insertions(+) create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTag.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTagProvider.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTags.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlElement.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlFile.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelListener.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelRepository.java create mode 100644 bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlParseException.java diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTag.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTag.java new file mode 100644 index 00000000000..8b5adbc8481 --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTag.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2023 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.semantics.internal.config.yaml; + +import java.util.List; + +import org.openhab.core.semantics.model.yaml.YamlElement; +import org.openhab.core.semantics.model.yaml.YamlParseException; + +/** + * The {@link YamlSemanticTag} is a data transfer object used to serialize a semantic tag + * in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +public class YamlSemanticTag implements YamlElement { + + public String uid; + public String label; + public String description; + public List synonyms; + + public YamlSemanticTag() { + } + + @Override + public String getId() { + return uid; + } + + @Override + public void checkValidity() throws YamlParseException { + if (uid == null) { + throw new YamlParseException("uid missing"); + } + } +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTagProvider.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTagProvider.java new file mode 100644 index 00000000000..5f79737ee11 --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTagProvider.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2010-2023 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.semantics.internal.config.yaml; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.AbstractProvider; +import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.semantics.SemanticTagImpl; +import org.openhab.core.semantics.SemanticTagProvider; +import org.openhab.core.semantics.SemanticTagRegistry; +import org.openhab.core.semantics.model.yaml.YamlElement; +import org.openhab.core.semantics.model.yaml.YamlFile; +import org.openhab.core.semantics.model.yaml.YamlModelListener; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link YamlSemanticTagProvider} is an OSGi service, that allows to define semantic tags + * in YAML configuration files in folder conf/tags. + * Files can be added, updated or removed at runtime. + * These semantic tags are automatically exposed to the {@link SemanticTagRegistry}. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { SemanticTagProvider.class, YamlSemanticTagProvider.class, + YamlModelListener.class }) +public class YamlSemanticTagProvider extends AbstractProvider + implements SemanticTagProvider, YamlModelListener { + + private final Logger logger = LoggerFactory.getLogger(YamlSemanticTagProvider.class); + + private final List tags = new ArrayList<>(); + + @Activate + public YamlSemanticTagProvider() { + } + + @Deactivate + public void deactivate() { + tags.clear(); + } + + @Override + public Collection getAll() { + return tags.stream().sorted(Comparator.comparing(SemanticTag::getUID)).toList(); + } + + @Override + public String getRootName() { + return "tags"; + } + + @Override + public Class getFileClass() { + return YamlSemanticTags.class; + } + + @Override + public Class getElementClass() { + return YamlSemanticTag.class; + } + + @Override + public void addedModel(String modelName, List elements) { + List added = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)) + .sorted(Comparator.comparing(SemanticTag::getUID)).toList(); + tags.addAll(added); + added.forEach(t -> { + logger.debug("model {} added tag {}", modelName, t.getUID()); + notifyListenersAboutAddedElement(t); + }); + } + + @Override + public void updatedModel(String modelName, List elements) { + List updated = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)).toList(); + updated.forEach(t -> { + tags.stream().filter(tag -> tag.getUID().equals(t.getUID())).findFirst().ifPresentOrElse(oldTag -> { + tags.remove(oldTag); + tags.add(t); + logger.debug("model {} updated tag {}", modelName, t.getUID()); + notifyListenersAboutUpdatedElement(oldTag, t); + }, () -> logger.debug("model {} tag {} not found", modelName, t.getUID())); + }); + } + + @Override + public void removedModel(String modelName, List elements) { + List removed = elements.stream().map(e -> mapSemanticTag((YamlSemanticTag) e)) + .sorted(Comparator.comparing(SemanticTag::getUID).reversed()).toList(); + removed.forEach(t -> { + tags.stream().filter(tag -> tag.getUID().equals(t.getUID())).findFirst().ifPresentOrElse(oldTag -> { + tags.remove(oldTag); + logger.debug("model {} removed tag {}", modelName, t.getUID()); + notifyListenersAboutRemovedElement(oldTag); + }, () -> logger.debug("model {} tag {} not found", modelName, t.getUID())); + }); + } + + private SemanticTag mapSemanticTag(YamlSemanticTag tagDTO) { + if (tagDTO.uid == null) { + throw new IllegalArgumentException("The argument 'tagDTO.uid' must not be null."); + } + return new SemanticTagImpl(tagDTO.uid, tagDTO.label, tagDTO.description, tagDTO.synonyms); + } +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTags.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTags.java new file mode 100644 index 00000000000..3f44af0c5ca --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/internal/config/yaml/YamlSemanticTags.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2023 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.semantics.internal.config.yaml; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.semantics.model.yaml.YamlElement; +import org.openhab.core.semantics.model.yaml.YamlFile; +import org.openhab.core.semantics.model.yaml.YamlParseException; + +/** + * The {@link YamlSemanticTags} is a data transfer object used to serialize a list of semantic tags + * in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class YamlSemanticTags extends YamlFile { + + public List tags = List.of(); + + @Override + public List getElements() { + return tags; + } + + @Override + protected void checkVersion() throws YamlParseException { + if (version != 1) { + throw new YamlParseException("Version 1 required; please convert your file"); + } + } +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlElement.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlElement.java new file mode 100644 index 00000000000..e994732d3cd --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlElement.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2023 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.semantics.model.yaml; + +/** + * The {@link YamlElement} interface offers an identifier to any element defined in a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +public interface YamlElement { + + String getId(); + + void checkValidity() throws YamlParseException; +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlFile.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlFile.java new file mode 100644 index 00000000000..ae90fc3234e --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlFile.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2023 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.semantics.model.yaml; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link YamlFile} is the DTO base class used to map a YAML configuration file. + * + * A YAML configuration file consists of a version and a list of elements. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public abstract class YamlFile { + + public int version; + + public abstract List getElements(); + + protected abstract void checkVersion() throws YamlParseException; + + public void checkValidity() throws YamlParseException { + checkVersion(); + List elts = getElements(); + for (int i = 0; i < elts.size(); i++) { + try { + elts.get(i).checkValidity(); + } catch (YamlParseException e) { + throw new YamlParseException("Error in element " + (i + 1) + ": " + e.getMessage()); + } + } + } +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelListener.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelListener.java new file mode 100644 index 00000000000..6a49659b659 --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelListener.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2023 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.semantics.model.yaml; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link YamlModelListener} interface is responsible for managing a particular model + * with data processed from YAML configuration files. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public interface YamlModelListener { + + void addedModel(String modelName, List elements); + + void updatedModel(String modelName, List elements); + + void removedModel(String modelName, List elements); + + String getRootName(); + + Class getFileClass(); + + Class getElementClass(); +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelRepository.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelRepository.java new file mode 100644 index 00000000000..17b0a1d75b9 --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlModelRepository.java @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2010-2023 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.semantics.model.yaml; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +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.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * The {@link YamlModelRepository} is an OSGi service, that encapsulates all YAML file processing + * including file monitoring to detect created, updated and removed YAML configuration files. + * Data processed from these files are consumed by registered OSGi services that implement {@link YamlModelListener}. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true) +public class YamlModelRepository implements WatchService.WatchEventListener { + + private final Logger logger = LoggerFactory.getLogger(YamlModelRepository.class); + + private final WatchService watchService; + private final List watchPaths = new CopyOnWriteArrayList<>(); + private final ObjectMapper yamlReader; + private final List> listeners = new CopyOnWriteArrayList<>(); + private final Map> objects = new ConcurrentHashMap<>(); + + @Activate + public YamlModelRepository(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) { + this.watchService = watchService; + this.yamlReader = new ObjectMapper(new YAMLFactory()); + yamlReader.findAndRegisterModules(); + } + + @Deactivate + public void deactivate() { + objects.clear(); + } + + @Override + public synchronized void processWatchEvent(Kind kind, Path path) { + // path parameter is currently wrong ! It is a path in userdata folder + // Temporary workaround: build a path in tags folder + Path fixecPath = watchService.getWatchPath().resolve("tags").resolve(path.getFileName()); + logger.debug("processWatchEvent {} path={} fixecPath={}", kind, path.toFile().getAbsolutePath(), + fixecPath.toFile().getAbsolutePath()); + YamlModelListener listener = findModelListener(fixecPath); + if (listener == null) { + logger.warn("No YAML file consumer available for {}; file is ignored", + fixecPath.toFile().getAbsolutePath()); + return; + } + processWatchEvent(kind, fixecPath, listener); + } + + private void processWatchEvent(Kind kind, Path path, YamlModelListener listener) { + logger.debug("processWatchEvent {} {} {}", kind, listener.getRootName(), path.toFile().getAbsolutePath()); + if (Files.isDirectory(path) || path.toFile().isHidden() || !path.toFile().getName().endsWith(".yaml")) { + logger.debug("{} is ignored", path.toFile().getAbsolutePath()); + return; + } + Map oldObjects; + Map newObjects; + if (kind == WatchService.Kind.DELETE) { + newObjects = Map.of(); + + List oldListObjects = objects.remove(path); + if (oldListObjects == null) { + oldListObjects = List.of(); + } + oldObjects = oldListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); + } else { + YamlFile yamlData; + try { + yamlData = readYamlFile(path, listener.getFileClass()); + } catch (YamlParseException e) { + logger.warn("Failed to parse Yaml file {} with DTO class {}: {}", path.toFile().getAbsolutePath(), + listener.getFileClass().getName(), e.getMessage()); + return; + } + List newListObjects = yamlData.getElements(); + newObjects = newListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); + + List oldListObjects = objects.get(path); + if (oldListObjects == null) { + oldListObjects = List.of(); + } + oldObjects = oldListObjects.stream().collect(Collectors.toMap(YamlElement::getId, obj -> obj)); + + objects.put(path, newListObjects); + } + + String modelName = path.toFile().getName(); + modelName = modelName.substring(0, modelName.indexOf(".yaml")); + List listElts; + listElts = oldObjects.entrySet().stream() + .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) + && !newObjects.containsKey(entry.getKey())) + .map(entry -> entry.getValue()).toList(); + if (!listElts.isEmpty()) { + listener.removedModel(modelName, listElts); + } + + listElts = newObjects.entrySet().stream() + .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) + && !oldObjects.containsKey(entry.getKey())) + .map(entry -> entry.getValue()).toList(); + if (!listElts.isEmpty()) { + listener.addedModel(modelName, listElts); + } + + listElts = newObjects.entrySet().stream() + .filter(entry -> entry.getValue().getClass().equals(listener.getElementClass()) + && oldObjects.containsKey(entry.getKey())) + .map(entry -> entry.getValue()).toList(); + if (!listElts.isEmpty()) { + listener.updatedModel(modelName, listElts); + } + } + + private @Nullable YamlModelListener findModelListener(Path path) { + for (YamlModelListener listener : listeners) { + if (path.startsWith(watchService.getWatchPath().resolve(listener.getRootName()))) { + return listener; + } + } + return null; + } + + private YamlFile readYamlFile(Path path, Class dtoClass) throws YamlParseException { + logger.debug("readYamlFile {} with {}", path.toFile().getAbsolutePath(), dtoClass.getName()); + try { + YamlFile dto = yamlReader.readValue(path.toFile(), dtoClass); + dto.checkValidity(); + return dto; + } catch (IOException e) { + throw new YamlParseException(e); + } + } + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + protected void addYamlModelListener(YamlModelListener listener) { + logger.debug("addYamlModelListener {}", listener.getRootName()); + listeners.add(listener); + + Path watchPath = watchService.getWatchPath().resolve(listener.getRootName()); + watchPaths.add(watchPath); + watchService.unregisterListener(this); + watchService.registerListener(this, watchPaths); + + // Load all existing YAML files + try (Stream stream = Files.walk(watchPath)) { + stream.forEach(path -> processWatchEvent(Kind.CREATE, path, listener)); + } catch (IOException e) { + } + } + + protected void removeYamlModelListener(YamlModelListener listener) { + listeners.remove(listener); + + Path watchPath = watchService.getWatchPath().resolve(listener.getRootName()); + watchPaths.remove(watchPath); + watchService.unregisterListener(this); + if (!watchPaths.isEmpty()) { + watchService.registerListener(this, watchPaths); + } + } +} diff --git a/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlParseException.java b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlParseException.java new file mode 100644 index 00000000000..95894fdcc2f --- /dev/null +++ b/bundles/org.openhab.core.semantics/src/main/java/org/openhab/core/semantics/model/yaml/YamlParseException.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2023 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.semantics.model.yaml; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link YamlParseException} is used when an error is detected when parsing the content + * of a YAML configuration file. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class YamlParseException extends Exception { + + private static final long serialVersionUID = 1L; + + public YamlParseException(String message) { + super(message); + } + + public YamlParseException(Throwable cause) { + super(cause); + } + + public YamlParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml index 615a415ab7b..ba8ed3ae829 100644 --- a/features/karaf/openhab-core/src/main/feature/feature.xml +++ b/features/karaf/openhab-core/src/main/feature/feature.xml @@ -55,6 +55,7 @@ mvn:org.openhab.core.bundles/org.openhab.core.persistence/${project.version} mvn:org.openhab.core.bundles/org.openhab.core.semantics/${project.version} openhab.tp-asm + openhab.tp-jackson mvn:org.openhab.core.bundles/org.openhab.core.thing/${project.version} mvn:org.openhab.core.bundles/org.openhab.core.transform/${project.version} mvn:org.openhab.core.bundles/org.openhab.core.audio/${project.version}