diff --git a/.gitignore b/.gitignore
index 5881721788..a9f10d29c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,7 @@ maven.properties
# Test Files
*.tmp
+spring-shell.log
# Documentation
.gitmodules
diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java
index eb91d17895..7ac886b1fb 100644
--- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java
+++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java
@@ -46,4 +46,4 @@ public static MgmtSystemTenantConfigurationValue toResponseDefaultDsType(Long de
.withSelfRel().expand());
return restConfValue;
}
-}
+}
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/pom.xml b/hawkbit-sdk/hawkbit-sdk-commons/pom.xml
new file mode 100644
index 0000000000..a12fc4e114
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+ org.eclipse.hawkbit
+ hawkbit-sdk
+ ${revision}
+
+
+ hawkbit-sdk-commons
+ hawkBit :: SDK :: Commons
+ SDK commons
+
+
+ 4.0.4
+ 13.0
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-openfeign
+ ${spring-cloud-starter-openfeign.version}
+
+
+ io.github.openfeign
+ feign-hc5
+ ${openfeign-hc5.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-hateoas
+
+
+
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java
new file mode 100644
index 0000000000..c52b3e6c70
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.ToString;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+@Data
+@Builder
+public class Controller {
+
+ // id of the tenant
+ @NonNull
+ private String controllerId;
+ // (target) security token
+ @Nullable
+ private String securityToken;
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java
new file mode 100644
index 0000000000..f674b178f4
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2023 Contributors to the Eclipse Foundation
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk;
+
+import feign.Client;
+import feign.Contract;
+import feign.Feign;
+import feign.codec.Decoder;
+import feign.codec.Encoder;
+import feign.codec.ErrorDecoder;
+import lombok.Builder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.ObjectUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Objects;
+
+@Slf4j
+@Builder
+public class HawkbitClient {
+
+ private static final String AUTHORIZATION = "Authorization";
+ private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default();
+
+ private final HawkbitServer hawkBitServerProperties;
+
+ private final Client client;
+ private final Encoder encoder;
+ private final Decoder decoder;
+ private final Contract contract;
+
+ public HawkbitClient(
+ final HawkbitServer hawkBitServerProperties,
+ final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
+ this.hawkBitServerProperties = hawkBitServerProperties;
+ this.client = client;
+ this.encoder = encoder;
+ this.decoder = decoder;
+ this.contract = contract;
+ }
+
+ public T mgmtService(final Class serviceType, final Tenant tenantProperties) {
+ return service(serviceType, tenantProperties, null);
+ }
+ public T ddiService(final Class serviceType, final Tenant tenantProperties, final Controller controller) {
+ return service(serviceType, tenantProperties, controller);
+ }
+
+ private T service(final Class serviceType, final Tenant tenantProperties, final Controller controller) {
+ return Feign.builder().client(client)
+ .encoder(encoder)
+ .decoder(decoder)
+ .errorDecoder((methodKey, response) -> {
+ final Exception e = DEFAULT_ERROR_DECODER.decode(methodKey, response);
+ log.trace("REST API call failed!", e);
+ return e;
+ })
+ .contract(contract)
+ .requestInterceptor(controller == null ?
+ template -> {
+ template.header(AUTHORIZATION,
+ "Basic " +
+ Base64.getEncoder()
+ .encodeToString(
+ (Objects.requireNonNull(tenantProperties.getUsername(),
+ "User is null!") +
+ ":" +
+ Objects.requireNonNull(tenantProperties.getPassword(),
+ "Password is not available!"))
+ .getBytes(StandardCharsets.ISO_8859_1)));
+ } :
+ template -> {
+ if (ObjectUtils.isEmpty(tenantProperties.getGatewayToken())) {
+ if (!ObjectUtils.isEmpty(controller.getSecurityToken())) {
+ template.header(AUTHORIZATION, "TargetToken " + controller.getSecurityToken());
+ } // else do not sent authentication
+ } else {
+ template.header(AUTHORIZATION, "GatewayToken " + tenantProperties.getGatewayToken());
+ }
+ })
+ .target(serviceType,
+ controller == null ?
+ hawkBitServerProperties.getMgmtUrl() :
+ hawkBitServerProperties.getDdiUrl());
+ }
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java
new file mode 100644
index 0000000000..c0641e6c35
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk;
+
+import feign.Contract;
+import feign.MethodMetadata;
+import feign.RequestInterceptor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cloud.openfeign.FeignClientsConfiguration;
+import org.springframework.cloud.openfeign.hateoas.WebConvertersCustomizer;
+import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer;
+import org.springframework.cloud.openfeign.support.SpringMvcContract;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.hateoas.config.EnableHypermediaSupport;
+import org.springframework.hateoas.config.WebConverters;
+import org.springframework.http.MediaType;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+
+@Slf4j
+@Configuration
+@EnableConfigurationProperties({ HawkbitServer.class, Tenant.class})
+@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
+@Import(FeignClientsConfiguration.class)
+public class HawkbitSDKConfigurtion {
+
+ /**
+ * An feign request interceptor to set the defined {@code Accept} and {@code Content-Type} headers for each request
+ * to {@code application/json}.
+ *
+ * TODO - is this needed?
+ */
+ @Bean
+ @Primary
+ public RequestInterceptor jsonHeaderInterceptorOverride() {
+ return template -> template
+ .header("Accept", MediaType.APPLICATION_JSON_VALUE)
+ .header("Content-Type", MediaType.APPLICATION_JSON_VALUE);
+ }
+
+ // takes place only when spring app is started in non-web-app mode
+ // in that case org.springframework.cloud.openfeign.hateoas.FeignHalAutoConfiguration
+ // is explicitly disabled and HAL/HATEOAS support doesn't work
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnNotWebApplication
+ @ConditionalOnClass({ WebConverters.class})
+ public HttpMessageConverterCustomizer webConvertersCustomizerOverrider(WebConverters webConverters) {
+ return new WebConvertersCustomizer(webConverters);
+ }
+ // another option would be something like (need to import io.github.openfeign:feign-jackson
+ // @Bean @Primary @ConditionalOnNotWebApplication
+ // public Decoder feignDecoderOverride() {
+ // return new ResponseEntityDecoder(new JacksonDecoder(new ObjectMapper().registerModule(new Jackson2HalModule())));
+ // }
+
+ /**
+ * Own implementation of the {@link SpringMvcContract} which catches the {@link IllegalStateException} which occurs
+ * due multiple produces and consumes values in the request-mapping
+ * annotation.https://github.com/spring-cloud/spring-cloud-netflix/issues/808
+ *
+ * TODO - is this needed?
+ */
+ @Bean
+ @Primary
+ public Contract feignContractOverride() {
+ return new SpringMvcContract() {
+
+ @Override
+ protected void processAnnotationOnMethod(final MethodMetadata data, final Annotation methodAnnotation, final Method method) {
+ try {
+ super.processAnnotationOnMethod(data, methodAnnotation, method);
+ } catch (final IllegalStateException e) {
+ // ignore illegalstateexception here because it's thrown because of
+ // multiple consumers and produces, see
+ // https://github.com/spring-cloud/spring-cloud-netflix/issues/808
+ log.trace(e.getMessage(), e);
+
+ // This line from super is mandatory to avoid that access to the
+ // expander causes a nullpointer.
+ data.indexToExpander(new LinkedHashMap<>());
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java
new file mode 100644
index 0000000000..3e13d1ec7d
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.lang.NonNull;
+
+@ConfigurationProperties(prefix="hawkbit.server")
+@Data
+public class HawkbitServer {
+
+ @NonNull
+ private String mgmtUrl = "http://localhost:8080";
+ @NonNull
+ private String ddiUrl = "http://localhost:8081";
+}
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java
new file mode 100644
index 0000000000..3f8d0966bd
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk;
+
+import lombok.Data;
+import lombok.ToString;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+@ConfigurationProperties("hawkbit.tenant")
+@Data
+public class Tenant {
+
+ // id of the tenant
+ @NonNull
+ private String tenantId = "DEFAULT";
+
+ // basic auth user, to access management api
+ @Nullable
+ private String username = "admin";
+ @ToString.Exclude
+ @Nullable
+ private String password = "admin";
+
+ // gateway token
+ @Nullable
+ private String gatewayToken;
+
+ private boolean downloadAuthenticationEnabled;
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties
new file mode 100644
index 0000000000..8b73eecab9
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+#
+# Copyright (c) 2023 Bosch.IO GmbH and others
+#
+# 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/
+#
+# SPDX-License-Identifier: EPL-2.0
+#
+
+spring.cloud.openfeign.httpclient.hc5.enabled=true
+
diff --git a/hawkbit-sdk/hawkbit-sdk-demo/pom.xml b/hawkbit-sdk/hawkbit-sdk-demo/pom.xml
new file mode 100644
index 0000000000..3912a10241
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-demo/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+ org.eclipse.hawkbit
+ hawkbit-sdk
+ ${revision}
+
+
+ hawkbit-sdk-test
+ hawkBit :: SDK :: Test / Example
+ Test / Example of how SDK could be used to for devices and for Mgmt API access
+
+
+ 3.1.5
+
+
+
+
+ org.eclipse.hawkbit
+ hawkbit-sdk-device
+ ${project.version}
+
+
+ org.eclipse.hawkbit
+ hawkbit-mgmt-api
+ ${project.version}
+
+
+
+ org.springframework.shell
+ spring-shell-starter
+ ${spring-shell.version}
+
+
+
diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java
new file mode 100644
index 0000000000..e6e6f29e6e
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk.demo;
+
+import feign.FeignException;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
+import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody;
+import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi;
+import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi;
+import org.eclipse.hawkbit.sdk.HawkbitClient;
+import org.eclipse.hawkbit.sdk.Tenant;
+import org.eclipse.hawkbit.sdk.device.DdiController;
+import org.springframework.util.ObjectUtils;
+
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * Abstract class representing DDI device connecting directly to hawkVit.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Slf4j
+public class SetupHelper {
+
+ private static final String AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED = "authentication.gatewaytoken.enabled";
+
+ /**
+ * Gateway token value.
+ */
+ private static final String AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY = "authentication.gatewaytoken.key";
+ private static final String AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED = "authentication.targettoken.enabled";
+
+ // if gateway toke is configured then the gateway auth is enabled key is set
+ // so all devices use gateway token authentication
+ // otherwise target token authentication is enabled. Then all devices shall be registerd
+ // and the target token shall be set to the one from the DDI controller instance
+ public static void setupTargetAuthentication(final HawkbitClient hawkbitClient, final Tenant tenant) {
+ final MgmtTenantManagementRestApi mgmtTenantManagementRestApi =
+ hawkbitClient.mgmtService(MgmtTenantManagementRestApi.class, tenant);
+ if (ObjectUtils.isEmpty(tenant.getGatewayToken())) {
+ if (!((Boolean)mgmtTenantManagementRestApi
+ .getTenantConfigurationValue(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED)
+ .getBody().getValue())) {
+ mgmtTenantManagementRestApi.updateTenantConfiguration(
+ Map.of(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, true)
+ );
+ }
+ } else {
+ if (!((Boolean)mgmtTenantManagementRestApi
+ .getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED)
+ .getBody().getValue())) {
+ mgmtTenantManagementRestApi.updateTenantConfiguration(
+ Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, true)
+ );
+ }
+ if (!tenant.getGatewayToken().equals(
+ mgmtTenantManagementRestApi
+ .getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY)
+ .getBody().getValue())) {
+ mgmtTenantManagementRestApi.updateTenantConfiguration(
+ Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, tenant.getGatewayToken())
+ );
+ }
+ }
+ }
+
+ // returns target token
+ public static String setupTargetToken(
+ final String controllerId, String securityTargetToken,
+ final HawkbitClient hawkbitClient, final Tenant tenant) {
+ if (ObjectUtils.isEmpty(tenant.getGatewayToken())) {
+ final MgmtTargetRestApi mgmtTargetRestApi = hawkbitClient.mgmtService(MgmtTargetRestApi.class, tenant);
+ try {
+ // test if target exist, if not - throws 404
+ final MgmtTarget target = mgmtTargetRestApi.getTarget(controllerId).getBody();
+ if (ObjectUtils.isEmpty(securityTargetToken)) {
+ if (ObjectUtils.isEmpty(target.getSecurityToken())) {
+ // generate random to set to tha existing target without configured security token
+ securityTargetToken = randomToken();
+ mgmtTargetRestApi.updateTarget(controllerId,
+ new MgmtTargetRequestBody().setSecurityToken(securityTargetToken));
+ }
+ } else if (!securityTargetToken.equals(target.getSecurityToken())){
+ // update target's with the security token (since it doesn't match)
+ mgmtTargetRestApi.updateTarget(controllerId,
+ new MgmtTargetRequestBody().setSecurityToken(securityTargetToken));
+ }
+ } catch (final FeignException.NotFound e) {
+ if (ObjectUtils.isEmpty(securityTargetToken)) {
+ securityTargetToken = randomToken();
+ }
+ // create target with the security token
+ mgmtTargetRestApi.createTargets(List.of(
+ new MgmtTargetRequestBody()
+ .setControllerId(controllerId)
+ .setSecurityToken(securityTargetToken)));
+ }
+ }
+
+ return securityTargetToken;
+ }
+
+ private static final Random RND = new Random();
+ public static String randomToken() {
+ final byte[] rnd = new byte[24];
+ RND.nextBytes(rnd);
+ return Base64.getEncoder().encodeToString(rnd);
+ }
+}
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java
new file mode 100644
index 0000000000..a8c9b7cd9f
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk.demo.device;
+
+import feign.Client;
+import feign.Contract;
+import feign.codec.Decoder;
+import feign.codec.Encoder;
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.hawkbit.sdk.Controller;
+import org.eclipse.hawkbit.sdk.HawkbitServer;
+import org.eclipse.hawkbit.sdk.HawkbitClient;
+import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion;
+import org.eclipse.hawkbit.sdk.Tenant;
+import org.eclipse.hawkbit.sdk.demo.SetupHelper;
+import org.eclipse.hawkbit.sdk.device.DdiController;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.shell.standard.ShellComponent;
+import org.springframework.shell.standard.ShellMethod;
+import org.springframework.util.ObjectUtils;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Abstract class representing DDI device connecting directly to hawkVit.
+ */
+@Slf4j
+@SpringBootApplication
+@Import({ HawkbitSDKConfigurtion.class})
+public class DeviceApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DeviceApp.class, args);
+ }
+
+ @Bean
+ HawkbitClient hawkbitClient(
+ final HawkbitServer hawkBitServer,
+ final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
+ return new HawkbitClient(hawkBitServer, client, encoder, decoder, contract);
+ }
+
+ @Bean
+ DdiController device(
+ @Value("${hawkbit.device:controller-default}") final String controllerId,
+ @Value("${hawkbit.device.securityToken:}") final String securityToken,
+ final Tenant defaultTenant, final HawkbitClient hawkbitClient) {
+ return new DdiController(
+ defaultTenant,
+ Controller.builder()
+ .controllerId(controllerId)
+ .securityToken(ObjectUtils.isEmpty(securityToken) ?
+ (ObjectUtils.isEmpty(defaultTenant.getGatewayToken()) ? SetupHelper.randomToken() : securityToken) :
+ securityToken)
+ .build(),
+ hawkbitClient).setOverridePollMillis(10_000);
+ }
+
+ @ShellComponent
+ public static class Shell {
+
+ private final Tenant tenant;
+ private final DdiController device;
+ private final HawkbitClient hawkbitClient;
+
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+
+ Shell(final Tenant tenant, final DdiController device, final HawkbitClient hawkbitClient) {
+ this.tenant = tenant;
+ this.device = device;
+ this.hawkbitClient = hawkbitClient;
+ }
+
+ @ShellMethod(key = "setup")
+ public void setup() {
+ SetupHelper.setupTargetAuthentication(hawkbitClient, tenant);
+ SetupHelper.setupTargetToken(
+ device.getControllerId(), device.getTargetSecurityToken(), hawkbitClient, tenant);
+ }
+
+ @ShellMethod(key = "start")
+ public void start() {
+ device.start(scheduler);
+ }
+
+ @ShellMethod(key = "stop")
+ public void stop() {
+ device.stop();
+ }
+ }
+}
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java
new file mode 100644
index 0000000000..6cf092e9ce
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk.demo.multidevice;
+
+import feign.Client;
+import feign.Contract;
+import feign.codec.Decoder;
+import feign.codec.Encoder;
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.hawkbit.sdk.Controller;
+import org.eclipse.hawkbit.sdk.HawkbitServer;
+import org.eclipse.hawkbit.sdk.HawkbitClient;
+import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion;
+import org.eclipse.hawkbit.sdk.Tenant;
+import org.eclipse.hawkbit.sdk.demo.SetupHelper;
+import org.eclipse.hawkbit.sdk.device.DdiController;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.shell.standard.ShellComponent;
+import org.springframework.shell.standard.ShellMethod;
+import org.springframework.shell.standard.ShellOption;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Abstract class representing DDI device connecting directly to hawkVit.
+ */
+@Slf4j
+@SpringBootApplication
+@Import({ HawkbitSDKConfigurtion.class})
+public class MultiDeviceApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MultiDeviceApp.class, args);
+ }
+
+ @Bean
+ HawkbitClient hawkbitClient(
+ final HawkbitServer hawkBitServer,
+ final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
+ return new HawkbitClient(hawkBitServer, client, encoder, decoder, contract);
+ }
+
+ @ShellComponent
+ public static class Shell {
+
+ private final Tenant tenant;
+ private final HawkbitClient hawkbitClient;
+ private final Map devices = new ConcurrentHashMap<>();
+
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+
+ private boolean setup;
+
+ Shell(final Tenant tenant, final HawkbitClient hawkbitClient) {
+ this.tenant = tenant;
+ this.hawkbitClient = hawkbitClient;
+ }
+
+ @ShellMethod(key = "setup")
+ public void setup() {
+ SetupHelper.setupTargetAuthentication(hawkbitClient, tenant);
+ setup = true;
+ }
+
+ @ShellMethod(key = "start-one")
+ public void startOne(@ShellOption("--id") final String controllerId) {
+ DdiController device = devices.get(controllerId);
+ final String securityTargetToken;
+ if (setup) {
+ securityTargetToken = SetupHelper.setupTargetToken(
+ controllerId, null, hawkbitClient, tenant);
+ } else {
+ securityTargetToken = null;
+ }
+ if (device == null) {
+ device = new DdiController(tenant,
+ Controller.builder()
+ .controllerId(controllerId)
+ .securityToken(securityTargetToken)
+ .build(),
+ hawkbitClient).setOverridePollMillis(10_000);
+ final DdiController oldDevice = devices.putIfAbsent(controllerId, device);
+ if (oldDevice != null) {
+ device = oldDevice; // reuse existing
+ }
+ }
+
+ device.start(scheduler);
+ }
+
+ @ShellMethod(key = "stop-one")
+ public void stopOne(@ShellOption("--id") final String controllerId) {
+ final DdiController device = devices.get(controllerId);
+ if (device == null) {
+ System.out.println("ERROR: controller with id " + controllerId + " not found!");
+ } else {
+ device.stop();
+ }
+ }
+
+ @ShellMethod(key = "start")
+ public void start(
+ @ShellOption(value = "--prefix", defaultValue = "") final String prefix,
+ @ShellOption(value = "--offset", defaultValue = "0") final int offset,
+ @ShellOption(value = "--count") final int count) {
+ for (int i = 0; i < count; i++) {
+ startOne(String.format(prefix + "%03d", offset + i));
+ }
+ }
+
+ @ShellMethod(key = "stop")
+ public void stop(
+ @ShellOption(value = "--prefix", defaultValue = "") final String prefix,
+ @ShellOption(value = "--offset", defaultValue = "0") final int offset,
+ @ShellOption(value = "--count") final int count) {
+ for (int i = 0; i < count; i++) {
+ stopOne(String.format(prefix + "%03d", offset + i));
+ }
+ }
+ }
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties
new file mode 100644
index 0000000000..cc3b6310fb
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties
@@ -0,0 +1,15 @@
+#
+# Copyright (c) 2023 Bosch.IO GmbH and others
+#
+# 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/
+#
+# SPDX-License-Identifier: EPL-2.0
+#
+
+spring.main.web-application-type=none
+spring.cloud.openfeign.httpclient.hc5.enabled=true
+
+logging.level.org.eclipse.hawkbit=DEBUG
+
diff --git a/hawkbit-sdk/hawkbit-sdk-device/pom.xml b/hawkbit-sdk/hawkbit-sdk-device/pom.xml
new file mode 100644
index 0000000000..50c1676e3b
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-device/pom.xml
@@ -0,0 +1,39 @@
+
+
+ 4.0.0
+
+ org.eclipse.hawkbit
+ hawkbit-sdk
+ ${revision}
+
+
+ hawkbit-sdk-device
+ hawkBit :: SDK :: Device SDK
+ Device SDK that could be used for development of devices on JVM based languages
+
+
+
+ org.eclipse.hawkbit
+ hawkbit-sdk-commons
+ ${project.version}
+
+
+
+ org.eclipse.hawkbit
+ hawkbit-ddi-api
+ ${project.version}
+
+
+
\ No newline at end of file
diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java
new file mode 100644
index 0000000000..57aa793b7e
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java
@@ -0,0 +1,492 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk.device;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.security.DigestOutputStream;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalTime;
+import java.time.temporal.ChronoField;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import com.google.common.io.BaseEncoding;
+import com.google.common.io.ByteStreams;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.core5.ssl.SSLContextBuilder;
+import org.eclipse.hawkbit.ddi.json.model.DdiArtifact;
+import org.eclipse.hawkbit.ddi.json.model.DdiChunk;
+import org.eclipse.hawkbit.ddi.json.model.DdiConfigData;
+import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback;
+import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase;
+import org.eclipse.hawkbit.ddi.json.model.DdiDeployment;
+import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase;
+import org.eclipse.hawkbit.ddi.json.model.DdiUpdateMode;
+import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi;
+import org.eclipse.hawkbit.sdk.Controller;
+import org.eclipse.hawkbit.sdk.HawkbitClient;
+import org.eclipse.hawkbit.sdk.Tenant;
+import org.springframework.hateoas.Link;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Abstract class representing DDI device connecting directly to hawkVit.
+ */
+@Slf4j
+@Getter
+public class DdiController {
+
+ private static final String LOG_PREFIX = "[{}:{}] ";
+
+ // TODO - make them configurable
+ private static final long IMMEDIATE_MS = 10;
+ private static final long DEFAULT_POLL_MS = 5_000;
+
+ private static final String DEPLOYMENT_BASE_LINK = "deploymentBase";
+ private static final String CONFIRMATION_BASE_LINK = "confirmationBase";
+
+ private final String tenantId;
+ private final String controllerId;
+ private final DdiRootControllerRestApi ddiApi;
+
+ // configuration
+ private final boolean downloadAuthenticationEnabled;
+ private final String gatewayToken;
+ private final String targetSecurityToken;
+ @Setter
+ @Accessors(chain = true)
+ private long overridePollMillis = -1; // -1 means disabled
+
+ // state
+ private volatile ScheduledExecutorService executorService;
+ private volatile Long currentActionId;
+ private volatile UpdateStatus updateStatus;
+
+ /**
+ * Creates a new device instance.
+ *
+ * @param tenant the tenant of the device belongs to
+ * @param controller the the controller
+ * @param hawkbitClient a factory for creaint to {@link DdiRootControllerRestApi} (and moreused)
+ * for communication to hawkBit
+ */
+ public DdiController(final Tenant tenant, final Controller controller, final HawkbitClient hawkbitClient) {
+ this.tenantId = tenant.getTenantId();
+ gatewayToken = tenant.getGatewayToken();
+ downloadAuthenticationEnabled = tenant.isDownloadAuthenticationEnabled();
+ this.controllerId = controller.getControllerId();
+ this.targetSecurityToken = controller.getSecurityToken();
+ ddiApi = hawkbitClient.ddiService(DdiRootControllerRestApi.class, tenant, controller);
+ }
+
+ // expects single threaded {@link java.util.concurrent.ScheduledExecutorService}
+ public void start(final ScheduledExecutorService executorService) {
+ Objects.requireNonNull(executorService, "Require non null executor!");
+
+ this.executorService = executorService;
+ executorService.submit(this::poll);
+ }
+
+ public void stop() {
+ executorService = null;
+ currentActionId = null;
+ }
+
+ private void poll() {
+ Optional.ofNullable(executorService).ifPresent(executor -> {
+ getControllerBase().ifPresentOrElse(
+ controllerBase -> {
+ final Optional confirmationBaseLink = getRequiredLink(controllerBase, CONFIRMATION_BASE_LINK);
+ if (confirmationBaseLink.isPresent()) {
+ final long actionId = getActionId(confirmationBaseLink.get());
+ log.info(LOG_PREFIX + "Confirmation is required for action {}!", getTenantId(),
+ getControllerId(), actionId);
+ // TODO - confirmation handler
+ sendConfirmationFeedback(actionId);
+ executor.schedule(this::poll, IMMEDIATE_MS, TimeUnit.MILLISECONDS);
+ } else {
+ getRequiredLink(controllerBase, DEPLOYMENT_BASE_LINK).flatMap(this::getActionWithDeployment).ifPresentOrElse(actionWithDeployment -> {
+ final long actionId = actionWithDeployment.getKey();
+ if (currentActionId == null) {
+ log.info(LOG_PREFIX + "Process action {}", getTenantId(), getControllerId(),
+ actionId);
+ final DdiDeployment deployment = actionWithDeployment.getValue().getDeployment();
+ final DdiDeployment.HandlingType updateType = deployment.getUpdate();
+ final List modules = deployment.getChunks();
+
+ currentActionId = actionId;
+ executor.submit(new UpdateProcessor(actionId, updateType, modules));
+ } else if (currentActionId != actionId) {
+ // TODO - cancel and start new one?
+ log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(),
+ getControllerId(), getCurrentActionId());
+ } // else same action - already processing
+ }, () -> {
+ if (currentActionId != null) {
+ // TODO - cancel current?
+ log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(),
+ getControllerId(), getCurrentActionId());
+ }
+ });
+ executor.schedule(this::poll, getPollMillis(controllerBase), TimeUnit.MILLISECONDS);
+ }
+ },
+ () -> {
+ // error has occurred or no controller base hasn't been acquired
+ executor.schedule(this::poll, DEFAULT_POLL_MS, TimeUnit.MILLISECONDS);
+ }
+ );
+ });
+ }
+
+ private Optional getControllerBase() {
+ log.trace(LOG_PREFIX + "Polling ...", getTenantId(), getControllerId());
+ final ResponseEntity poll;
+ try {
+ poll = getDdiApi().getControllerBase(getTenantId(), getControllerId());
+ } catch (final RuntimeException ex) {
+ log.error(LOG_PREFIX + "Failed base poll", getTenantId(), getControllerId(), ex);
+ return Optional.empty();
+ }
+
+ if (poll.getStatusCode() != HttpStatus.OK) {
+ log.error(LOG_PREFIX + "Failed base poll {}", getTenantId(), getControllerId(), poll.getStatusCode());
+ return Optional.empty();
+ }
+
+ return Optional.ofNullable(poll.getBody());
+ }
+
+ private Optional getRequiredLink(final DdiControllerBase controllerBase, final String nameOfTheLink) {
+ final Optional link = controllerBase != null ? controllerBase.getLink(nameOfTheLink) : Optional.empty();
+ link.ifPresentOrElse(
+ l -> log.debug(LOG_PREFIX + "Polling finished. Has {} link: {}", getTenantId(), getControllerId(), nameOfTheLink, l),
+ () -> log.trace(LOG_PREFIX + "Polling finished. No {} link", getTenantId(), getControllerId(), nameOfTheLink));
+ return link;
+ }
+
+ private long getPollMillis(final DdiControllerBase controllerBase) {
+ if (overridePollMillis >= 0) {
+ return overridePollMillis;
+ }
+
+ final String pollingTimeFromResponse = controllerBase.getConfig().getPolling().getSleep();
+ if (pollingTimeFromResponse == null) {
+ return DEFAULT_POLL_MS;
+ } else {
+ final LocalTime localtime = LocalTime.parse(pollingTimeFromResponse);
+ return localtime.getLong(ChronoField.MILLI_OF_DAY);
+ }
+ }
+
+ private Optional> getActionWithDeployment(final Link deploymentBaseLink) {
+ final long actionId = getActionId(deploymentBaseLink);
+ final ResponseEntity action = getDdiApi()
+ .getControllerDeploymentBaseAction(getTenantId(), getControllerId(), actionId, -1, null);
+ if (action.getStatusCode() != HttpStatus.OK) {
+ log.warn(LOG_PREFIX + "Fail to get deployment action: {} -> {}", getTenantId(), getControllerId(), actionId, action.getStatusCode());
+ return Optional.empty();
+ }
+
+ return Optional.ofNullable(action.getBody() == null ? null : new AbstractMap.SimpleEntry<>(actionId, action.getBody()));
+ }
+
+ public void updateAttribute(final String mode, final String key, final String value) {
+ final DdiUpdateMode updateMode = switch (mode.toLowerCase()) {
+ case "replace" -> DdiUpdateMode.REPLACE;
+ case "remove" -> DdiUpdateMode.REMOVE;
+ default -> DdiUpdateMode.MERGE;
+ };
+
+ final DdiConfigData configData = new DdiConfigData(Collections.singletonMap(key, value), updateMode);
+
+ getDdiApi().putConfigData(configData, getTenantId(), getControllerId());
+ }
+
+ private void sendFeedback(final long actionId) {
+ getDdiApi().postDeploymentBaseActionFeedback(updateStatus.feedback(), getTenantId(), getControllerId(), actionId);
+ currentActionId = null;
+ }
+
+ private void sendConfirmationFeedback(final long actionId) {
+ final DdiConfirmationFeedback ddiConfirmationFeedback = new DdiConfirmationFeedback(
+ DdiConfirmationFeedback.Confirmation.CONFIRMED, 0, Collections.singletonList(
+ "the confirmation status for the device is" + DdiConfirmationFeedback.Confirmation.CONFIRMED));
+ getDdiApi().postConfirmationActionFeedback(ddiConfirmationFeedback, getTenantId(), getControllerId(), actionId);
+ }
+
+ private long getActionId(final Link link) {
+ final String href = link.getHref();
+ return Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?')));
+ }
+
+ private class UpdateProcessor implements Runnable {
+
+ private static final String BUT_GOT_LOG_MESSAGE = " but got: ";
+ private static final String DOWNLOAD_LOG_MESSAGE = "Download ";
+ private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6;
+
+ private final long actionId;
+ private final DdiDeployment.HandlingType updateType;
+ private final List modules;
+
+ private UpdateProcessor(
+ final long actionId, final DdiDeployment.HandlingType updateType, final List modules) {
+ this.actionId = actionId;
+ this.updateType = updateType;
+ this.modules = modules;
+ }
+
+ @Override
+ public void run() {
+ updateStatus = new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!"));
+ sendFeedback(actionId);
+
+ if (!CollectionUtils.isEmpty(modules)) {
+ updateStatus = download();
+ sendFeedback(actionId);
+ final UpdateStatus updateStatus = getUpdateStatus();
+ if (updateStatus != null && updateStatus.status() == UpdateStatus.Status.ERROR) {
+ currentActionId = null;
+ return;
+ }
+ }
+
+ if (updateType != DdiDeployment.HandlingType.SKIP) {
+ updateStatus = new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!"));
+ sendFeedback(actionId);
+ currentActionId = null;
+ }
+ }
+
+ private UpdateStatus download() {
+ updateStatus = new UpdateStatus(UpdateStatus.Status.DOWNLOADING,
+ modules.stream().flatMap(mod -> mod.getArtifacts().stream())
+ .map(art -> "Download starts for: " + art.getFilename() + " with SHA1 hash "
+ + art.getHashes().getSha1() + " and size " + art.getSize())
+ .collect(Collectors.toList()));
+ sendFeedback(actionId);
+
+ log.info(LOG_PREFIX + "Start download", getTenantId(), getControllerId());
+
+ final List updateStatusList = new ArrayList<>();
+ modules.forEach(module -> module.getArtifacts().forEach(artifact -> {
+ if (downloadAuthenticationEnabled) {
+ handleArtifact(getTargetSecurityToken(), gatewayToken, updateStatusList, artifact);
+ } else {
+ handleArtifact(null, null, updateStatusList, artifact);
+ }
+ }));
+
+ log.info(LOG_PREFIX + "Download complete", getTenantId(), getControllerId());
+
+ final List messages = new LinkedList<>();
+ messages.add("Download complete!");
+ updateStatusList.forEach(download -> messages.addAll(download.messages()));
+ return new UpdateStatus(
+ updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ?
+ UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED,
+ messages);
+ }
+
+ private void handleArtifact(
+ final String targetToken, final String gatewayToken,
+ final List status, final DdiArtifact artifact) {
+ artifact.getLink("download").ifPresentOrElse(
+ // HTTPS
+ link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken,
+ artifact.getHashes().getSha1(), artifact.getSize()))
+ ,
+ // HTTP
+ () -> status.add(downloadUrl(
+ artifact.getLink("download-http")
+ .map(Link::getHref)
+ .orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")),
+ gatewayToken, targetToken,
+ artifact.getHashes().getSha1(), artifact.getSize()))
+ );
+ }
+
+ private UpdateStatus downloadUrl(
+ final String url, final String gatewayToken, final String targetToken,
+ final String sha1Hash, final long size) {
+ if (log.isDebugEnabled()) {
+ log.debug(LOG_PREFIX + "Downloading {} with token {}, expected sha1 hash {} and size {}", getTenantId(), getControllerId(), url,
+ hideTokenDetails(targetToken), sha1Hash, size);
+ }
+
+ try {
+ return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size);
+ } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
+ log.error(LOG_PREFIX + "Failed to download {}", getTenantId(), getControllerId(), url, e);
+ return new UpdateStatus(UpdateStatus.Status.ERROR, List.of("Failed to download " + url + ": " + e.getMessage()));
+ }
+
+ }
+
+ private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken,
+ final String targetToken, final String sha1Hash, final long size)
+ throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException {
+ long overallread;
+ final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts();
+ final HttpGet request = new HttpGet(url);
+
+ if (StringUtils.hasLength(targetToken)) {
+ request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken);
+ } else if (StringUtils.hasLength(gatewayToken)) {
+ request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken);
+ }
+
+ final String sha1HashResult;
+ try (final CloseableHttpResponse response = httpclient.execute(request)) {
+
+ if (response.getCode() != HttpStatus.OK.value()) {
+ final String message = wrongStatusCode(url, response);
+ return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
+ }
+
+ if (response.getEntity().getContentLength() != size) {
+ final String message = wrongContentLength(url, size, response);
+ return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
+ }
+
+ // Exception squid:S2070 - not used for hashing sensitive
+ // data
+ @SuppressWarnings("squid:S2070")
+ final MessageDigest md = MessageDigest.getInstance("SHA-1");
+
+ overallread = getOverallRead(response, md);
+
+ if (overallread != size) {
+ final String message = incompleteRead(url, size, overallread);
+ return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
+ }
+
+ sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest());
+ }
+
+ if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) {
+ final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult);
+ return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
+ }
+
+ final String message = "Downloaded " + url + " (" + overallread + " bytes)";
+ log.debug(message);
+ return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message));
+ }
+
+ private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md)
+ throws IOException {
+
+ long overallread;
+
+ try (final OutputStream os = ByteStreams.nullOutputStream();
+ final BufferedOutputStream bos = new BufferedOutputStream(new DigestOutputStream(os, md))) {
+
+ try (BufferedInputStream bis = new BufferedInputStream(response.getEntity().getContent())) {
+ overallread = ByteStreams.copy(bis, bos);
+ }
+ }
+
+ return overallread;
+ }
+
+ private static String hideTokenDetails(final String targetToken) {
+ if (targetToken == null) {
+ return "";
+ }
+
+ if (targetToken.isEmpty()) {
+ return "";
+ }
+
+ if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) {
+ return "***";
+ }
+
+ return targetToken.substring(0, 2) + "***"
+ + targetToken.substring(targetToken.length() - 2, targetToken.length());
+ }
+
+ private String wrongHash(final String url, final String sha1Hash, final long overallread,
+ final String sha1HashResult) {
+ final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: "
+ + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)";
+ log.error(message, getTenantId(), getControllerId());
+ return message;
+ }
+
+ private String incompleteRead(final String url, final long size, final long overallread) {
+ final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size
+ + BUT_GOT_LOG_MESSAGE + overallread + ")";
+ log.error(message, getTenantId(), getControllerId());
+ return message;
+ }
+
+ private String wrongContentLength(final String url, final long size,
+ final CloseableHttpResponse response) {
+ final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size
+ + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")";
+ log.error(message, getTenantId(), getControllerId());
+ return message;
+ }
+
+ private String wrongStatusCode(final String url, final CloseableHttpResponse response) {
+ final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getCode() + ")";
+ log.error(message, getTenantId(), getControllerId());
+ return message;
+ }
+
+ private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts()
+ throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
+ return HttpClients
+ .custom()
+ .setConnectionManager(
+ PoolingHttpClientConnectionManagerBuilder.create()
+ .setSSLSocketFactory(
+ new SSLConnectionSocketFactory(
+ SSLContextBuilder
+ .create()
+ .loadTrustMaterial(null, (chain, authType) -> true)
+ .build()))
+ .build()
+ )
+ .build();
+ }
+ }
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java
new file mode 100644
index 0000000000..c3c3ca1369
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2023 Bosch.IO GmbH and others
+ *
+ * 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/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.hawkbit.sdk.device;
+
+import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback;
+import org.eclipse.hawkbit.ddi.json.model.DdiResult;
+import org.eclipse.hawkbit.ddi.json.model.DdiStatus;
+
+import java.util.List;
+
+record UpdateStatus(Status status, List messages) {
+
+ /**
+ * The status to response to the hawkBit update server if an simulated update process should be respond with
+ * successful or failure update.
+ */
+ enum Status {
+
+ /**
+ * Update has been successful and response the successful update.
+ */
+ SUCCESSFUL(DdiStatus.ExecutionStatus.CLOSED, DdiResult.FinalResult.SUCCESS, 200),
+
+ /**
+ * Update has been not successful and response the error update.
+ */
+ ERROR(DdiStatus.ExecutionStatus.CLOSED, DdiResult.FinalResult.FAILURE, null),
+
+ /**
+ * Update is running (intermediate status).
+ */
+ RUNNING(DdiStatus.ExecutionStatus.PROCEEDING, DdiResult.FinalResult.NONE, null),
+
+ /**
+ * Device starts to download.
+ */
+ DOWNLOADING(DdiStatus.ExecutionStatus.DOWNLOAD, DdiResult.FinalResult.NONE, null),
+
+ /**
+ * Device is finished with downloading.
+ */
+ DOWNLOADED(DdiStatus.ExecutionStatus.DOWNLOADED, DdiResult.FinalResult.NONE, null);
+
+ private final DdiStatus.ExecutionStatus executionStatus;
+ private final DdiResult.FinalResult finalResult;
+ private final Integer code;
+
+ Status(final DdiStatus.ExecutionStatus executionStatus, final DdiResult.FinalResult finalResult,
+ final Integer code) {
+ this.executionStatus = executionStatus;
+ this.finalResult = finalResult;
+ this.code = code;
+ }
+ }
+
+ DdiActionFeedback feedback() {
+ return new DdiActionFeedback(null,
+ new DdiStatus(status.executionStatus, new DdiResult(status.finalResult, null), status.code, messages));
+ }
+}
diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties
new file mode 100644
index 0000000000..2abe714817
--- /dev/null
+++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties
@@ -0,0 +1,11 @@
+#
+# Copyright (c) 2023 Bosch.IO GmbH and others
+#
+# 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/
+#
+# SPDX-License-Identifier: EPL-2.0
+#
+
+
diff --git a/hawkbit-sdk/pom.xml b/hawkbit-sdk/pom.xml
new file mode 100644
index 0000000000..1d9328daed
--- /dev/null
+++ b/hawkbit-sdk/pom.xml
@@ -0,0 +1,30 @@
+
+
+ 4.0.0
+
+ org.eclipse.hawkbit
+ hawkbit-parent
+ ${revision}
+
+
+ hawkbit-sdk
+ pom
+
+
+ hawkbit-sdk-commons
+ hawkbit-sdk-device
+ hawkbit-sdk-demo
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index d336420b15..43ed186363 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,6 +131,7 @@
hawkbit-test-report
hawkbit-runtime
hawkbit-starters
+ hawkbit-sdk