Community Marketplace Add-on Service - initial contribution (#2405)

Signed-off-by: Yannick Schaus <github@schaus.net>
pull/2487/head
Yannick Schaus 2021-09-14 09:04:04 +02:00 committed by GitHub
parent 4d842f4ba3
commit 2663a3fc7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1840 additions and 42 deletions

View File

@ -508,6 +508,12 @@
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon.marketplace</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.addon.marketplace</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@ -0,0 +1,14 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-core

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.reactor.bundles</artifactId>
<version>3.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.addon.marketplace</artifactId>
<name>openHAB Core :: Bundles :: Community Marketplace Add-on Service</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.automation</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.core</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.ui</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,69 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.addon.Addon;
/**
* This interface can be implemented by services that want to register as handlers for specific marketplace add-on
* content types and content types.
* In a system there should always only be exactly one handler responsible for a given type+contentType
* combination. If
* multiple handers support it, it is undefined which one will be called.
* This mechanism allows solutions to add support for specific formats (e.g. Karaf features) that are not supported by
* openHAB out of the box.
* It also allows to decide which add-on types are made available at all.
*
* @author Kai Kreuzer - Initial contribution and API
* @author Yannick Schaus - refactoring
*
*/
@NonNullByDefault
public interface MarketplaceAddonHandler {
/**
* Tells whether this handler supports a given add-on.
*
* @param addon the add-on in question
* @return true, if the add-on is supported, false otherwise
*/
boolean supports(String type, String contentType);
/**
* Tells whether a given add-on is currently installed.
* Note: This method is only called, if the hander claimed support for the add-on before.
*
* @param id the id of the add-on in question
* @return true, if the add-on is installed, false otherwise
*/
boolean isInstalled(String id);
/**
* Installs a given add-on.
* Note: This method is only called, if the hander claimed support for the add-on before.
*
* @param addon the add-on to install
* @throws MarketplaceHandlerException if the installation failed for some reason
*/
void install(Addon addon) throws MarketplaceHandlerException;
/**
* Uninstalls a given add-on.
* Note: This method is only called, if the hander claimed support for the add-on before.
*
* @param addon the add-on to uninstall
* @throws MarketplaceHandlerException if the uninstallation failed for some reason
*/
void uninstall(Addon addon) throws MarketplaceHandlerException;
}

View File

@ -0,0 +1,167 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handle the management of bundles related to marketplace add-ons that resists OSGi cache cleanups.
*
* These operations cache incoming bundle files locally in a structure under the user data folder, and can make sure the
* bundles are re-installed if they are present in the local cache but not installed in the OSGi framework.
* They can be used by marketplace handler implementations dealing with OSGi bundles.
*
* @author Yannick Schaus - Initial contribution and API
*
*/
@NonNullByDefault
public abstract class MarketplaceBundleInstaller {
private final Logger logger = LoggerFactory.getLogger(MarketplaceBundleInstaller.class);
private static final String BUNDLE_CACHE_PATH = OpenHAB.getUserDataFolder() + File.separator + "marketplace"
+ File.separator + "bundles";
/**
* Downloads a bundle file from a remote source and puts it in the local cache with the add-on ID.
*
* @param addonId the add-on ID
* @param sourceUrl the (online) source where the .jar file can be found
* @throws MarketplaceHandlerException
*/
protected void addBundleToCache(String addonId, URL sourceUrl) throws MarketplaceHandlerException {
try {
String fileName = new File(sourceUrl.toURI().getPath()).getName();
File addonFile = new File(getAddonCacheDirectory(addonId), fileName);
addonFile.getParentFile().mkdirs();
InputStream source = sourceUrl.openStream();
Path outputPath = Path.of(addonFile.toURI());
Files.copy(source, outputPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | URISyntaxException e) {
throw new MarketplaceHandlerException("Cannot copy bundle to local cache: " + e.getMessage());
}
}
/**
* Installs a bundle from its ID by looking up in the local cache
*
* @param bundleContext the {@link BundleContext} to use to install the bundle
* @param addonId the add-on ID
* @throws MarketplaceHandlerException
*/
protected void installFromCache(BundleContext bundleContext, String addonId) throws MarketplaceHandlerException {
File addonPath = getAddonCacheDirectory(addonId);
if (addonPath.exists() && addonPath.isDirectory()) {
File[] bundleFiles = addonPath.listFiles();
if (bundleFiles.length != 1) {
throw new MarketplaceHandlerException(
"The local cache folder doesn't contain a single file: " + addonPath.toString());
}
try (FileInputStream fileInputStream = new FileInputStream(bundleFiles[0])) {
Bundle bundle = bundleContext.installBundle(addonId, fileInputStream);
try {
bundle.start();
} catch (BundleException e) {
logger.warn("The marketplace bundle was successfully installed but doesn't start: {}",
e.getMessage());
}
} catch (IOException | BundleException e) {
throw new MarketplaceHandlerException(
"Cannot install bundle from marketplace cache: " + e.getMessage());
}
}
}
/**
* Determines whether a bundle associated to the given add-on ID is installed
*
* @param bundleContext the {@link BundleContext} to use to look up the bundle
* @param addonId the add-on ID
*/
protected boolean isBundleInstalled(BundleContext bundleContext, String addonId) {
return bundleContext.getBundle(addonId) != null;
}
/**
* Uninstalls a bundle associated to the given add-on ID. Also removes it from the local cache.
*
* @param bundleContext the {@link BundleContext} to use to look up the bundle
* @param addonId the add-on ID
*/
protected void uninstallBundle(BundleContext bundleContext, String addonId) throws MarketplaceHandlerException {
File addonPath = getAddonCacheDirectory(addonId);
if (addonPath.exists() && addonPath.isDirectory()) {
for (File bundleFile : addonPath.listFiles()) {
bundleFile.delete();
}
}
addonPath.delete();
if (isBundleInstalled(bundleContext, addonId)) {
Bundle bundle = bundleContext.getBundle(addonId);
try {
bundle.stop();
bundle.uninstall();
} catch (BundleException e) {
throw new MarketplaceHandlerException("Failed uninstalling bundle: " + e.getMessage());
}
}
}
/**
* Iterates over the local cache entries and re-installs bundles that are missing
*
* @param bundleContext the {@link BundleContext} to use to look up the bundles
*/
protected void ensureCachedBundlesAreInstalled(BundleContext bundleContext) {
File addonPath = new File(BUNDLE_CACHE_PATH);
if (addonPath.exists() && addonPath.isDirectory()) {
for (File bundleFile : addonPath.listFiles()) {
if (bundleFile.isDirectory()) {
String addonId = "marketplace:" + bundleFile.getName();
if (!isBundleInstalled(bundleContext, addonId)) {
logger.info("Reinstalling missing marketplace bundle: {}", addonId);
try {
installFromCache(bundleContext, addonId);
} catch (MarketplaceHandlerException e) {
logger.warn("Failed reinstalling add-on from cache", e);
}
}
}
bundleFile.delete();
}
}
}
private File getAddonCacheDirectory(String addonId) {
return new File(BUNDLE_CACHE_PATH + File.separator + addonId.replace("marketplace:", ""));
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This is an exception that can be thrown by {@link MarketplaceAddonHandler}s if some operation fails.
*
* @author Kai Kreuzer - Initial contribution and API
*
*/
@NonNullByDefault
public class MarketplaceHandlerException extends Exception {
private static final long serialVersionUID = -5652014141471618161L;
/**
* Main constructor
*
* @param message A message describing the issue
*/
public MarketplaceHandlerException(String message) {
super(message);
}
}

View File

@ -0,0 +1,152 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.automation;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.automation.dto.RuleTemplateDTO;
import org.openhab.core.automation.dto.RuleTemplateDTOMapper;
import org.openhab.core.automation.parser.Parser;
import org.openhab.core.automation.parser.ParsingException;
import org.openhab.core.automation.template.RuleTemplate;
import org.openhab.core.automation.template.RuleTemplateProvider;
import org.openhab.core.common.registry.AbstractManagedProvider;
import org.openhab.core.storage.StorageService;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/**
* This is a {@link RuleTemplateProvider}, which gets its content from the marketplace add-on service
* and stores it through the OH storage service.
*
* @author Kai Kreuzer - Initial contribution and API
* @author Yannick Schaus - refactoring
*
*/
@NonNullByDefault
@Component(service = { MarketplaceRuleTemplateProvider.class, RuleTemplateProvider.class })
public class MarketplaceRuleTemplateProvider extends AbstractManagedProvider<RuleTemplate, String, RuleTemplateDTO>
implements RuleTemplateProvider {
private final Logger logger = LoggerFactory.getLogger(MarketplaceRuleTemplateProvider.class);
private final Parser<RuleTemplate> parser;
ObjectMapper yamlMapper;
@Activate
public MarketplaceRuleTemplateProvider(final @Reference StorageService storageService,
final @Reference(target = "(&(format=json)(parser.type=parser.template))") Parser<RuleTemplate> parser) {
super(storageService);
this.parser = parser;
this.yamlMapper = new ObjectMapper(new YAMLFactory());
yamlMapper.findAndRegisterModules();
}
@Override
public @Nullable RuleTemplate getTemplate(String uid, @Nullable Locale locale) {
return get(uid);
}
@Override
public Collection<RuleTemplate> getTemplates(@Nullable Locale locale) {
return getAll();
}
@Override
protected String getStorageName() {
return "marketplace_ruletemplates";
}
@Override
protected String keyToString(String key) {
return key;
}
@Override
protected @Nullable RuleTemplate toElement(String key, RuleTemplateDTO persistableElement) {
return RuleTemplateDTOMapper.map(persistableElement);
}
@Override
protected RuleTemplateDTO toPersistableElement(RuleTemplate element) {
return RuleTemplateDTOMapper.map(element);
}
/**
* This adds a new rule template to the persistent storage from its JSON representation.
*
* @param uid the UID to be used for the template
* @param json the template content as a JSON string
*
* @throws ParsingException if the content cannot be parsed correctly
*/
public void addTemplateAsJSON(String uid, String json) throws ParsingException {
try (InputStreamReader isr = new InputStreamReader(
new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)))) {
Set<RuleTemplate> templates = parser.parse(isr);
if (templates.size() != 1) {
throw new IllegalArgumentException("JSON must contain exactly one template!");
} else {
RuleTemplate entry = templates.iterator().next();
// add a tag with the add-on ID to be able to identify the widget in the registry
entry.getTags().add(uid);
RuleTemplate template = new RuleTemplate(entry.getUID(), entry.getLabel(), entry.getDescription(),
entry.getTags(), entry.getTriggers(), entry.getConditions(), entry.getActions(),
entry.getConfigurationDescriptions(), entry.getVisibility());
add(template);
}
} catch (IOException e) {
logger.error("Cannot close input stream.", e);
}
}
/**
* This adds a new rule template to the persistent storage from its YAML representation.
*
* @param uid the UID to be used for the template
* @param json the template content as a YAML string
*
* @throws ParsingException if the content cannot be parsed correctly
*/
public void addTemplateAsYAML(String uid, String yaml) throws ParsingException {
try {
RuleTemplateDTO dto = yamlMapper.readValue(yaml, RuleTemplateDTO.class);
// add a tag with the add-on ID to be able to identify the widget in the registry
dto.tags = new HashSet<@Nullable String>((dto.tags != null) ? dto.tags : new HashSet<String>());
dto.tags.add(uid);
RuleTemplate entry = RuleTemplateDTOMapper.map(dto);
RuleTemplate template = new RuleTemplate(entry.getUID(), entry.getLabel(), entry.getDescription(),
entry.getTags(), entry.getTriggers(), entry.getConditions(), entry.getActions(),
entry.getConfigurationDescriptions(), entry.getVisibility());
add(template);
} catch (IOException e) {
logger.error("Unable to parse YAML: {}", e.getMessage());
throw new IllegalArgumentException("Unable to parse YAML");
}
}
}

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceBundleInstaller;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.osgi.framework.BundleContext;
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;
/**
* A {@link MarketplaceAddonHandler} implementation, which handles add-ons as jar files (specifically, OSGi
* bundles) and installs them through the standard OSGi bundle installation mechanism.
* The bundles installed this way have their location set to the add-on ID to identify them and determine their
* installation status.
*
* @author Kai Kreuzer - Initial contribution and API
* @author Yannick Schaus - refactoring
*
*/
@Component(immediate = true)
public class CommunityBundleAddonHandler extends MarketplaceBundleInstaller implements MarketplaceAddonHandler {
// add-on types supported by this handler
private static final List<String> SUPPORTED_EXT_TYPES = Arrays.asList("binding");
private static final String BUNDLE_CONTENTTYPE = "application/vnd.openhab.bundle";
private static final String JAR_DOWNLOAD_URL_PROPERTY = "jar_download_url";
private final Logger logger = LoggerFactory.getLogger(CommunityBundleAddonHandler.class);
private BundleContext bundleContext;
@Activate
protected void activate(BundleContext bundleContext, Map<String, Object> config) {
this.bundleContext = bundleContext;
ensureCachedBundlesAreInstalled(bundleContext);
}
@Deactivate
protected void deactivate() {
this.bundleContext = null;
}
@Override
public boolean supports(String type, String contentType) {
// we support only certain extension types, and only as pure OSGi bundles
return SUPPORTED_EXT_TYPES.contains(type) && contentType.equals(BUNDLE_CONTENTTYPE);
}
@Override
public boolean isInstalled(String id) {
return isBundleInstalled(bundleContext, id);
}
@Override
public void install(Addon addon) throws MarketplaceHandlerException {
URL sourceUrl;
try {
sourceUrl = new URL((String) addon.getProperties().get(JAR_DOWNLOAD_URL_PROPERTY));
addBundleToCache(addon.getId(), sourceUrl);
installFromCache(bundleContext, addon.getId());
} catch (MalformedURLException e) {
throw new MarketplaceHandlerException("Malformed source URL: " + e.getMessage());
}
}
@Override
public void uninstall(Addon addon) throws MarketplaceHandlerException {
uninstallBundle(bundleContext, addon.getId());
}
}

View File

@ -0,0 +1,436 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.AddonEventFactory;
import org.openhab.core.addon.AddonService;
import org.openhab.core.addon.AddonType;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscoursePosterInfo;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscourseTopicItem;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscourseUser;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponse;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponse.DiscoursePostLink;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.events.Event;
import org.openhab.core.events.EventPublisher;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
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.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* This class is a {@link AddonService} retrieving posts on community.openhab.org (Discourse).
*
* @author Yannick Schaus - Initial contribution
*
*/
@Component(immediate = true, configurationPid = "org.openhab.marketplace", //
property = Constants.SERVICE_PID + "=org.openhab.marketplace")
@ConfigurableService(category = "system", label = "Community Marketplace", description_uri = CommunityMarketplaceAddonService.CONFIG_URI)
public class CommunityMarketplaceAddonService implements AddonService {
// constants for the configuration properties
static final String CONFIG_URI = "system:marketplace";
static final String CONFIG_API_KEY = "apiKey";
private final Logger logger = LoggerFactory.getLogger(CommunityMarketplaceAddonService.class);
private static final String COMMUNITY_BASE_URL = "https://community.openhab.org";
private static final String COMMUNITY_MARKETPLACE_URL = COMMUNITY_BASE_URL + "/c/marketplace/69/l/latest";
private static final String COMMUNITY_TOPIC_URL = COMMUNITY_BASE_URL + "/t/";
private static final String ADDON_ID_PREFIX = "marketplace:";
private static final String JSON_CODE_MARKUP_START = "<pre><code class=\"lang-json\">";
private static final String YAML_CODE_MARKUP_START = "<pre><code class=\"lang-yaml\">";
private static final String CODE_MARKUP_END = "</code></pre>";
private HashMap<Integer, AddonType> types = new HashMap<Integer, AddonType>(3);
private static final Integer BINDINGS_CATEGORY = 73;
private static final Integer RULETEMPLATES_CATEGORY = 74;
private static final Integer UIWIDGETS_CATEGORY = 75;
private static final String PUBLISHED_TAG = "published";
private HashMap<String, String> contentTypes = new HashMap<String, String>(3);
private static final String BINDINGS_CONTENT_TYPE = "application/vnd.openhab.bundle";
private static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate";
private static final String UIWIDGETS_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=widget";
private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
private final Set<MarketplaceAddonHandler> addonHandlers = new HashSet<>();
private EventPublisher eventPublisher;
private String apiKey = null;
@Activate
protected void activate(Map<String, Object> config) {
types.put(BINDINGS_CATEGORY, new AddonType("binding", "Bindings"));
types.put(RULETEMPLATES_CATEGORY, new AddonType("automation", "Automation"));
types.put(UIWIDGETS_CATEGORY, new AddonType("ui", "User Interfaces"));
contentTypes.put("binding", BINDINGS_CONTENT_TYPE);
contentTypes.put("automation", RULETEMPLATES_CONTENT_TYPE);
contentTypes.put("ui", UIWIDGETS_CONTENT_TYPE);
modified(config);
}
@Modified
void modified(@Nullable Map<String, Object> config) {
if (config != null) {
this.apiKey = (String) config.get(CONFIG_API_KEY);
}
}
@Reference(cardinality = ReferenceCardinality.AT_LEAST_ONE, policy = ReferencePolicy.DYNAMIC)
protected void addAddonHandler(MarketplaceAddonHandler handler) {
this.addonHandlers.add(handler);
}
protected void removeAddonHandler(MarketplaceAddonHandler handler) {
this.addonHandlers.remove(handler);
}
@Reference
protected void setEventPublisher(EventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
protected void unsetEventPublisher(EventPublisher eventPublisher) {
this.eventPublisher = null;
}
@Override
public String getId() {
return "marketplace";
}
@Override
public String getName() {
return "Community Marketplace";
}
@Override
public void refreshSource() {
}
@Override
public List<Addon> getAddons(Locale locale) {
try {
List<DiscourseCategoryResponse> pages = new ArrayList<DiscourseCategoryResponse>();
URL url = new URL(COMMUNITY_MARKETPLACE_URL);
int pageNb = 1;
while (url != null) {
URLConnection connection = url.openConnection();
connection.addRequestProperty("Accept", "application/json");
if (this.apiKey != null) {
connection.addRequestProperty("Api-Key", this.apiKey);
}
try (Reader reader = new InputStreamReader(connection.getInputStream())) {
DiscourseCategoryResponse parsed = gson.fromJson(reader, DiscourseCategoryResponse.class);
pages.add(parsed);
if (parsed.topic_list.more_topics_url != null) {
// Discourse URL for next page is wrong
url = new URL(COMMUNITY_MARKETPLACE_URL + "?page=" + pageNb++);
} else {
url = null;
}
}
}
List<DiscourseUser> users = pages.stream().flatMap(p -> Stream.of(p.users)).collect(Collectors.toList());
return pages.stream().flatMap(p -> Stream.of(p.topic_list.topics))
.filter(t -> Arrays.asList(t.tags).contains(PUBLISHED_TAG))
.map(t -> convertTopicItemToAddon(t, users)).collect(Collectors.toList());
} catch (Exception e) {
logger.error("Unable to retrieve marketplace add-ons", e);
return new ArrayList<Addon>();
}
}
@Override
public Addon getAddon(String id, Locale locale) {
URL url;
try {
url = new URL(String.format("%s%s", COMMUNITY_TOPIC_URL, id.replace(ADDON_ID_PREFIX, "")));
URLConnection connection = url.openConnection();
connection.addRequestProperty("Accept", "application/json");
if (this.apiKey != null) {
connection.addRequestProperty("Api-Key", this.apiKey);
}
try (Reader reader = new InputStreamReader(connection.getInputStream())) {
DiscourseTopicResponse parsed = gson.fromJson(reader, DiscourseTopicResponse.class);
return convertTopicToAddon(parsed);
}
} catch (Exception e) {
return null;
}
}
@Override
public List<AddonType> getTypes(Locale locale) {
return new ArrayList<AddonType>(types.values());
}
@Override
public void install(String id) {
Addon addon = getAddon(id, null);
for (MarketplaceAddonHandler handler : addonHandlers) {
if (handler.supports(addon.getType(), addon.getContentType())) {
if (!handler.isInstalled(addon.getId())) {
try {
handler.install(addon);
postInstalledEvent(id);
} catch (MarketplaceHandlerException e) {
postFailureEvent(id, e.getMessage());
}
} else {
postFailureEvent(id, "Add-on is already installed.");
}
return;
}
}
postFailureEvent(id, "Add-on not known.");
}
@Override
public void uninstall(String id) {
Addon addon = getAddon(id, null);
for (MarketplaceAddonHandler handler : addonHandlers) {
if (handler.supports(addon.getType(), addon.getContentType())) {
if (handler.isInstalled(addon.getId())) {
try {
handler.uninstall(addon);
postUninstalledEvent(id);
} catch (MarketplaceHandlerException e) {
postFailureEvent(id, e.getMessage());
}
} else {
postFailureEvent(id, "Add-on is not installed.");
}
return;
}
}
postFailureEvent(id, "Add-on not known.");
}
@Override
public String getAddonId(URI addonURI) {
if (addonURI.toString().startsWith(COMMUNITY_TOPIC_URL)) {
return addonURI.toString().substring(0, addonURI.toString().indexOf("/", COMMUNITY_BASE_URL.length()));
}
return "";
}
/**
* Transforms a {@link DiscourseTopicItem} to a {@link Addon}
*
* @param topic the topic
* @return the list item
*/
private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List<DiscourseUser> users) {
String id = ADDON_ID_PREFIX + topic.id.toString();
AddonType addonType = types.get(topic.category_id);
String type = (addonType != null) ? addonType.getId() : "";
String contentType = (contentTypes.get(type) != null) ? contentTypes.get(type) : "";
String version = "";
String title = topic.title;
String link = COMMUNITY_TOPIC_URL + topic.id.toString();
int likeCount = topic.like_count;
int views = topic.views;
int postsCount = topic.posts_count;
String[] tags = topic.tags;
Date createdDate = topic.created_at;
String author = "";
boolean verifiedAuthor = false;
for (DiscoursePosterInfo posterInfo : topic.posters) {
if (posterInfo.description.contains("Original Poster")) {
author = users.stream().filter(u -> u.id.equals(posterInfo.user_id)).findFirst().get().name;
}
}
HashMap<String, Object> properties = new HashMap<>(10);
properties.put("created_at", createdDate);
properties.put("like_count", likeCount);
properties.put("views", views);
properties.put("posts_count", postsCount);
properties.put("tags", tags);
String description = "";
String detailedDescription = "";
// try to use an handler to determine if the add-on is installed
boolean installed = false;
for (MarketplaceAddonHandler handler : addonHandlers) {
if (handler.supports(type, contentType)) {
if (handler.isInstalled(id)) {
installed = true;
}
}
}
String configDescriptionURI = "";
String keywords = "";
String countries = "";
String connection = "";
String backgroundColor = "";
String imageLink = topic.image_url;
Addon addon = new Addon(id, type, title, version, contentType, link, author, verifiedAuthor, installed,
description, detailedDescription, configDescriptionURI, keywords, countries, connection,
backgroundColor, imageLink, properties);
return addon;
}
/**
* Unescapes occurrences of XML entities found in the supplied content.
*
* @param content the content with potentially escaped entities
* @return the unescaped content
*/
private String unescapeEntities(String content) {
return content.replace("&quot;", "\"").replace("&amp;", "&").replace("&apos;", "'").replace("&lt;", "<")
.replace("&gt;", ">");
}
/**
* Transforms a {@link DiscourseTopicResponse} to a {@link Addon}
*
* @param topic the topic
* @return the list item
*/
private Addon convertTopicToAddon(DiscourseTopicResponse topic) {
String id = ADDON_ID_PREFIX + topic.id.toString();
AddonType addonType = types.get(topic.category_id);
String type = (addonType != null) ? addonType.getId() : "";
String contentType = contentTypes.get(type);
String version = "";
String title = topic.title;
String link = COMMUNITY_TOPIC_URL + topic.id.toString();
int likeCount = topic.like_count;
int views = topic.views;
int postsCount = topic.posts_count;
String[] tags = topic.tags;
Date createdDate = topic.post_stream.posts[0].created_at;
Date updatedDate = topic.post_stream.posts[0].updated_at;
Date lastPostedDate = topic.last_posted;
String author = topic.post_stream.posts[0].display_username;
boolean verifiedAuthor = false;
HashMap<String, Object> properties = new HashMap<>(10);
properties.put("created_at", createdDate);
properties.put("updated_at", updatedDate);
properties.put("last_posted", lastPostedDate);
properties.put("like_count", likeCount);
properties.put("views", views);
properties.put("posts_count", postsCount);
properties.put("tags", tags);
String description = "";
String detailedDescription = topic.post_stream.posts[0].cooked;
// try to extract contents or links
if (topic.post_stream.posts[0].link_counts != null) {
for (DiscoursePostLink postLink : topic.post_stream.posts[0].link_counts) {
if (postLink.url.endsWith(".jar")) {
properties.put("jar_download_url", postLink.url);
}
if (postLink.url.endsWith(".json")) {
properties.put("json_download_url", postLink.url);
}
if (postLink.url.endsWith(".yaml")) {
properties.put("yaml_download_url", postLink.url);
}
}
}
if (detailedDescription.contains(JSON_CODE_MARKUP_START)) {
String jsonContent = detailedDescription.substring(
detailedDescription.indexOf(JSON_CODE_MARKUP_START) + JSON_CODE_MARKUP_START.length(),
detailedDescription.indexOf(CODE_MARKUP_END, detailedDescription.indexOf(JSON_CODE_MARKUP_START)));
properties.put("json_content", unescapeEntities(jsonContent));
}
if (detailedDescription.contains(YAML_CODE_MARKUP_START)) {
String yamlContent = detailedDescription.substring(
detailedDescription.indexOf(YAML_CODE_MARKUP_START) + YAML_CODE_MARKUP_START.length(),
detailedDescription.indexOf(CODE_MARKUP_END, detailedDescription.indexOf(YAML_CODE_MARKUP_START)));
properties.put("yaml_content", unescapeEntities(yamlContent));
}
// try to use an handler to determine if the add-on is installed
boolean installed = false;
for (MarketplaceAddonHandler handler : addonHandlers) {
if (handler.supports(type, (contentType != null) ? contentType : "")) {
if (handler.isInstalled(id)) {
installed = true;
}
}
}
String configDescriptionURI = "";
String keywords = "";
String countries = "";
String connection = "";
String backgroundColor = "";
Addon addon = new Addon(id, type, title, version, contentType, link, author, verifiedAuthor, installed,
description, detailedDescription, configDescriptionURI, keywords, countries, connection,
backgroundColor, null, properties);
return addon;
}
private void postInstalledEvent(String extensionId) {
Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
eventPublisher.post(event);
}
private void postUninstalledEvent(String extensionId) {
Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId);
eventPublisher.post(event);
}
private void postFailureEvent(String extensionId, String msg) {
Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg);
eventPublisher.post(event);
}
}

