diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java new file mode 100644 index 0000000000..f752ca4ec7 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/fileformat/FileFormatResource.java @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2010-2025 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.io.rest.core.internal.fileformat; + +import static org.openhab.core.config.discovery.inbox.InboxPredicates.forThingUID; + +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.auth.Role; +import org.openhab.core.config.core.ConfigDescription; +import org.openhab.core.config.core.ConfigDescriptionParameter; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.config.core.ConfigUtil; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.inbox.Inbox; +import org.openhab.core.io.rest.RESTConstants; +import org.openhab.core.io.rest.RESTResource; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataRegistry; +import org.openhab.core.items.fileconverter.ItemFileGenerator; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingFactory; +import org.openhab.core.thing.fileconverter.ThingFileGenerator; +import org.openhab.core.thing.link.ItemChannelLink; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.ThingType; +import org.openhab.core.thing.type.ThingTypeRegistry; +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.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants; +import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired; +import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect; +import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsName; +import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * This class acts as a REST resource and provides different methods to generate file format + * for existing items and things. + * + * This resource is registered with the Jersey servlet. + * + * @author Laurent Garnier - Initial contribution + */ +@Component +@JaxrsResource +@JaxrsName(FileFormatResource.PATH_FILE_FORMAT) +@JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + RESTConstants.JAX_RS_NAME + ")") +@JSONRequired +@Path(FileFormatResource.PATH_FILE_FORMAT) +@Tag(name = FileFormatResource.PATH_FILE_FORMAT) +@NonNullByDefault +public class FileFormatResource implements RESTResource { + + /** The URI path to this resource */ + public static final String PATH_FILE_FORMAT = "file-format"; + + private final Logger logger = LoggerFactory.getLogger(FileFormatResource.class); + + private final ItemRegistry itemRegistry; + private final MetadataRegistry metadataRegistry; + private final ItemChannelLinkRegistry itemChannelLinkRegistry; + private final ThingRegistry thingRegistry; + private final Inbox inbox; + private final ThingTypeRegistry thingTypeRegistry; + private final ConfigDescriptionRegistry configDescRegistry; + private final Map itemFileGenerators = new ConcurrentHashMap<>(); + private final Map thingFileGenerators = new ConcurrentHashMap<>(); + + @Activate + public FileFormatResource(// + final @Reference ItemRegistry itemRegistry, // + final @Reference MetadataRegistry metadataRegistry, + final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, + final @Reference ThingRegistry thingRegistry, // + final @Reference Inbox inbox, // + final @Reference ThingTypeRegistry thingTypeRegistry, // + final @Reference ConfigDescriptionRegistry configDescRegistry) { + this.itemRegistry = itemRegistry; + this.metadataRegistry = metadataRegistry; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.thingRegistry = thingRegistry; + this.inbox = inbox; + this.thingTypeRegistry = thingTypeRegistry; + this.configDescRegistry = configDescRegistry; + } + + @Deactivate + void deactivate() { + } + + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addItemFileGenerator(ItemFileGenerator itemFileGenerator) { + itemFileGenerators.put(itemFileGenerator.getFileFormatGenerator(), itemFileGenerator); + } + + protected void removeItemFileGenerator(ItemFileGenerator itemFileGenerator) { + itemFileGenerators.remove(itemFileGenerator.getFileFormatGenerator()); + } + + @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.MULTIPLE) + protected void addThingFileGenerator(ThingFileGenerator thingFileGenerator) { + thingFileGenerators.put(thingFileGenerator.getFileFormatGenerator(), thingFileGenerator); + } + + protected void removeThingFileGenerator(ThingFileGenerator thingFileGenerator) { + thingFileGenerators.remove(thingFileGenerator.getFileFormatGenerator()); + } + + @GET + @RolesAllowed({ Role.ADMIN }) + @Path("/items") + @Produces("text/vnd.openhab.dsl.item") + @Operation(operationId = "createFileFormatForAllItems", summary = "Create file format for all existing items in registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = "Group Group1 \"Label\"\nGroup:Switch:OR(ON,OFF) Group2 \"Label\"\nSwitch MyItem \"Label\" (Group1, Group2) [Tag1, Tag2] { channel=\"binding:type:id:channelid\", namespace=\"my value\" [param=\"my param value\"] }"))), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response createFileFormatForAllItems(final @Context HttpHeaders httpHeaders, + @DefaultValue("true") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + String format = "text/vnd.openhab.dsl.item".equals(acceptHeader) ? "DSL" : null; + ItemFileGenerator generator = format == null ? null : itemFileGenerators.get(format); + if (generator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + Collection items = itemRegistry.getAll(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + generator.generateFileFormat(outputStream, sortItems(items), getMetadata(items), hideDefaultParameters); + return Response.ok(new String(outputStream.toByteArray())).build(); + } + + @GET + @RolesAllowed({ Role.ADMIN }) + @Path("/items/{itemname: [a-zA-Z_0-9]+}") + @Produces("text/vnd.openhab.dsl.item") + @Operation(operationId = "createFileFormatForItem", summary = "Create file format for an existing item in registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "text/vnd.openhab.dsl.item", schema = @Schema(example = "Number MyItem \"Label\" (Group1, Group2) [Tag1, Tag2] { channel=\"binding:type:id:channelid\", namespace=\"my value\" [param=\"my param value\"] }"))), + @ApiResponse(responseCode = "404", description = "Item not found in registry."), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response createFileFormatForItem(final @Context HttpHeaders httpHeaders, + @DefaultValue("true") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters, + @PathParam("itemname") @Parameter(description = "item name") String itemname) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + String format = "text/vnd.openhab.dsl.item".equals(acceptHeader) ? "DSL" : null; + ItemFileGenerator generator = format == null ? null : itemFileGenerators.get(format); + if (generator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + Item item = itemRegistry.get(itemname); + if (item == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Item with name '" + itemname + "' not found in the items registry!").build(); + } + List items = List.of(item); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + generator.generateFileFormat(outputStream, items, getMetadata(items), hideDefaultParameters); + return Response.ok(new String(outputStream.toByteArray())).build(); + } + + @GET + @RolesAllowed({ Role.ADMIN }) + @Path("/things") + @Produces("text/vnd.openhab.dsl.thing") + @Operation(operationId = "createFileFormatForAllThings", summary = "Create file format for all existing things in registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = "Bridge binding:typeBridge:idBridge \"Label\" @ \"Location\" [stringParam=\"my value\"] {\n Thing type id \"Label\" @ \"Location\" [booleanParam=true, decimalParam=2.5]\n}"))), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response createFileFormatForAllThings(final @Context HttpHeaders httpHeaders, + @DefaultValue("true") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + String format = "text/vnd.openhab.dsl.thing".equals(acceptHeader) ? "DSL" : null; + ThingFileGenerator generator = format == null ? null : thingFileGenerators.get(format); + if (generator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + generator.generateFileFormat(outputStream, sortThings(thingRegistry.getAll()), hideDefaultParameters); + return Response.ok(new String(outputStream.toByteArray())).build(); + } + + @GET + @RolesAllowed({ Role.ADMIN }) + @Path("/things/{thingUID}") + @Produces("text/vnd.openhab.dsl.thing") + @Operation(operationId = "createFileFormatForThing", summary = "Create file format for an existing thing in things or discovery registry.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "text/vnd.openhab.dsl.thing", schema = @Schema(example = "Thing binding:type:idBridge:id \"Label\" @ \"Location\" (binding:typeBridge:idBridge) [stringParam=\"my value\", booleanParam=true, decimalParam=2.5]"))), + @ApiResponse(responseCode = "404", description = "Thing not found in things or discovery registry or thing type not found."), + @ApiResponse(responseCode = "415", description = "Unsupported media type.") }) + public Response createFileFormatForThing(final @Context HttpHeaders httpHeaders, + @DefaultValue("true") @QueryParam("hideDefaultParameters") @Parameter(description = "hide the configuration parameters having the default value") boolean hideDefaultParameters, + @PathParam("thingUID") @Parameter(description = "thingUID") String thingUID) { + String acceptHeader = httpHeaders.getHeaderString(HttpHeaders.ACCEPT); + String format = "text/vnd.openhab.dsl.thing".equals(acceptHeader) ? "DSL" : null; + ThingFileGenerator generator = format == null ? null : thingFileGenerators.get(format); + if (generator == null) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .entity("Unsupported media type '" + acceptHeader + "'!").build(); + } + ThingUID aThingUID = new ThingUID(thingUID); + Thing thing = thingRegistry.get(aThingUID); + if (thing == null) { + List results = inbox.getAll().stream().filter(forThingUID(new ThingUID(thingUID))) + .toList(); + if (results.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Thing with UID '" + thingUID + "' not found in the things or discovery registry!") + .build(); + } + DiscoveryResult result = results.get(0); + ThingType thingType = thingTypeRegistry.getThingType(result.getThingTypeUID()); + if (thingType == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Thing type with UID '" + result.getThingTypeUID() + "' does not exist!").build(); + } + thing = simulateThing(result, thingType); + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + generator.generateFileFormat(outputStream, List.of(thing), hideDefaultParameters); + return Response.ok(new String(outputStream.toByteArray())).build(); + } + + /* + * Get all the metadata for a list of items including channel links mapped to metadata in the namespace "channel" + */ + private Collection getMetadata(Collection items) { + Collection metadata = new ArrayList<>(); + for (Item item : items) { + String itemName = item.getName(); + metadataRegistry.getAll().stream().filter(md -> md.getUID().getItemName().equals(itemName)).forEach(md -> { + metadata.add(md); + }); + itemChannelLinkRegistry.getLinks(itemName).forEach(link -> { + MetadataKey key = new MetadataKey("channel", itemName); + Metadata md = new Metadata(key, link.getLinkedUID().getAsString(), + link.getConfiguration().getProperties()); + metadata.add(md); + }); + } + return metadata; + } + + /* + * Sort the items in such a way: + * - group items are before non group items + * - group items are sorted to have as much as possible ancestors before their children + * - items not linked to a channel are before items linked to a channel + * - items linked to a channel are grouped by thing UID + * - items linked to the same thing UID are sorted by item name + */ + private List sortItems(Collection items) { + List groups = items.stream().filter(item -> item instanceof GroupItem).sorted((item1, item2) -> { + return item1.getName().compareTo(item2.getName()); + }).collect(Collectors.toList()); + + List topGroups = groups.stream().filter(group -> group.getGroupNames().isEmpty()) + .sorted((group1, group2) -> { + return group1.getName().compareTo(group2.getName()); + }).collect(Collectors.toList()); + + List groupTree = new ArrayList<>(); + for (Item group : topGroups) { + fillGroupTree(groupTree, group); + } + + if (groupTree.size() != groups.size()) { + logger.warn("Something want wrong when sorting groups; failback to a sort by name."); + groupTree = groups; + } + + List nonGroups = items.stream().filter(item -> !(item instanceof GroupItem)).sorted((item1, item2) -> { + Set channelLinks1 = itemChannelLinkRegistry.getLinks(item1.getName()); + String thingUID1 = channelLinks1.isEmpty() ? null + : channelLinks1.iterator().next().getLinkedUID().getThingUID().getAsString(); + Set channelLinks2 = itemChannelLinkRegistry.getLinks(item2.getName()); + String thingUID2 = channelLinks2.isEmpty() ? null + : channelLinks2.iterator().next().getLinkedUID().getThingUID().getAsString(); + + if (thingUID1 == null && thingUID2 != null) { + return -1; + } else if (thingUID1 != null && thingUID2 == null) { + return 1; + } else if (thingUID1 != null && thingUID2 != null && !thingUID1.equals(thingUID2)) { + return thingUID1.compareTo(thingUID2); + } + return item1.getName().compareTo(item2.getName()); + }).collect(Collectors.toList()); + + return Stream.of(groupTree, nonGroups).flatMap(List::stream).collect(Collectors.toList()); + } + + private void fillGroupTree(List groups, Item item) { + if (item instanceof GroupItem group && !groups.contains(group)) { + groups.add(group); + List members = group.getMembers().stream().sorted((member1, member2) -> { + return member1.getName().compareTo(member2.getName()); + }).collect(Collectors.toList()); + for (Item member : members) { + fillGroupTree(groups, member); + } + } + } + + /* + * Sort the things in such a way: + * - things are grouped by binding, sorted by natural order of binding name + * - all things of a binding are sorted to follow the tree, that is bridge thing is before its sub-things + * - all things of a binding at a certain tree depth are sorted by thing UID + */ + private List sortThings(Collection things) { + List thingTree = new ArrayList<>(); + Set bindings = things.stream().map(thing -> thing.getUID().getBindingId()).collect(Collectors.toSet()); + for (String binding : bindings.stream().sorted().collect(Collectors.toList())) { + List topThings = things.stream() + .filter(thing -> thing.getUID().getBindingId().equals(binding) && thing.getBridgeUID() == null) + .sorted((thing1, thing2) -> { + return thing1.getUID().getAsString().compareTo(thing2.getUID().getAsString()); + }).collect(Collectors.toList()); + for (Thing thing : topThings) { + fillThingTree(thingTree, thing); + } + } + return thingTree; + } + + private void fillThingTree(List things, Thing thing) { + if (!things.contains(thing)) { + things.add(thing); + if (thing instanceof Bridge bridge) { + List subThings = bridge.getThings().stream().sorted((thing1, thing2) -> { + return thing1.getUID().getAsString().compareTo(thing2.getUID().getAsString()); + }).collect(Collectors.toList()); + for (Thing subThing : subThings) { + fillThingTree(things, subThing); + } + } + } + } + + /* + * Create a thing from a discovery result without inserting it in the thing registry + */ + private Thing simulateThing(DiscoveryResult result, ThingType thingType) { + Map configParams = new HashMap<>(); + List configDescriptionParameters = List.of(); + URI descURI = thingType.getConfigDescriptionURI(); + if (descURI != null) { + ConfigDescription desc = configDescRegistry.getConfigDescription(descURI); + if (desc != null) { + configDescriptionParameters = desc.getParameters(); + } + } + for (ConfigDescriptionParameter param : configDescriptionParameters) { + Object value = result.getProperties().get(param.getName()); + Object normalizedValue = value != null ? ConfigUtil.normalizeType(value, param) : null; + if (normalizedValue != null) { + configParams.put(param.getName(), normalizedValue); + } + } + Configuration config = new Configuration(configParams); + return ThingFactory.createThing(thingType, result.getThingUID(), config, result.getBridgeUID(), + configDescRegistry); + } +} diff --git a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java index 2f216b626b..1a374fb3f6 100644 --- a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java +++ b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/ModelRepository.java @@ -13,6 +13,7 @@ package org.openhab.core.model.core; import java.io.InputStream; +import java.io.OutputStream; import java.util.Set; import org.eclipse.emf.ecore.EObject; @@ -26,6 +27,7 @@ import org.eclipse.jdt.annotation.Nullable; * come from. * * @author Kai Kreuzer - Initial contribution + * @author Laurent Garnier - Added method generateSyntaxFromModel */ @NonNullByDefault public interface ModelRepository { @@ -92,4 +94,14 @@ public interface ModelRepository { * @param listener the listener to remove */ void removeModelRepositoryChangeListener(ModelRepositoryChangeListener listener); + + /** + * Generate the syntax from a provided model content. + * + * @param out the output stream to write the generated syntax to + * @param modelType the model type + * @param modelContent the content of the model + * @return the corresponding syntax + */ + void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent); } diff --git a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java index 736ac28fda..08de70a83f 100644 --- a/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java +++ b/bundles/org.openhab.core.model.core/src/main/java/org/openhab/core/model/core/internal/ModelRepositoryImpl.java @@ -15,6 +15,7 @@ package org.openhab.core.model.core.internal; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.text.MessageFormat; import java.util.ArrayList; @@ -50,6 +51,7 @@ import org.slf4j.LoggerFactory; * @author Kai Kreuzer - Initial contribution * @author Oliver Libutzki - Added reloadAllModelsOfType method * @author Simon Kaufmann - added validation of models before loading them + * @author Laurent Garnier - Added method generateSyntaxFromModel */ @Component(immediate = true) @NonNullByDefault @@ -64,6 +66,8 @@ public class ModelRepositoryImpl implements ModelRepository { private final SafeEMF safeEmf; + private int counter; + @Activate public ModelRepositoryImpl(final @Reference SafeEMF safeEmf) { this.safeEmf = safeEmf; @@ -171,7 +175,8 @@ public class ModelRepositoryImpl implements ModelRepository { return resourceListCopy.stream() .filter(input -> input.getURI().lastSegment().contains(".") && input.isLoaded() - && modelType.equalsIgnoreCase(input.getURI().fileExtension())) + && modelType.equalsIgnoreCase(input.getURI().fileExtension()) + && !input.getURI().lastSegment().startsWith("tmp_")) .map(from -> from.getURI().path()).toList(); } } @@ -227,6 +232,23 @@ public class ModelRepositoryImpl implements ModelRepository { listeners.remove(listener); } + @Override + public void generateSyntaxFromModel(OutputStream out, String modelType, EObject modelContent) { + String result = ""; + synchronized (resourceSet) { + String name = "tmp_generated_syntax_%d.%s".formatted(++counter, modelType); + Resource resource = resourceSet.createResource(URI.createURI(name)); + try { + resource.getContents().add(modelContent); + resource.save(out, Map.of(XtextResource.OPTION_ENCODING, StandardCharsets.UTF_8.name())); + } catch (IOException e) { + logger.warn("Exception when saving the model {}", resource.getURI().lastSegment()); + } finally { + resourceSet.getResources().remove(resource); + } + } + } + private @Nullable Resource getResource(String name) { return resourceSet.getResource(URI.createURI(name), false); } diff --git a/bundles/org.openhab.core.model.item/bnd.bnd b/bundles/org.openhab.core.model.item/bnd.bnd index d11180b251..b3d12aab17 100644 --- a/bundles/org.openhab.core.model.item/bnd.bnd +++ b/bundles/org.openhab.core.model.item/bnd.bnd @@ -21,6 +21,7 @@ Import-Package: javax.measure,\ org.openhab.core.i18n,\ org.openhab.core.items,\ org.openhab.core.items.dto,\ + org.openhab.core.items.fileconverter,\ org.openhab.core.library.items,\ org.openhab.core.library.types,\ org.openhab.core.thing.util,\ diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/ItemsRuntimeModule.xtend b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/ItemsRuntimeModule.xtend index fc01add6fc..90ce09fee5 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/ItemsRuntimeModule.xtend +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/ItemsRuntimeModule.xtend @@ -18,19 +18,24 @@ org.openhab.core.model import com.google.inject.Binder import com.google.inject.name.Names +import org.openhab.core.model.formatting.ItemsFormatter import org.openhab.core.model.internal.valueconverter.ItemValueConverters import org.eclipse.xtext.conversion.IValueConverterService +import org.eclipse.xtext.formatting.IFormatter import org.eclipse.xtext.linking.lazy.LazyURIEncoder /** * Use this class to register components to be used at runtime / without the Equinox extension registry. */ class ItemsRuntimeModule extends AbstractItemsRuntimeModule { - override Class bindIValueConverterService() { return ItemValueConverters } - + + override Class bindIFormatter() { + return ItemsFormatter + } + override void configureUseIndexFragmentsForLazyLinking(Binder binder) { binder.bind(Boolean.TYPE).annotatedWith(Names.named(LazyURIEncoder.USE_INDEXED_FRAGMENTS_BINDING)).toInstance( Boolean.FALSE) diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/formatting/ItemsFormatter.xtend b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/formatting/ItemsFormatter.xtend index 7f45bc9aad..02f6a98165 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/formatting/ItemsFormatter.xtend +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/formatting/ItemsFormatter.xtend @@ -26,17 +26,17 @@ class ItemsFormatter extends AbstractDeclarativeFormatter { override protected void configureFormatting(FormattingConfig c) { c.setLinewrap(1, 1, 2).before(modelGroupItemRule) - c.setLinewrap(1, 1, 2).before(modelItemTypeRule) + c.setLinewrap(1, 1, 2).before(modelNormalItemRule) c.setNoSpace().withinKeywordPairs("<", ">") c.setNoSpace().withinKeywordPairs("(", ")") + c.setNoSpace().withinKeywordPairs("[", "]") - c.setIndentationIncrement.after(modelItemTypeRule) - c.setIndentationDecrement.before(modelItemTypeRule) - c.setIndentationIncrement.after(modelGroupItemRule) - c.setIndentationDecrement.before(modelGroupItemRule) + c.setNoSpace().around(":", "=") + c.setNoSpace().before(",") + + c.autoLinewrap = 400 - c.autoLinewrap = 160 c.setLinewrap(0, 1, 2).before(SL_COMMENTRule) c.setLinewrap(0, 1, 2).before(ML_COMMENTRule) c.setLinewrap(0, 1, 1).after(ML_COMMENTRule) @@ -48,4 +48,16 @@ class ItemsFormatter extends AbstractDeclarativeFormatter { locator.before(pair.second) } } + + def around(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.around(keyword) + } + } + + def before(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.before(keyword) + } + } } diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java new file mode 100644 index 0000000000..15ba5ed75c --- /dev/null +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/item/internal/fileconverter/DslItemFileConverter.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2010-2025 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.item.internal.fileconverter; + +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.core.ConfigDescription; +import org.openhab.core.config.core.ConfigDescriptionParameter; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.config.core.ConfigUtil; +import org.openhab.core.items.GroupFunction; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.fileconverter.AbstractItemFileGenerator; +import org.openhab.core.items.fileconverter.ItemFileGenerator; +import org.openhab.core.model.core.ModelRepository; +import org.openhab.core.model.items.ItemModel; +import org.openhab.core.model.items.ItemsFactory; +import org.openhab.core.model.items.ModelBinding; +import org.openhab.core.model.items.ModelGroupFunction; +import org.openhab.core.model.items.ModelGroupItem; +import org.openhab.core.model.items.ModelItem; +import org.openhab.core.model.items.ModelProperty; +import org.openhab.core.types.State; +import org.openhab.core.types.StateDescription; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link DslItemFileConverter} is the DSL file converter for {@link Item} object + * with the capabilities of parsing and generating file. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = ItemFileGenerator.class) +public class DslItemFileConverter extends AbstractItemFileGenerator { + + private final Logger logger = LoggerFactory.getLogger(DslItemFileConverter.class); + + private final ModelRepository modelRepository; + private final ConfigDescriptionRegistry configDescriptionRegistry; + + @Activate + public DslItemFileConverter(final @Reference ModelRepository modelRepository, + final @Reference ConfigDescriptionRegistry configDescriptionRegistry) { + this.modelRepository = modelRepository; + this.configDescriptionRegistry = configDescriptionRegistry; + } + + @Override + public String getFileFormatGenerator() { + return "DSL"; + } + + @Override + public synchronized void generateFileFormat(OutputStream out, List items, Collection metadata, + boolean hideDefaultParameters) { + ItemModel model = ItemsFactory.eINSTANCE.createItemModel(); + for (Item item : items) { + model.getItems().add(buildModelItem(item, getChannelLinks(metadata, item.getName()), + getMetadata(metadata, item.getName()), hideDefaultParameters)); + } + modelRepository.generateSyntaxFromModel(out, "items", model); + } + + private ModelItem buildModelItem(Item item, List channelLinks, List metadata, + boolean hideDefaultParameters) { + ModelItem model; + if (item instanceof GroupItem groupItem) { + ModelGroupItem modelGroup = ItemsFactory.eINSTANCE.createModelGroupItem(); + model = modelGroup; + Item baseItem = groupItem.getBaseItem(); + if (baseItem != null) { + modelGroup.setType(baseItem.getType()); + GroupFunction function = groupItem.getFunction(); + if (function != null) { + ModelGroupFunction modelFunction = ModelGroupFunction + .getByName(function.getClass().getSimpleName().toUpperCase()); + modelGroup.setFunction(modelFunction); + State[] parameters = function.getParameters(); + for (int i = 0; i < parameters.length; i++) { + modelGroup.getArgs().add(parameters[i].toString()); + } + } + } + } else { + model = ItemsFactory.eINSTANCE.createModelNormalItem(); + model.setType(item.getType()); + } + + model.setName(item.getName()); + String label = item.getLabel(); + boolean patternInjected = false; + String defaultPattern = getDefaultStatePattern(item); + if (label != null && !label.isEmpty()) { + StateDescription stateDescr = item.getStateDescription(); + String statePattern = stateDescr == null ? null : stateDescr.getPattern(); + String patterToInject = statePattern != null && !statePattern.equals(defaultPattern) ? statePattern : null; + if (patterToInject != null) { + // Inject the pattern in the label + patternInjected = true; + model.setLabel("%s [%s]".formatted(label, patterToInject)); + } else { + model.setLabel(label); + } + } + + String category = item.getCategory(); + if (category != null && !category.isEmpty()) { + model.setIcon(category); + } + for (String group : item.getGroupNames()) { + model.getGroups().add(group); + } + for (String tag : item.getTags().stream().sorted().collect(Collectors.toList())) { + model.getTags().add(tag); + } + + for (Metadata md : channelLinks) { + String namespace = md.getUID().getNamespace(); + ModelBinding binding = ItemsFactory.eINSTANCE.createModelBinding(); + binding.setType(namespace); + binding.setConfiguration(md.getValue()); + for (ConfigParameter param : getConfigurationParameters(md, hideDefaultParameters)) { + ModelProperty property = buildModelProperty(param.name(), param.value()); + if (property != null) { + binding.getProperties().add(property); + } + } + model.getBindings().add(binding); + } + + for (Metadata md : metadata) { + String namespace = md.getUID().getNamespace(); + ModelBinding binding = ItemsFactory.eINSTANCE.createModelBinding(); + binding.setType(namespace); + binding.setConfiguration(md.getValue()); + String statePattern = null; + for (ConfigParameter param : getConfigurationParameters(md)) { + ModelProperty property = buildModelProperty(param.name(), param.value()); + if (property != null) { + binding.getProperties().add(property); + } + if ("stateDescription".equals(namespace) && "pattern".equals(param.name())) { + statePattern = param.value().toString(); + } + } + // Ignore state description in case it contains only a state pattern and state pattern was injected + // in the item label or is the default pattern + if (!(statePattern != null && binding.getProperties().size() == 1 + && (patternInjected || statePattern.equals(defaultPattern)))) { + model.getBindings().add(binding); + } + } + + return model; + } + + private @Nullable ModelProperty buildModelProperty(String key, Object value) { + ModelProperty property = ItemsFactory.eINSTANCE.createModelProperty(); + property.setKey(key); + if (value instanceof List list) { + if (!list.isEmpty()) { + property.getValue().addAll(list); + } else { + property = null; + } + } else { + property.getValue().add(value); + } + return property; + } + + /* + * Get the list of configuration parameters for a channel link. + * + * If a profile is set and a configuration description is found for this profile, the parameters are provided + * in the same order as in this configuration description, and any parameter having the default value is ignored. + * If no profile is set, the parameters are provided sorted by natural order of their names. + */ + private List getConfigurationParameters(Metadata metadata, boolean hideDefaultParameters) { + List parameters = new ArrayList<>(); + Set handledNames = new HashSet<>(); + Map configParameters = metadata.getConfiguration(); + Object profile = configParameters.get("profile"); + List configDescriptionParameter = List.of(); + if (profile instanceof String profileStr) { + parameters.add(new ConfigParameter("profile", profileStr)); + handledNames.add("profile"); + try { + ConfigDescription configDesc = configDescriptionRegistry + .getConfigDescription(new URI("profile:" + profileStr)); + if (configDesc != null) { + configDescriptionParameter = configDesc.getParameters(); + } + } catch (URISyntaxException e) { + // Ignored; in practice this will never be thrown + } + } + for (ConfigDescriptionParameter param : configDescriptionParameter) { + String paramName = param.getName(); + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + Object defaultValue = ConfigUtil.getDefaultValueAsCorrectType(param); + if (value != null && (!hideDefaultParameters || !value.equals(defaultValue))) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + for (String paramName : configParameters.keySet().stream().sorted().collect(Collectors.toList())) { + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + if (value != null) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + return parameters; + } +} diff --git a/bundles/org.openhab.core.model.thing/bnd.bnd b/bundles/org.openhab.core.model.thing/bnd.bnd index c1bfc49cfd..b880046129 100644 --- a/bundles/org.openhab.core.model.thing/bnd.bnd +++ b/bundles/org.openhab.core.model.thing/bnd.bnd @@ -21,9 +21,11 @@ Import-Package: org.apache.log4j,\ org.openhab.core.thing,\ org.openhab.core.thing.binding,\ org.openhab.core.thing.binding.builder,\ + org.openhab.core.thing.fileconverter,\ org.openhab.core.thing.link,\ org.openhab.core.thing.type,\ org.openhab.core.thing.util,\ + org.openhab.core.types,\ org.openhab.core.types.util,\ org.openhab.core.util,\ org.openhab.core.model.core,\ diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/Thing.xtext b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/Thing.xtext index 6bce9396bf..33479f60ba 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/Thing.xtext +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/Thing.xtext @@ -26,9 +26,9 @@ ModelBridge returns ModelThing: properties+=ModelProperty? (',' properties+=ModelProperty)* ']')? ('{' - ('Things:')? + (thingsHeader?='Things:')? things+=(ModelThing|ModelBridge)* - ('Channels:')? + (channelsHeader?='Channels:')? channels+=ModelChannel* '}')? @@ -43,7 +43,7 @@ ModelThing: properties+=ModelProperty? (',' properties+=ModelProperty)* ']')? ('{' - ('Channels:')? + (channelsHeader?='Channels:')? channels+=ModelChannel* '}')? ; diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/ThingRuntimeModule.xtend b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/ThingRuntimeModule.xtend index 1a7b7fcaba..7b033310b5 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/ThingRuntimeModule.xtend +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/ThingRuntimeModule.xtend @@ -12,11 +12,13 @@ */ package org.openhab.core.model.thing -import org.openhab.core.model.thing.valueconverter.ThingValueConverters -import org.eclipse.xtext.conversion.IValueConverterService -import org.eclipse.xtext.linking.lazy.LazyURIEncoder import com.google.inject.Binder import com.google.inject.name.Names +import org.openhab.core.model.thing.formatting.ThingFormatter +import org.openhab.core.model.thing.valueconverter.ThingValueConverters +import org.eclipse.xtext.conversion.IValueConverterService +import org.eclipse.xtext.formatting.IFormatter +import org.eclipse.xtext.linking.lazy.LazyURIEncoder /** * Use this class to register components to be used at runtime / without the Equinox extension registry. @@ -30,6 +32,10 @@ import com.google.inject.name.Names return org.openhab.core.model.thing.serializer.ThingSyntacticSequencerExtension } + override Class bindIFormatter() { + return ThingFormatter + } + override void configureUseIndexFragmentsForLazyLinking(Binder binder) { binder.bind(Boolean.TYPE).annotatedWith(Names.named(LazyURIEncoder.USE_INDEXED_FRAGMENTS_BINDING)).toInstance( Boolean.FALSE) diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/formatting/ThingFormatter.xtend b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/formatting/ThingFormatter.xtend index 45d855a59e..fd206860f8 100644 --- a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/formatting/ThingFormatter.xtend +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/formatting/ThingFormatter.xtend @@ -17,8 +17,8 @@ package org.openhab.core.model.thing.formatting import org.eclipse.xtext.formatting.impl.AbstractDeclarativeFormatter import org.eclipse.xtext.formatting.impl.FormattingConfig -// import com.google.inject.Inject; -// import org.openhab.core.model.thing.services.ThingGrammarAccess +import com.google.inject.Inject; +import org.openhab.core.model.thing.services.ThingGrammarAccess /** * This class contains custom formatting description. @@ -30,13 +30,53 @@ import org.eclipse.xtext.formatting.impl.FormattingConfig */ class ThingFormatter extends AbstractDeclarativeFormatter { -// @Inject extension ThingGrammarAccess - + @Inject extension ThingGrammarAccess + override protected void configureFormatting(FormattingConfig c) { -// It's usually a good idea to activate the following three statements. -// They will add and preserve newlines around comments -// c.setLinewrap(0, 1, 2).before(SL_COMMENTRule) -// c.setLinewrap(0, 1, 2).before(ML_COMMENTRule) -// c.setLinewrap(0, 1, 1).after(ML_COMMENTRule) + c.setLinewrap(1, 1, 2).before("Bridge", "Things:", "Channels:") + c.setLinewrap(1, 1, 2).before(modelThingRule) + c.setLinewrap(1, 1, 2).before(modelChannelRule) + + c.setIndentationIncrement.after("{") + c.setIndentationDecrement.before("}") + c.setIndentationIncrement.before(modelChannelRule) + c.setIndentationDecrement.after(modelChannelRule) + c.setLinewrap().before("}") + + c.setNoSpace().withinKeywordPairs("(", ")") + c.setNoSpace().withinKeywordPairs("[", "]") + c.setNoSpace().around("=") + c.setNoSpace().before(",") + + c.autoLinewrap = 400 + + c.setLinewrap(0, 1, 2).before(SL_COMMENTRule) + c.setLinewrap(0, 1, 2).before(ML_COMMENTRule) + c.setLinewrap(0, 1, 1).after(ML_COMMENTRule) + } + + def withinKeywordPairs(FormattingConfig.NoSpaceLocator locator, String leftKW, String rightKW) { + for (pair : findKeywordPairs(leftKW, rightKW)) { + locator.after(pair.first) + locator.before(pair.second) + } + } + + def around(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.around(keyword) + } + } + + def after(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.after(keyword) + } + } + + def before(FormattingConfig.ElementLocator locator, String ... listKW) { + for (keyword : findKeywords(listKW)) { + locator.before(keyword) + } } } diff --git a/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java new file mode 100644 index 0000000000..254daec10e --- /dev/null +++ b/bundles/org.openhab.core.model.thing/src/org/openhab/core/model/thing/internal/fileconverter/DslThingFileConverter.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2010-2025 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.thing.internal.fileconverter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.model.core.ModelRepository; +import org.openhab.core.model.thing.thing.ModelBridge; +import org.openhab.core.model.thing.thing.ModelChannel; +import org.openhab.core.model.thing.thing.ModelProperty; +import org.openhab.core.model.thing.thing.ModelThing; +import org.openhab.core.model.thing.thing.ThingFactory; +import org.openhab.core.model.thing.thing.ThingModel; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.fileconverter.AbstractThingFileGenerator; +import org.openhab.core.thing.fileconverter.ThingFileGenerator; +import org.openhab.core.thing.type.ChannelKind; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.thing.type.ThingTypeRegistry; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link DslThingFileConverter} is the DSL file converter for {@link Thing} object + * with the capabilities of parsing and generating file. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = ThingFileGenerator.class) +public class DslThingFileConverter extends AbstractThingFileGenerator { + + private final Logger logger = LoggerFactory.getLogger(DslThingFileConverter.class); + + private final ModelRepository modelRepository; + + @Activate + public DslThingFileConverter(final @Reference ModelRepository modelRepository, + final @Reference ThingTypeRegistry thingTypeRegistry, + final @Reference ChannelTypeRegistry channelTypeRegistry, + final @Reference ConfigDescriptionRegistry configDescRegistry) { + super(thingTypeRegistry, channelTypeRegistry, configDescRegistry); + this.modelRepository = modelRepository; + } + + @Override + public String getFileFormatGenerator() { + return "DSL"; + } + + @Override + public synchronized void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters) { + ThingModel model = ThingFactory.eINSTANCE.createThingModel(); + Set handledThings = new HashSet<>(); + for (Thing thing : things) { + if (handledThings.contains(thing)) { + continue; + } + model.getThings() + .add(buildModelThing(thing, hideDefaultParameters, things.size() > 1, true, things, handledThings)); + } + // Double quotes are unexpectedly generated in thing UID when the segment contains a -. + // Fix that by removing these double quotes. Requires to first build the generated syntax as a String + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + modelRepository.generateSyntaxFromModel(outputStream, "things", model); + String syntax = new String(outputStream.toByteArray()).replaceAll(":\"([a-zA-Z0-9_][a-zA-Z0-9_-]*)\"", ":$1"); + try { + out.write(syntax.getBytes()); + } catch (IOException e) { + logger.warn("Exception when writing the generated syntax {}", e.getMessage()); + } + } + + private ModelThing buildModelThing(Thing thing, boolean hideDefaultParameters, boolean preferPresentationAsTree, + boolean topLevel, List onlyThings, Set handledThings) { + ModelThing model; + ModelBridge modelBridge; + if (preferPresentationAsTree && thing instanceof Bridge bridge && !bridge.getThings().isEmpty()) { + modelBridge = ThingFactory.eINSTANCE.createModelBridge(); + modelBridge.setBridge(true); + model = modelBridge; + } else { + modelBridge = null; + model = ThingFactory.eINSTANCE.createModelThing(); + } + if (!preferPresentationAsTree || topLevel) { + model.setId(thing.getUID().getAsString()); + ThingUID bridgeUID = thing.getBridgeUID(); + if (bridgeUID != null && modelBridge == null) { + model.setBridgeUID(bridgeUID.getAsString()); + } + } else { + model.setThingTypeId(thing.getThingTypeUID().getId()); + model.setThingId(thing.getUID().getId()); + } + if (thing.getLabel() != null) { + model.setLabel(thing.getLabel()); + } + if (thing.getLocation() != null) { + model.setLocation(thing.getLocation()); + } + + for (ConfigParameter param : getConfigurationParameters(thing, hideDefaultParameters)) { + ModelProperty property = buildModelProperty(param.name(), param.value()); + if (property != null) { + model.getProperties().add(property); + } + } + + if (preferPresentationAsTree && modelBridge != null) { + modelBridge.setThingsHeader(false); + for (Thing child : getChildThings(thing)) { + if (onlyThings.contains(child) && !handledThings.contains(child)) { + modelBridge.getThings() + .add(buildModelThing(child, hideDefaultParameters, true, false, onlyThings, handledThings)); + } + } + } + + List channels = getNonDefaultChannels(thing); + model.setChannelsHeader(!channels.isEmpty()); + for (Channel channel : channels) { + model.getChannels().add(buildModelChannel(channel, hideDefaultParameters)); + } + + handledThings.add(thing); + + return model; + } + + private ModelChannel buildModelChannel(Channel channel, boolean hideDefaultParameters) { + ModelChannel modelChannel = ThingFactory.eINSTANCE.createModelChannel(); + ChannelTypeUID channelTypeUID = channel.getChannelTypeUID(); + if (channelTypeUID != null) { + modelChannel.setChannelType(channelTypeUID.getId()); + } else { + modelChannel.setChannelKind(channel.getKind() == ChannelKind.STATE ? "State" : "Trigger"); + modelChannel.setType(channel.getAcceptedItemType()); + } + modelChannel.setId(channel.getUID().getId()); + if (channel.getLabel() != null) { + modelChannel.setLabel(channel.getLabel()); + } + for (ConfigParameter param : getConfigurationParameters(channel, hideDefaultParameters)) { + ModelProperty property = buildModelProperty(param.name(), param.value()); + if (property != null) { + modelChannel.getProperties().add(property); + } + } + return modelChannel; + } + + private @Nullable ModelProperty buildModelProperty(String key, Object value) { + ModelProperty property = ThingFactory.eINSTANCE.createModelProperty(); + property.setKey(key); + if (value instanceof List list) { + if (!list.isEmpty()) { + property.getValue().addAll(list); + } else { + property = null; + } + } else { + property.getValue().add(value); + } + return property; + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/AbstractThingFileGenerator.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/AbstractThingFileGenerator.java new file mode 100644 index 0000000000..0dd3371b1f --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/AbstractThingFileGenerator.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2010-2025 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.thing.fileconverter; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.core.ConfigDescription; +import org.openhab.core.config.core.ConfigDescriptionParameter; +import org.openhab.core.config.core.ConfigDescriptionRegistry; +import org.openhab.core.config.core.ConfigUtil; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.thing.type.ThingType; +import org.openhab.core.thing.type.ThingTypeRegistry; +import org.osgi.service.component.annotations.Activate; + +/** + * {@link AbstractThingFileGenerator} is the base class for any {@link Thing} file generator. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractThingFileGenerator implements ThingFileGenerator { + + private final ThingTypeRegistry thingTypeRegistry; + private final ChannelTypeRegistry channelTypeRegistry; + private final ConfigDescriptionRegistry configDescRegistry; + + @Activate + public AbstractThingFileGenerator(ThingTypeRegistry thingTypeRegistry, ChannelTypeRegistry channelTypeRegistry, + ConfigDescriptionRegistry configDescRegistry) { + this.thingTypeRegistry = thingTypeRegistry; + this.channelTypeRegistry = channelTypeRegistry; + this.configDescRegistry = configDescRegistry; + } + + /** + * {@link ConfigParameter} is a container for any configuration parameter defined by a name and a value. + */ + protected record ConfigParameter(String name, Object value) { + } + + /** + * Get the child things of a bridge thing, ordered by UID. + * + * @param thing the thing + * @return the sorted list of child things or an empty list if the thing is not a bridge thing + */ + protected List getChildThings(Thing thing) { + if (thing instanceof Bridge bridge) { + return bridge.getThings().stream().sorted((thing1, thing2) -> { + return thing1.getUID().getAsString().compareTo(thing2.getUID().getAsString()); + }).collect(Collectors.toList()); + } + return List.of(); + } + + /** + * Get the list of configuration parameters for a thing. + * + * If a configuration description is found for the thing type, the parameters are provided in the same order + * as in this configuration description, and any parameter having the default value is ignored. + * If not, the parameters are provided sorted by natural order of their names. + * + * @param thing the thing + * @param hideDefaultParameters true to hide the configuration parameters having the default value + * @return the sorted list of configuration parameters for the thing + */ + protected List getConfigurationParameters(Thing thing, boolean hideDefaultParameters) { + return getConfigurationParameters(getConfigDescriptionParameters(thing), thing.getConfiguration(), + hideDefaultParameters); + } + + /** + * Get the list of configuration parameters for a channel. + * + * If a configuration description is found for the channel type, the parameters are provided in the same order + * as in this configuration description, and any parameter having the default value is ignored. + * If not, the parameters are provided sorted by natural order of their names. + * + * @param thing the channel + * @param hideDefaultParameters true to hide the configuration parameters having the default value + * @return the sorted list of configuration parameters for the channel + */ + protected List getConfigurationParameters(Channel channel, boolean hideDefaultParameters) { + return getConfigurationParameters(getConfigDescriptionParameters(channel), channel.getConfiguration(), + hideDefaultParameters); + } + + private List getConfigurationParameters( + List configDescriptionParameter, Configuration configParameters, + boolean hideDefaultParameters) { + List parameters = new ArrayList<>(); + Set handledNames = new HashSet<>(); + for (ConfigDescriptionParameter param : configDescriptionParameter) { + String paramName = param.getName(); + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + Object defaultValue = ConfigUtil.getDefaultValueAsCorrectType(param); + if (value != null && (!hideDefaultParameters || !value.equals(defaultValue))) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + for (String paramName : configParameters.keySet().stream().sorted().collect(Collectors.toList())) { + if (handledNames.contains(paramName)) { + continue; + } + Object value = configParameters.get(paramName); + if (value != null) { + parameters.add(new ConfigParameter(paramName, value)); + } + handledNames.add(paramName); + } + return parameters; + } + + private List getConfigDescriptionParameters(Thing thing) { + List configParams = null; + ThingType thingType = thingTypeRegistry.getThingType(thing.getThingTypeUID()); + if (thingType != null) { + configParams = getConfigDescriptionParameters(thingType.getConfigDescriptionURI()); + } + return configParams != null ? configParams : List.of(); + } + + private List getConfigDescriptionParameters(Channel channel) { + List configParams = null; + ChannelTypeUID channelTypeUID = channel.getChannelTypeUID(); + if (channelTypeUID != null) { + ChannelType channelType = channelTypeRegistry.getChannelType(channelTypeUID); + if (channelType != null) { + configParams = getConfigDescriptionParameters(channelType.getConfigDescriptionURI()); + } + } + return configParams != null ? configParams : List.of(); + } + + private @Nullable List getConfigDescriptionParameters(@Nullable URI descURI) { + if (descURI != null) { + ConfigDescription configDesc = configDescRegistry.getConfigDescription(descURI); + if (configDesc != null) { + return configDesc.getParameters(); + } + } + return null; + } + + /** + * Get non default channels. + * It includes extensible channels and channels with a non default configuration. + * + * Resulting channels are sorted in such a way that channels without channel type are after channels + * with a channel type. Sort is done first on the channel type and then on the channel UID. + * + * @param thing the thing + * @return the sorted list of channels + */ + protected List getNonDefaultChannels(Thing thing) { + ThingType thingType = thingTypeRegistry.getThingType(thing.getThingTypeUID()); + List ids = thingType != null ? thingType.getExtensibleChannelTypeIds() : List.of(); + List channels = thing + .getChannels().stream().filter(ch -> ch.getChannelTypeUID() == null + || ids.contains(ch.getChannelTypeUID().getId()) || channelWithNonDefaultConfig(ch)) + .collect(Collectors.toList()); + return channels.stream().sorted((ch1, ch2) -> { + ChannelTypeUID typeUID1 = ch1.getChannelTypeUID(); + ChannelTypeUID typeUID2 = ch2.getChannelTypeUID(); + if (typeUID1 != null && typeUID2 == null) { + return -1; + } else if (typeUID1 == null && typeUID2 != null) { + return 1; + } else if (typeUID1 != null && typeUID2 != null && !typeUID1.equals(typeUID2)) { + return typeUID1.getAsString().compareTo(typeUID2.getAsString()); + } else { + return ch1.getUID().getAsString().compareTo(ch2.getUID().getAsString()); + } + }).collect(Collectors.toList()); + } + + private boolean channelWithNonDefaultConfig(Channel channel) { + for (ConfigDescriptionParameter param : getConfigDescriptionParameters(channel)) { + Object value = channel.getConfiguration().get(param.getName()); + if (value != null) { + value = ConfigUtil.normalizeType(value, param); + if (!value.equals(ConfigUtil.getDefaultValueAsCorrectType(param))) { + return true; + } + } + } + return false; + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java new file mode 100644 index 0000000000..fe5d0ec53c --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/fileconverter/ThingFileGenerator.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2010-2025 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.thing.fileconverter; + +import java.io.OutputStream; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.Thing; + +/** + * {@link ThingFileGenerator} is the interface to implement by any file generator for {@link Thing} object. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public interface ThingFileGenerator { + + /** + * Returns the format of the file. + * + * @return the file format + */ + String getFileFormatGenerator(); + + /** + * Generate the file format for a sorted list of things. + * + * @param out the output stream to write the generated syntax to + * @param things the things + * @param hideDefaultParameters true to hide the configuration parameters having the default value + */ + void generateFileFormat(OutputStream out, List things, boolean hideDefaultParameters); +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/AbstractItemFileGenerator.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/AbstractItemFileGenerator.java new file mode 100644 index 0000000000..08da781646 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/AbstractItemFileGenerator.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2010-2025 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.items.fileconverter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.GroupItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.Metadata; +import org.openhab.core.library.CoreItemFactory; + +/** + * {@link AbstractItemFileGenerator} is the base class for any {@link Item} file generator. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractItemFileGenerator implements ItemFileGenerator { + + public AbstractItemFileGenerator() { + } + + /** + * {@link ConfigParameter} is a container for any configuration parameter defined by a name and a value. + */ + public record ConfigParameter(String name, Object value) { + } + + /** + * Get the list of available channel links for an item, sorted by natural order of their channel UID. + * + * @param metadata a collection of metadata + * @param itemName the item name + * @return the sorted list of metadata representing the channel links for this item + */ + protected List getChannelLinks(Collection metadata, String itemName) { + return metadata.stream().filter( + md -> "channel".equals(md.getUID().getNamespace()) && md.getUID().getItemName().equals(itemName)) + .sorted((md1, md2) -> { + return md1.getValue().compareTo(md2.getValue()); + }).collect(Collectors.toList()); + } + + /** + * Get the list of available metadata for an item, sorted by natural order of their namespaces. + * The "semantics" and "channel" namespaces are ignored. + * + * @param metadata a collection of metadata + * @param itemName the item name + * @return the sorted list of metadata for this item + */ + protected List getMetadata(Collection metadata, String itemName) { + return metadata.stream() + .filter(md -> !"semantics".equals(md.getUID().getNamespace()) + && !"channel".equals(md.getUID().getNamespace()) && md.getUID().getItemName().equals(itemName)) + .sorted((md1, md2) -> { + return md1.getUID().getNamespace().compareTo(md2.getUID().getNamespace()); + }).collect(Collectors.toList()); + } + + /** + * Get the list of configuration parameters for a metadata, sorted by natural order of their names + * with the exception of the "stateDescription" namespace where "min", "max" and "step" parameters + * are provided at first in this order. + * + * @param metadata the metadata + * @return a sorted list of configuration parameters for the metadata + */ + protected List getConfigurationParameters(Metadata metadata) { + String namespace = metadata.getUID().getNamespace(); + Map configParams = metadata.getConfiguration(); + List paramNames = configParams.keySet().stream().sorted((key1, key2) -> { + if ("stateDescription".equals(namespace)) { + if ("min".equals(key1)) { + return -1; + } else if ("min".equals(key2)) { + return 1; + } else if ("max".equals(key1)) { + return -1; + } else if ("max".equals(key2)) { + return 1; + } else if ("step".equals(key1)) { + return -1; + } else if ("step".equals(key2)) { + return 1; + } + } + return key1.compareTo(key2); + }).collect(Collectors.toList()); + + List parameters = new ArrayList<>(); + for (String paramName : paramNames) { + Object value = configParams.get(paramName); + if (value != null) { + parameters.add(new ConfigParameter(paramName, value)); + } + } + return parameters; + } + + /** + * Get the default state pattern for an item. + * + * @param item the item + * @return the default state pattern of null if no default + */ + protected @Nullable String getDefaultStatePattern(Item item) { + String pattern = null; + if (item instanceof GroupItem group) { + Item baseItem = group.getBaseItem(); + if (baseItem != null) { + pattern = getDefaultStatePattern(baseItem); + } + } else if (item.getType().startsWith(CoreItemFactory.NUMBER + ":")) { + pattern = "%.0f %unit%"; + } else { + switch (item.getType()) { + case CoreItemFactory.STRING: + pattern = "%s"; + break; + case CoreItemFactory.DATETIME: + pattern = "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"; + break; + case CoreItemFactory.NUMBER: + pattern = "%.0f"; + break; + default: + break; + } + } + return pattern; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java new file mode 100644 index 0000000000..dc19773edf --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/fileconverter/ItemFileGenerator.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2010-2025 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.items.fileconverter; + +import java.io.OutputStream; +import java.util.Collection; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.items.Item; +import org.openhab.core.items.Metadata; + +/** + * {@link ItemFileGenerator} is the interface to implement by any file generator for {@link Item} object. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public interface ItemFileGenerator { + + /** + * Returns the format of the file. + * + * @return the file format + */ + String getFileFormatGenerator(); + + /** + * Generate the file format for a sorted list of items. + * + * @param out the output stream to write the generated syntax to + * @param items the items + * @param metadata the provided collection of metadata for these items (including channel links) + * @param hideDefaultParameters true to hide the configuration parameters having the default value + */ + void generateFileFormat(OutputStream out, List items, Collection metadata, + boolean hideDefaultParameters); +}