View File

@ -0,0 +1,113 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.addon.marketplace.internal.automation.MarketplaceRuleTemplateProvider;
import org.openhab.core.automation.template.RuleTemplateProvider;
import org.openhab.core.storage.Storage;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link MarketplaceAddonHandler} implementation, which handles rule templates as JSON files and installs
* them by adding them to a {@link Storage}. The templates are then served from this storage through a dedicated
* {@link RuleTemplateProvider}.
*
* @author Kai Kreuzer - Initial contribution and API
* @author Yannick Schaus - refactoring
*
*/
@Component
public class CommunityRuleTemplateAddonHandler implements MarketplaceAddonHandler {
private static final String JSON_DOWNLOAD_URL_PROPERTY = "json_download_url";
private static final String YAML_DOWNLOAD_URL_PROPERTY = "yaml_download_url";
private static final String JSON_CONTENT_PROPERTY = "json_content";
private static final String YAML_CONTENT_PROPERTY = "yaml_content";
private static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate";
private final Logger logger = LoggerFactory.getLogger(CommunityRuleTemplateAddonHandler.class);
private MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider;
@Reference
protected void setMarketplaceRuleTemplateProvider(MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider) {
this.marketplaceRuleTemplateProvider = marketplaceRuleTemplateProvider;
}
protected void unsetMarketplaceRuleTemplateProvider(
MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider) {
this.marketplaceRuleTemplateProvider = null;
}
@Override
public boolean supports(String type, String contentType) {
return "automation".equals(type) && RULETEMPLATES_CONTENT_TYPE.equals(contentType);
}
@Override
public boolean isInstalled(String id) {
return marketplaceRuleTemplateProvider.getAll().stream().anyMatch(t -> t.getTags().contains(id));
}
@Override
public void install(Addon addon) throws MarketplaceHandlerException {
try {
String template;
if (addon.getProperties().containsKey(JSON_DOWNLOAD_URL_PROPERTY)) {
template = getTemplateFromURL((String) addon.getProperties().get(JSON_DOWNLOAD_URL_PROPERTY));
marketplaceRuleTemplateProvider.addTemplateAsJSON(addon.getId(), template);
} else if (addon.getProperties().containsKey(YAML_DOWNLOAD_URL_PROPERTY)) {
template = getTemplateFromURL((String) addon.getProperties().get(YAML_DOWNLOAD_URL_PROPERTY));
marketplaceRuleTemplateProvider.addTemplateAsYAML(addon.getId(), template);
} else if (addon.getProperties().containsKey(JSON_CONTENT_PROPERTY)) {
template = (@NonNull String) addon.getProperties().get(JSON_CONTENT_PROPERTY);
marketplaceRuleTemplateProvider.addTemplateAsJSON(addon.getId(), template);
} else if (addon.getProperties().containsKey(YAML_CONTENT_PROPERTY)) {
template = (@NonNull String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
marketplaceRuleTemplateProvider.addTemplateAsYAML(addon.getId(), template);
}
} catch (IOException e) {
logger.error("Rule template from marketplace cannot be downloaded: {}", e.getMessage());
throw new MarketplaceHandlerException("Template cannot be downloaded.");
} catch (Exception e) {
logger.error("Rule template from marketplace is invalid: {}", e.getMessage());
throw new MarketplaceHandlerException("Template is not valid.");
}
}
@Override
public void uninstall(Addon addon) throws MarketplaceHandlerException {
marketplaceRuleTemplateProvider.getAll().stream().filter(t -> t.getTags().contains(addon.getId()))
.forEach(w -> {
marketplaceRuleTemplateProvider.remove(w.getUID());
});
}
private String getTemplateFromURL(String urlString) throws IOException {
URL u = new URL(urlString);
try (InputStream in = u.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@ -0,0 +1,119 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.ui.components.RootUIComponent;
import org.openhab.core.ui.components.UIComponentRegistry;
import org.openhab.core.ui.components.UIComponentRegistryFactory;
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/**
* A {@link MarketplaceAddonHandler} implementation, which handles UI widgets as YAML files and installs
* them by adding them to the {@link UIComponentRegistry} for the ui:widget namespace.
*
* @author Yannick Schaus - Initial contribution and API
*
*/
@Component
public class CommunityUIWidgetAddonHandler implements MarketplaceAddonHandler {
private static final String YAML_DOWNLOAD_URL_PROPERTY = "yaml_download_url";
private static final String YAML_CONTENT_PROPERTY = "yaml_content";
private static final String UIWIDGETS_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=widget";
private final Logger logger = LoggerFactory.getLogger(CommunityUIWidgetAddonHandler.class);
ObjectMapper yamlMapper;
private UIComponentRegistry widgetRegistry;
@Activate
public CommunityUIWidgetAddonHandler(final @Reference UIComponentRegistryFactory uiComponentRegistryFactory) {
this.widgetRegistry = uiComponentRegistryFactory.getRegistry("ui:widget");
this.yamlMapper = new ObjectMapper(new YAMLFactory());
yamlMapper.findAndRegisterModules();
this.yamlMapper.setDateFormat(new SimpleDateFormat("MMM d, yyyy, hh:mm:ss aa"));
}
@Override
public boolean supports(String type, String contentType) {
return "ui".equals(type) && UIWIDGETS_CONTENT_TYPE.equals(contentType);
}
@Override
public boolean isInstalled(String id) {
return widgetRegistry.getAll().stream().anyMatch(w -> w.hasTag(id));
}
@Override
public void install(Addon addon) throws MarketplaceHandlerException {
try {
String widget;
if (addon.getProperties().containsKey(YAML_DOWNLOAD_URL_PROPERTY)) {
widget = getWidgetFromURL((String) addon.getProperties().get(YAML_DOWNLOAD_URL_PROPERTY));
} else if (addon.getProperties().containsKey(YAML_CONTENT_PROPERTY)) {
widget = (@NonNull String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
} else {
throw new IllegalArgumentException("Couldn't find the widget in the add-on entry");
}
addWidgetAsYAML(addon.getId(), widget);
} catch (IOException e) {
logger.error("Widget from marketplace cannot be downloaded: {}", e.getMessage());
throw new MarketplaceHandlerException("Widget cannot be downloaded.");
} catch (Exception e) {
logger.error("Widget from marketplace is invalid: {}", e.getMessage());
throw new MarketplaceHandlerException("Widget is not valid.");
}
}
@Override
public void uninstall(Addon addon) throws MarketplaceHandlerException {
widgetRegistry.getAll().stream().filter(w -> w.hasTag(addon.getId())).forEach(w -> {
widgetRegistry.remove(w.getUID());
});
}
private String getWidgetFromURL(String urlString) throws IOException {
URL u = new URL(urlString);
try (InputStream in = u.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
private void addWidgetAsYAML(String id, String yaml) {
try {
RootUIComponent widget = yamlMapper.readValue(yaml, RootUIComponent.class);
// add a tag with the add-on ID to be able to identify the widget in the registry
widget.addTag(id);
widgetRegistry.add(widget);
} catch (IOException e) {
logger.error("Unable to parse YAML: {}", e.getMessage());
throw new IllegalArgumentException("Unable to parse YAML");
}
}
}

View File

@ -0,0 +1,59 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community.model;
import java.util.Date;
/**
* A DTO class mapped to the Discourse category topic list API.
*
* @author Yannick Schaus - Initial contribution
*
*/
public class DiscourseCategoryResponse {
public DiscourseUser[] users;
public DiscourseTopicList topic_list;
public class DiscourseUser {
public Integer id;
public String username;
public String name;
public String avatar_template;
}
public class DiscourseTopicList {
public String more_topics_url;
public Integer per_page;
public DiscourseTopicItem[] topics;
}
public class DiscoursePosterInfo {
public String extras;
public String description;
public Integer user_id;
}
public class DiscourseTopicItem {
public Integer id;
public String title;
public String slug;
public String[] tags;
public Integer posts_count;
public String image_url;
public Date created_at;
public Integer like_count;
public Integer views;
public Integer category_id;
public DiscoursePosterInfo[] posters;
}
}

View File

@ -0,0 +1,79 @@
/**
* Copyright (c) 2010-2021 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.addon.marketplace.internal.community.model;
import java.util.Date;
/**
* A DTO class mapped to the Discourse topic API.
*
* @author Yannick Schaus - Initial contribution
*
*/
public class DiscourseTopicResponse {
public Integer id;
public DiscoursePostStream post_stream;
public String title;
public Integer posts_count;
public Date created_at;
public Date updated_at;
public Date last_posted;
public Integer like_count;
public Integer views;
public String[] tags;
public Integer category_id;
public DiscourseTopicDetails details;
public class DiscoursePostAuthor {
public Integer id;
public String username;
public String avatar_template;
}
public class DiscoursePostLink {
public String url;
public Boolean internal;
public Integer clicks;
}
public class DiscoursePostStream {
public DiscoursePost[] posts;
}
public class DiscoursePost {
public Integer id;
public String username;
public String display_username;
public Date created_at;
public Date updated_at;
public String cooked;
public DiscoursePostLink[] link_counts;
}
public class DiscourseTopicDetails {
public DiscoursePostAuthor created_by;
public DiscoursePostAuthor last_poster;
public DiscoursePostLink[] links;
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="system:marketplace">
<parameter name="apiKey" type="text">
<label>API Key for community.openhab.org</label>
<description>Specify the API key to use on the community forum (for staff and curators - this allows for instance to
see content which is not yet reviewed or otherwise hidden from the general public). Leave blank if you don't have
one.</description>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -0,0 +1,4 @@
system.config.marketplace.apiKey.label = API Key for community.openhab.org
system.config.marketplace.defaultSource.description = Specify the API key to use on the community forum (for staff and curators - this allows for instance to see content which is not yet reviewed or otherwise hidden from the general public). Leave blank if you don't have one.
service.system.marketplace.label = Community Marketplace

View File

@ -80,8 +80,8 @@ public class SampleAddonService implements AddonService {
String description = createDescription();
String imageLink = null;
String backgroundColor = createRandomColor();
Addon extension = new Addon(id, typeId, label, version, link, installed, description, backgroundColor,
imageLink);
Addon extension = new Addon(id, typeId, label, version, "example/vnd.openhab.addon", link, "John Doe",
false, installed, description, backgroundColor, imageLink, null, null, null, null, null, null);
extensions.put(extension.getId(), extension);
}
}
@ -111,6 +111,20 @@ public class SampleAddonService implements AddonService {
extensions.clear();
}
@Override
public String getId() {
return "sample";
}
@Override
public String getName() {
return "Sample Add-on Service";
}
@Override
public void refreshSource() {
}
@Override
public void install(String id) {
try {

View File

@ -14,6 +14,7 @@ package org.openhab.core.automation.template;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@ -122,7 +123,7 @@ public class RuleTemplate implements Template {
this.configDescriptions = configDescriptions == null ? Collections.emptyList()
: Collections.unmodifiableList(configDescriptions);
this.visibility = visibility == null ? Visibility.VISIBLE : visibility;
this.tags = tags == null ? Collections.emptySet() : Collections.unmodifiableSet(tags);
this.tags = tags == null ? new HashSet<>() : new HashSet<>(tags);
}
/**

View File

@ -29,6 +29,9 @@ public class ConfigDescriptionDTO {
public List<ConfigDescriptionParameterGroupDTO> parameterGroups;
public ConfigDescriptionDTO() {
}
public ConfigDescriptionDTO(String uri, List<ConfigDescriptionParameterDTO> parameters,
List<ConfigDescriptionParameterGroupDTO> parameterGroups) {
this.uri = uri;

View File

@ -29,6 +29,7 @@ import javax.ws.rs.POST;
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.MediaType;
import javax.ws.rs.core.Response;
@ -37,6 +38,7 @@ import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.AddonEventFactory;
import org.openhab.core.addon.AddonService;
@ -79,6 +81,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
* @author Franck Dechavanne - Added DTOs to ApiResponses
* @author Markus Rathgeb - Migrated to JAX-RS Whiteboard Specification
* @author Wouter Born - Migrated to OpenAPI annotations
* @author Yannick Schaus - Add service-related parameters & operations
*/
@Component
@JaxrsResource
@ -96,6 +99,8 @@ public class AddonResource implements RESTResource {
public static final String PATH_ADDONS = "addons";
public static final String DEFAULT_ADDON_SERVICE = "karaf";
private final Logger logger = LoggerFactory.getLogger(AddonResource.class);
private final Set<AddonService> addonServices = new CopyOnWriteArraySet<>();
private final EventPublisher eventPublisher;
@ -121,26 +126,62 @@ public class AddonResource implements RESTResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(operationId = "getAddons", summary = "Get all add-ons.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Addon.class)))) })
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Addon.class)))),
@ApiResponse(responseCode = "404", description = "Service not found") })
public Response getAddon(
@HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language,
@QueryParam("serviceId") @Parameter(description = "service ID") @Nullable String serviceId) {
logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
Locale locale = localeService.getLocale(language);
if (serviceId == "all") {
return Response.ok(new Stream2JSONInputStream(getAllAddons(locale))).build();
} else {
AddonService addonService = (serviceId != null) ? getServiceById(serviceId) : getDefaultService();
if (addonService == null) {
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
return Response.ok(new Stream2JSONInputStream(addonService.getAddons(locale).stream())).build();
}
}
@GET
@Path("/services")
@Produces(MediaType.APPLICATION_JSON)
@Operation(operationId = "getAddonTypes", summary = "Get all add-on types.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = AddonType.class)))) })
public Response getServices(
@HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language) {
logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
Locale locale = localeService.getLocale(language);
return Response.ok(new Stream2JSONInputStream(getAllAddons(locale))).build();
Stream<AddonServiceDTO> addonTypeStream = addonServices.stream().map(s -> convertToAddonServiceDTO(s, locale));
return Response.ok(new Stream2JSONInputStream(addonTypeStream)).build();
}
@GET
@Path("/types")
@Produces(MediaType.APPLICATION_JSON)
@Operation(operationId = "getAddonTypes", summary = "Get all add-on types.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = AddonType.class)))) })
@Operation(operationId = "getAddonServices", summary = "Get add-on services.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = AddonType.class)))),
@ApiResponse(responseCode = "404", description = "Service not found") })
public Response getTypes(
@HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language) {
@HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language,
@QueryParam("serviceId") @Parameter(description = "service ID") @Nullable String serviceId) {
logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
Locale locale = localeService.getLocale(language);
if (serviceId != null) {
@Nullable
AddonService service = getServiceById(serviceId);
if (service != null) {
Stream<AddonType> addonTypeStream = getAddonTypesForService(service, locale).stream().distinct();
return Response.ok(new Stream2JSONInputStream(addonTypeStream)).build();
} else {
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
} else {
Stream<AddonType> addonTypeStream = getAllAddonTypes(locale).stream().distinct();
return Response.ok(new Stream2JSONInputStream(addonTypeStream)).build();
}
}
@GET
@Path("/{addonId: [a-zA-Z_0-9-:]+}")
@ -150,26 +191,35 @@ public class AddonResource implements RESTResource {
@ApiResponse(responseCode = "404", description = "Not found") })
public Response getById(
@HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language,
@PathParam("addonId") @Parameter(description = "addon ID") String addonId) {
@PathParam("addonId") @Parameter(description = "addon ID") String addonId,
@QueryParam("serviceId") @Parameter(description = "service ID") @Nullable String serviceId) {
logger.debug("Received HTTP GET request at '{}'.", uriInfo.getPath());
Locale locale = localeService.getLocale(language);
AddonService addonService = getAddonService(addonId);
AddonService addonService = (serviceId != null) ? getServiceById(serviceId) : getDefaultService();
if (addonService == null) {
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
Addon responseObject = addonService.getAddon(addonId, locale);
if (responseObject != null) {
return Response.ok(responseObject).build();
}
return Response.status(404).build();
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
@POST
@Path("/{addonId: [a-zA-Z_0-9-:]+}/install")
@Operation(operationId = "installAddonById", summary = "Installs the add-on with the given ID.", responses = {
@ApiResponse(responseCode = "200", description = "OK") })
public Response installAddon(final @PathParam("addonId") @Parameter(description = "addon ID") String addonId) {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "404", description = "Not found") })
public Response installAddon(final @PathParam("addonId") @Parameter(description = "addon ID") String addonId,
@QueryParam("serviceId") @Parameter(description = "service ID") @Nullable String serviceId) {
AddonService addonService = (serviceId != null) ? getServiceById(serviceId) : getDefaultService();
if (addonService == null) {
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
ThreadPoolManager.getPool(THREAD_POOL_NAME).submit(() -> {
try {
AddonService addonService = getAddonService(addonId);
addonService.install(addonId);
} catch (Exception e) {
logger.error("Exception while installing add-on: {}", e.getMessage());
@ -189,7 +239,7 @@ public class AddonResource implements RESTResource {
try {
URI addonURI = new URI(url);
String addonId = getAddonId(addonURI);
installAddon(addonId);
installAddon(addonId, getAddonServiceForAddonId(addonURI));
} catch (URISyntaxException | IllegalArgumentException e) {
logger.error("Exception while parsing the addon URL '{}': {}", url, e.getMessage());
return JSONResponse.createErrorResponse(Status.BAD_REQUEST, "The given URL is malformed or not valid.");
@ -201,11 +251,16 @@ public class AddonResource implements RESTResource {
@POST
@Path("/{addonId: [a-zA-Z_0-9-:]+}/uninstall")
@Operation(operationId = "uninstallAddon", summary = "Uninstalls the add-on with the given ID.", responses = {
@ApiResponse(responseCode = "200", description = "OK") })
public Response uninstallAddon(final @PathParam("addonId") @Parameter(description = "addon ID") String addonId) {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "404", description = "Not found") })
public Response uninstallAddon(final @PathParam("addonId") @Parameter(description = "addon ID") String addonId,
@QueryParam("serviceId") @Parameter(description = "service ID") @Nullable String serviceId) {
AddonService addonService = (serviceId != null) ? getServiceById(serviceId) : getDefaultService();
if (addonService == null) {
return Response.status(HttpStatus.NOT_FOUND_404).build();
}
ThreadPoolManager.getPool(THREAD_POOL_NAME).submit(() -> {
try {
AddonService addonService = getAddonService(addonId);
addonService.uninstall(addonId);
} catch (Exception e) {
logger.error("Exception while uninstalling add-on: {}", e.getMessage());
@ -220,6 +275,15 @@ public class AddonResource implements RESTResource {
eventPublisher.post(event);
}
private AddonService getDefaultService() {
for (AddonService addonService : addonServices) {
if (addonService.getId().equals(DEFAULT_ADDON_SERVICE)) {
return addonService;
}
}
return addonServices.iterator().next();
}
private Stream<Addon> getAllAddons(Locale locale) {
return addonServices.stream().map(s -> s.getAddons(locale)).flatMap(l -> l.stream());
}
@ -239,15 +303,26 @@ public class AddonResource implements RESTResource {
return ret;
}
private AddonService getAddonService(final String addonId) {
private Set<AddonType> getAddonTypesForService(AddonService addonService, Locale locale) {
final Collator coll = Collator.getInstance(locale);
coll.setStrength(Collator.PRIMARY);
Set<AddonType> ret = new TreeSet<>(new Comparator<AddonType>() {
@Override
public int compare(AddonType o1, AddonType o2) {
return coll.compare(o1.getLabel(), o2.getLabel());
}
});
ret.addAll(addonService.getTypes(locale));
return ret;
}
private @Nullable AddonService getServiceById(final String serviceId) {
for (AddonService addonService : addonServices) {
for (Addon addon : addonService.getAddons(Locale.getDefault())) {
if (addonId.equals(addon.getId())) {
if (addonService.getId().equals(serviceId)) {
return addonService;
}
}
}
throw new IllegalArgumentException("No add-on service registered for " + addonId);
return null;
}
private String getAddonId(URI addonURI) {
@ -260,4 +335,20 @@ public class AddonResource implements RESTResource {
throw new IllegalArgumentException("No add-on service registered for URI " + addonURI);
}
private String getAddonServiceForAddonId(URI addonURI) {
for (AddonService addonService : addonServices) {
String addonId = addonService.getAddonId(addonURI);
if (addonId != null && !addonId.isBlank()) {
return addonService.getId();
}
}
throw new IllegalArgumentException("No add-on service registered for URI " + addonURI);
}
private AddonServiceDTO convertToAddonServiceDTO(AddonService addonService, Locale locale) {
return new AddonServiceDTO(addonService.getId(), addonService.getName(),
getAddonTypesForService(addonService, locale));
}
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2021 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.addons;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.addon.AddonType;
/**
* A DTO representing an add-on service.
*
* @author Yannick Schaus - initial contribution
*/
@NonNullByDefault
public class AddonServiceDTO {
String id;
String name;
Set<AddonType> addonTypes;
public AddonServiceDTO(String id, String name, Set<AddonType> addonTypes) {
super();
this.id = id;
this.name = name;
this.addonTypes = addonTypes;
}
}

View File

@ -39,6 +39,8 @@ import org.slf4j.LoggerFactory;
*/
@Component(name = "org.openhab.core.karafaddons")
public class KarafAddonService implements AddonService {
private static final String ADDONS_CONTENTTYPE = "application/vnd.openhab.feature;type=karaf";
private static final String ADDONS_AUTHOR = "openHAB";
private final Logger logger = LoggerFactory.getLogger(KarafAddonService.class);
@ -63,6 +65,20 @@ public class KarafAddonService implements AddonService {
}
}
@Override
public String getId() {
return "karaf";
}
@Override
public String getName() {
return "openHAB Distribution";
}
@Override
public void refreshSource() {
}
@Override
public List<Addon> getAddons(Locale locale) {
List<Addon> addons = new LinkedList<>();
@ -133,7 +149,7 @@ public class KarafAddonService implements AddonService {
break;
}
boolean installed = featuresService.isInstalled(feature);
return new Addon(extId, type, label, version, link, installed);
return new Addon(extId, type, label, version, ADDONS_CONTENTTYPE, link, ADDONS_AUTHOR, true, installed);
}
@Override

View File

@ -43,10 +43,17 @@ public class RootUIComponent extends UIComponent implements Identifiable<String>
@Nullable
Date timestamp;
/**
* Empty constructor for deserialization.
*/
public RootUIComponent() {
this("");
}
/**
* Constructs a root component.
*
* @param name the name of the UI component to render the card on client frontends, ie. "HbCard"
* @param name the name of the UI component to render on client frontends, ie. "oh-block"
*/
public RootUIComponent(String name) {
super(name);
@ -58,7 +65,7 @@ public class RootUIComponent extends UIComponent implements Identifiable<String>
* Constructs a root component with a specific UID.
*
* @param uid the UID of the new card
* @param name the name of the UI component to render the card on client frontends, ie. "HbCard"
* @param name the name of the UI component to render on client frontends, ie. "oh-block"
*/
public RootUIComponent(String uid, String name) {
super(name);

View File

@ -37,6 +37,12 @@ public class UIComponent {
Map<String, List<UIComponent>> slots = null;
/**
* Empty constructor for deserialization.
*/
public UIComponent() {
}
/**
* Constructs a component by its type name - component names are not arbitrary, they are defined by the target
* frontend.
@ -58,6 +64,24 @@ public class UIComponent {
return component;
}
/**
* Retrieves the type of the component.
*
* @return the component type
*/
public String getComponent() {
return component;
}
/**
* Sets the type of the component.
*
* @return the component type
*/
public void setComponent(String component) {
this.component = component;
}
/**
* Gets all the configuration parameters of the component
*
@ -67,6 +91,15 @@ public class UIComponent {
return config;
}
/**
* Sets all the configuration parameters of the component
*
* @param config the map of configuration parameters
*/
public void setConfig(Map<String, Object> config) {
this.config = config;
}
/**
* Adds a new configuration parameter to the component
*
@ -78,7 +111,7 @@ public class UIComponent {
}
/**
* Returns all the slots of the components including their sub-components
* Returns all the slots of the component including their sub-components
*
* @return the slots and their sub-components
*/
@ -86,6 +119,15 @@ public class UIComponent {
return slots;
}
/**
* Sets all the slots of the component
*
* @param slots the slots and their sub-components
*/
public void setSlots(Map<String, List<UIComponent>> slots) {
this.slots = slots;
}
/**
* Adds a new empty slot to the component
*

View File

@ -12,22 +12,34 @@
*/
package org.openhab.core.addon;
import java.util.Map;
/**
* This class defines an add-on.
*
* @author Kai Kreuzer - Initial contribution
* @author Yannick Schaus - Add fields
*/
public class Addon {
private final String id;
private final String label;
private final String version;
private final String contentType;
private final String link;
private final String author;
private boolean verifiedAuthor;
private boolean installed;
private final String type;
private final String description;
private final String detailedDescription;
private final String configDescriptionURI;
private final String keywords;
private final String countries;
private final String connection;
private final String backgroundColor;
private final String imageLink;
private final Map<String, Object> properties;
/**
* Creates a new Addon instance
@ -36,11 +48,16 @@ public class Addon {
* @param type the type id of the add-on
* @param label the label of the add-on
* @param version the version of the add-on
* @param contentType the content type of the add-on
* @param link the link to find more information about the add-on (can be null)
* @param author the author of the add-on
* @param verifiedAuthor true, if the author is verified
* @param installed true, if the add-on is installed, false otherwise
*/
public Addon(String id, String type, String label, String version, String link, boolean installed) {
this(id, type, label, version, link, installed, null, null, null);
public Addon(String id, String type, String label, String version, String contentType, String link, String author,
boolean verifiedAuthor, boolean installed) {
this(id, type, label, version, contentType, link, author, verifiedAuthor, installed, null, null, null, null,
null, null, null, null, null);
}
/**
@ -50,23 +67,37 @@ public class Addon {
* @param type the type id of the add-on
* @param label the label of the add-on
* @param version the version of the add-on
* @param contentType the content type of the add-on
* @param description the detailed description of the add-on (may be null)
* @param backgroundColor for displaying the add-on (may be null)
* @param link the link to find more information about the add-on (may be null)
* @param author the author of the add-on
* @param verifiedAuthor true, if the author is verified
* @param imageLink the link to an image (png/svg) (may be null)
* @param installed true, if the add-on is installed, false otherwise
*/
public Addon(String id, String type, String label, String version, String link, boolean installed,
String description, String backgroundColor, String imageLink) {
public Addon(String id, String type, String label, String version, String contentType, String link, String author,
boolean verifiedAuthor, boolean installed, String description, String detailedDescription,
String configDescriptionURI, String keywords, String countries, String connection, String backgroundColor,
String imageLink, Map<String, Object> properties) {
this.id = id;
this.label = label;
this.version = version;
this.contentType = contentType;
this.description = description;
this.detailedDescription = detailedDescription;
this.configDescriptionURI = configDescriptionURI;
this.keywords = keywords;
this.countries = countries;
this.connection = connection;
this.backgroundColor = backgroundColor;
this.link = link;
this.imageLink = imageLink;
this.author = author;
this.verifiedAuthor = verifiedAuthor;
this.installed = installed;
this.type = type;
this.properties = properties;
}
/**
@ -97,6 +128,20 @@ public class Addon {
return link;
}
/**
* The author of the add-on
*/
public String getAuthor() {
return author;
}
/**
* Whether the add-on author is verified or not
*/
public boolean isVerifiedAuthor() {
return verifiedAuthor;
}
/**
* The version of the add-on
*/
@ -104,6 +149,62 @@ public class Addon {
return version;
}
/**
* The content type of the add-on
*/
public String getContentType() {
return contentType;
}
/**
* The description of the add-on
*/
public String getDescription() {
return description;
}
/**
* The detailed description of the add-on
*/
public String getDetailedDescription() {
return detailedDescription;
}
/**
* The URI to the configuration description for this add-on
*/
public String getConfigDescriptionURI() {
return configDescriptionURI;
}
/**
* The keywords for this add-on
*/
public String getKeywords() {
return keywords;
}
/**
* A comma-separated list of ISO 3166 codes relevant to this add-on
*/
public String getCountries() {
return countries;
}
/**
* A string describing the type of connection (local or cloud, push or pull...) this add-on uses, if applicable.
*/
public String getConnection() {
return connection;
}
/**
* A set of additional properties relative to this add-on
*/
public Map<String, Object> getProperties() {
return properties;
}
/**
* true, if the add-on is installed, false otherwise
*/
@ -118,13 +219,6 @@ public class Addon {
this.installed = installed;
}
/**
* The description of the add-on
*/
public String getDescription() {
return description;
}
/**
* The background color for rendering the add-on
*/

View File

@ -22,11 +22,34 @@ import java.util.Locale;
* The REST API offers an uri that exposes this functionality.
*
* @author Kai Kreuzer - Initial contribution
* @author Yannick Schaus - Add id, name and refreshSource
*/
public interface AddonService {
/**
* Retrieves all add-ons
* Returns the ID of the service.
*
* @return the service identifier
*/
String getId();
/**
* Returns the name of the service.
*
* @return the service name
*/
String getName();
/**
* Refreshes the source used for providing the add-ons.
*
* This can be called before getAddons to ensure the add-on information is up-to-date; otherwise they might be
* retrieved from a cache.
*/
void refreshSource();
/**
* Retrieves all add-ons.
*
* It is expected that this method is rather cheap to call and will return quickly, i.e. some caching should be
* implemented if required.

View File

@ -17,6 +17,7 @@
<name>openHAB Core :: Bundles</name>
<modules>
<module>org.openhab.core.addon.marketplace</module>
<module>org.openhab.core.auth.jaas</module>
<module>org.openhab.core.auth.oauth2client</module>
<module>org.openhab.core.automation</module>

View File

@ -64,6 +64,14 @@
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.sse/${project.version}</bundle>
</feature>
<feature name="openhab-core-addon-marketplace" version="${project.version}">
<feature>openhab-core-base</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.addon.marketplace/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jackson)"</requirement>
<feature dependency="true">openhab.tp-jackson</feature>
<feature dependency="true">openhab-core-ui</feature>
</feature>
<feature name="openhab-core-auth-jaas" version="${project.version}">
<feature>openhab-core-base</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.auth.jaas/${project.version}</bundle>
@ -361,6 +369,7 @@
<feature name="openhab-runtime-base" description="openHAB Runtime Base" version="${project.version}">
<feature>openhab-core-base</feature>
<feature>openhab-core-addon-marketplace</feature>
<feature>openhab-core-auth-jaas</feature>
<feature>openhab-core-automation-rest</feature>
<feature>openhab-core-automation-module-script</feature>