From 867219f7baa25d1f53de8b9d201e8660724af2e6 Mon Sep 17 00:00:00 2001 From: Charbull Date: Sun, 17 Feb 2019 17:22:38 -0500 Subject: [PATCH 01/54] init GCP --- hawkbit-device-simulator/pom.xml | 299 +++++++++++------- .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 9 + .../google/gcp/GcpRegistryHandler.java | 137 ++++++++ .../gcp/RetryHttpInitializerWrapper.java | 104 ++++++ .../hawkbit/simulator/DDISimulatedDevice.java | 2 + .../hawkbit/simulator/DMFSimulatedDevice.java | 1 + .../simulator/SimulatedDeviceFactory.java | 9 + .../src/main/resources/.gitignore | 1 + 8 files changed, 447 insertions(+), 115 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java create mode 100644 hawkbit-device-simulator/src/main/resources/.gitignore diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 0e97b58..fc76be5 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -1,122 +1,191 @@ - + + 4.0.0 + + org.eclipse.hawkbit + 0.3.0-SNAPSHOT + hawkbit-examples-parent + - Copyright (c) 2015 Bosch Software Innovations GmbH and others. + hawkbit-device-simulator + hawkBit :: Examples :: Device Simulator + Device Management Federation API based simulator - All rights reserved. This program and the accompanying materials - are made available under the terms of the Eclipse Public License v1.0 - which accompanies this distribution, and is available at - http://www.eclipse.org/legal/epl-v10.html + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + ${baseDir} + org.eclipse.hawkbit.simulator.DeviceSimulator + JAR + + + + + + + + src/main/resources + + + cf + true + ${project.build.directory} + + manifest.yml + + + + ---> - - 4.0.0 - - org.eclipse.hawkbit - 0.3.0-SNAPSHOT - hawkbit-examples-parent - + + + org.eclipse.hawkbit + hawkbit-dmf-api + + + org.eclipse.hawkbit + hawkbit-example-ddi-feign-client + ${project.version} + + + org.springframework.amqp + spring-rabbit + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-commons + + + org.springframework.cloud + spring-cloud-context + + + org.apache.httpcomponents + httpclient + - hawkbit-device-simulator - hawkBit :: Examples :: Device Simulator - Device Management Federation API based simulator - - - - org.springframework.boot - spring-boot-maven-plugin - - - - repackage - - - ${baseDir} - org.eclipse.hawkbit.simulator.DeviceSimulator - JAR - - - - - - - - src/main/resources - - - cf - true - ${project.build.directory} - - manifest.yml - - - - + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.0 + + + org.json + json + 20090211 + + + io.jsonwebtoken + jjwt + 0.7.0 + + + joda-time + joda-time + 2.1 + + + com.google.apis + google-api-services-cloudiot + v1-rev20181120-1.27.0 + + + com.google.cloud + google-cloud-pubsub + 0.24.0-beta + + + com.google.oauth-client + google-oauth-client + 1.23.0 + + + com.google.guava + guava + 23.0 + + + com.google.api-client + google-api-client + 1.23.0 + + + com.google.auth + google-auth-library-appengine + 0.12.0 + + + + com.google.apis + google-api-services-iam + v1-rev267-1.25.0 + + + commons-cli + commons-cli + 1.4 + - - - org.eclipse.hawkbit - hawkbit-dmf-api - - - org.eclipse.hawkbit - hawkbit-example-ddi-feign-client - ${project.version} - - - org.springframework.amqp - spring-rabbit - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-jetty - - - org.springframework.boot - spring-boot-starter-logging - - - org.springframework.security - spring-security-web - - - org.springframework.security - spring-security-config - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.boot - spring-boot-autoconfigure - - - org.springframework.cloud - spring-cloud-commons - - - org.springframework.cloud - spring-cloud-context - - - com.google.guava - guava - - - org.apache.httpcomponents - httpclient - - + + + junit + junit + 4.12 + test + + + com.google.truth + truth + 0.34 + test + + diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java new file mode 100644 index 0000000..66e62bc --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -0,0 +1,9 @@ +package org.eclipse.hawkbit.google.gcp; + +public class GCP_OTA { + + public final static String PROJECT_ID = "ota-iot-231619"; + public final static String CLOUD_REGION = "us-central1"; + + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java new file mode 100644 index 0000000..edd25cd --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java @@ -0,0 +1,137 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.cloudiot.v1.CloudIot; +import com.google.api.services.cloudiot.v1.CloudIotScopes; +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceRegistry; + + + +public class GcpRegistryHandler { + + final static String APP_NAME = "ota-iot-231619"; + + + private static GoogleCredential getCredentialsFromFile() + { + GoogleCredential credential = null; + try { + ClassLoader classLoader = GcpRegistryHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(CloudIotScopes.all()); + } catch (IOException e) { + System.out.println("Please make sure to put your keys.json in the project"); + } + return credential; + } + + + public static void listDevices(String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .setApplicationName(APP_NAME) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + List devices = + service + .projects() + .locations() + .registries() + .devices() + .list(registryPath) + .execute() + .getDevices(); + + if (devices != null) { + System.out.println("Found " + devices.size() + " devices"); + for (Device d : devices) { + System.out.println("Id: " + d.getId()); + if (d.getConfig() != null) { + // Note that this will show the device config in Base64 encoded format. + System.out.println("Config: " + d.getConfig().toPrettyString()); + } + System.out.println(); + } + } else { + System.out.println("Registry has no devices."); + } + } + + /** Lists all of the registries associated with the given project. */ + public static void listRegistries(String projectId, String cloudRegion) + throws GeneralSecurityException, IOException { + + + + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init) + .setApplicationName(APP_NAME).build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + + List registries = + service + .projects() + .locations() + .registries() + .list(projectPath) + .execute() + .getDeviceRegistries(); + + if (registries != null) { + System.out.println("Found " + registries.size() + " registries"); + for (DeviceRegistry r: registries) { + System.out.println("Id: " + r.getId()); + System.out.println("Name: " + r.getName()); + if (r.getMqttConfig() != null) { + System.out.println("Config: " + r.getMqttConfig().toPrettyString()); + } + System.out.println(); + } + } else { + System.out.println("Project has no registries."); + } + } + + + + /** Retrieves registry metadata from a project. **/ + public static DeviceRegistry getRegistry( + String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init) + .setApplicationName(APP_NAME).build(); + + final String registryPath = String.format("projects/%s/locations/%s/registries/%s", + projectId, cloudRegion, registryName); + + return service.projects().locations().registries().get(registryPath).execute(); + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java new file mode 100644 index 0000000..c98c0c2 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java @@ -0,0 +1,104 @@ +/* + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.eclipse.hawkbit.google.gcp; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.util.ExponentialBackOff; +import com.google.api.client.util.Sleeper; +import com.google.common.base.Preconditions; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * RetryHttpInitializerWrapper will automatically retry upon RPC failures, preserving the + * auto-refresh behavior of the Google Credentials. + */ +public class RetryHttpInitializerWrapper implements HttpRequestInitializer { + + /** A private logger. */ + private static final Logger LOG = Logger.getLogger(RetryHttpInitializerWrapper.class.getName()); + + /** One minutes in milliseconds. */ + private static final int ONE_MINUTE_MILLIS = 60 * 1000; + + /** + * Intercepts the request for filling in the "Authorization" header field, as well as recovering + * from certain unsuccessful error codes wherein the Credential must refresh its token for a + * retry. + */ + private final Credential wrappedCredential; + + /** A sleeper; you can replace it with a mock in your test. */ + private final Sleeper sleeper; + + /** + * A constructor. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + */ + public RetryHttpInitializerWrapper(final Credential wrappedCredential) { + this(wrappedCredential, Sleeper.DEFAULT); + } + + /** + * A protected constructor only for testing. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + * @param sleeper Sleeper for easy testing. + */ + RetryHttpInitializerWrapper(final Credential wrappedCredential, final Sleeper sleeper) { + this.wrappedCredential = Preconditions.checkNotNull(wrappedCredential); + this.sleeper = sleeper; + } + + /** Initializes the given request. */ + @Override + public final void initialize(final HttpRequest request) { + request.setReadTimeout(2 * ONE_MINUTE_MILLIS); // 2 minutes read timeout + final HttpUnsuccessfulResponseHandler backoffHandler = + new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff()).setSleeper(sleeper); + request.setInterceptor(wrappedCredential); + request.setUnsuccessfulResponseHandler( + new HttpUnsuccessfulResponseHandler() { + @Override + public boolean handleResponse( + final HttpRequest request, final HttpResponse response, final boolean supportsRetry) + throws IOException { + if (wrappedCredential.handleResponse(request, response, supportsRetry)) { + // If credential decides it can handle it, the return code or message indicated + // something specific to authentication, and no backoff is desired. + return true; + } else if (backoffHandler.handleResponse(request, response, supportsRetry)) { + // Otherwise, we defer to the judgment of our internal backoff handler. + LOG.info("Retrying " + request.getUrl().toString()); + return true; + } else { + return false; + } + } + }); + request.setIOExceptionHandler( + new HttpBackOffIOExceptionHandler(new ExponentialBackOff()).setSleeper(sleeper)); + } +} \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java index f82990f..a6a1347 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java @@ -75,6 +75,8 @@ public DDISimulatedDevice(final String id, final String tenant, final int pollDe this.controllerResource = controllerResource; this.deviceUpdater = deviceUpdater; this.gatewayToken = gatewayToken; + System.out.printf("[DDISimulatedDevice] Id: %s, tenant: %s \n", id, tenant); + } @Override diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java index 7764295..0844c0d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -27,6 +27,7 @@ public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderS final int pollDelaySec) { super(id, tenant, Protocol.DMF_AMQP, pollDelaySec); this.spSenderService = spSenderService; + System.out.printf("[DMFSimulatedDevice] Id: %s, tenant: %s \n", id, tenant); } @Override diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index 67bc4da..8b28d29 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -8,12 +8,16 @@ */ package org.eclipse.hawkbit.simulator; +import java.io.IOException; import java.net.URL; +import java.security.GeneralSecurityException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.eclipse.hawkbit.ddi.client.resource.RootControllerResourceClient; import org.eclipse.hawkbit.feign.core.client.IgnoreMultipleConsumersProducersSpringMvcContract; +import org.eclipse.hawkbit.google.gcp.GCP_OTA; +import org.eclipse.hawkbit.google.gcp.GcpRegistryHandler; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; @@ -75,6 +79,11 @@ private AbstractSimulatedDevice createSimulatedDevice(final String id, final Str final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { switch (protocol) { case DMF_AMQP: + try { + GcpRegistryHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); case DDI_HTTP: return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); diff --git a/hawkbit-device-simulator/src/main/resources/.gitignore b/hawkbit-device-simulator/src/main/resources/.gitignore new file mode 100644 index 0000000..fa8832e --- /dev/null +++ b/hawkbit-device-simulator/src/main/resources/.gitignore @@ -0,0 +1 @@ +/keys.json From 4a543fb1903391ff5c386d585cb4796deba3cea0 Mon Sep 17 00:00:00 2001 From: charbull Date: Sun, 17 Feb 2019 19:11:33 -0500 Subject: [PATCH 02/54] Listing devices --- hawkbit-device-simulator/README.md | 15 +++ .../eclipse/hawkbit/google/gcp/GCP_Init.java | 31 +++++ .../google/gcp/GcpRegistryHandler.java | 113 +++++++++++++++--- .../simulator/SimulatedDeviceFactory.java | 5 - .../simulator/SimulationProperties.java | 2 +- .../hawkbit/simulator/SimulatorStartup.java | 21 ++++ .../src/main/resources/.gitignore | 2 + 7 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index a6cae6c..54b36ae 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -1,3 +1,18 @@ +# hawkBit GCP Device Simulator + + +## First Credentials for GCP + +- Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) + +- Rename the downloaded file to `keys.json` + +- Add it to `src/main/resources` + + + + + # hawkBit Device Simulator The device simulator handles software update commands from the update server. It is designed to be used very conveniently, diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java new file mode 100644 index 0000000..bf517d0 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java @@ -0,0 +1,31 @@ +//package org.eclipse.hawkbit.google.gcp; +// +//import java.io.IOException; +//import java.security.GeneralSecurityException; +// +//import com.google.api.services.cloudiot.v1.model.Device; +//import com.google.api.services.cloudiot.v1.model.DeviceRegistry; +// +//public class GCP_Init { +// +// +// //create a registry with 2 devices +// +// +// private static DeviceRegistry registry; +// +// public static void init(String projectId, String cloudRegion, String registryName) +// { +// try { +// registry = +// GcpRegistryHandler.createRegistry(cloudRegion, projectId, registryName, "HawkBitRegistry"); +// Device charbelk = GcpRegistryHandler.createDeviceWithRs256("charbelDevice", +// "/Users/charbelk/dev/hawkbit-google/hawkbit-examples/hawkbit-device-simulator/src/main/resources/rsa_cert.pem", +// projectId, +// cloudRegion, +// registry.getName()); +// } catch (GeneralSecurityException | IOException e) { +// e.printStackTrace(); +// } +// } +//} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java index edd25cd..a8fe50a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java @@ -1,8 +1,11 @@ package org.eclipse.hawkbit.google.gcp; +import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; @@ -13,15 +16,16 @@ import com.google.api.services.cloudiot.v1.CloudIot; import com.google.api.services.cloudiot.v1.CloudIotScopes; import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceCredential; import com.google.api.services.cloudiot.v1.model.DeviceRegistry; - +import com.google.api.services.cloudiot.v1.model.EventNotificationConfig; +import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; +import com.google.common.base.Charsets; +import com.google.common.io.Files; public class GcpRegistryHandler { - final static String APP_NAME = "ota-iot-231619"; - - private static GoogleCredential getCredentialsFromFile() { GoogleCredential credential = null; @@ -37,15 +41,97 @@ private static GoogleCredential getCredentialsFromFile() } - public static void listDevices(String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { + public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException + { + List allDevices_per_project = new ArrayList(); + List gcp_registries = GcpRegistryHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + for(DeviceRegistry gcp_registry : gcp_registries) + { + allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); + } + return allDevices_per_project; + } + + + /** Create a registry for Cloud IoT. */ + public static DeviceRegistry createRegistry( + String cloudRegion, String projectId, String registryName, String pubsubTopicPath) + throws GeneralSecurityException, IOException { GoogleCredential credential = GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); final CloudIot service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .setApplicationName(APP_NAME) + .build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + final String fullPubsubPath = "projects/" + projectId + "/topics/" + pubsubTopicPath; + + DeviceRegistry registry = new DeviceRegistry(); + EventNotificationConfig notificationConfig = new EventNotificationConfig(); + notificationConfig.setPubsubTopicName(fullPubsubPath); + List notificationConfigs = new ArrayList(); + notificationConfigs.add(notificationConfig); + registry.setEventNotificationConfigs(notificationConfigs); + registry.setId(registryName); + + DeviceRegistry reg = + service.projects().locations().registries().create(projectPath, registry).execute(); + System.out.println("Created registry: " + reg.getName()); + + return reg; + } + + + public static Device createDeviceWithRs256( + String deviceId, + String certificateFilePath, + String projectId, + String cloudRegion, + String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); + String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); + publicKeyCredential.setKey(key); + publicKeyCredential.setFormat("RSA_X509_PEM"); + + DeviceCredential devCredential = new DeviceCredential(); + devCredential.setPublicKey(publicKeyCredential); + + System.out.println("Creating device with id: " + deviceId); + Device device = new Device(); + device.setId(deviceId); + device.setCredentials(Arrays.asList(devCredential)); + Device createdDevice = + service + .projects() + .locations() + .registries() + .devices() + .create(registryPath, device) + .execute(); + + System.out.println("Created device: " + createdDevice.toPrettyString()); + return createdDevice; + } + + public static List listDevices(String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) .build(); final String registryPath = @@ -75,19 +161,17 @@ public static void listDevices(String projectId, String cloudRegion, String regi } else { System.out.println("Registry has no devices."); } + return devices; } /** Lists all of the registries associated with the given project. */ - public static void listRegistries(String projectId, String cloudRegion) + public static List listRegistries(String projectId, String cloudRegion) throws GeneralSecurityException, IOException { - - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init) - .setApplicationName(APP_NAME).build(); + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; @@ -113,6 +197,8 @@ public static void listRegistries(String projectId, String cloudRegion) } else { System.out.println("Project has no registries."); } + return registries; + } @@ -126,8 +212,7 @@ public static DeviceRegistry getRegistry( JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init) - .setApplicationName(APP_NAME).build(); + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); final String registryPath = String.format("projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index 8b28d29..b4eb4cf 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -79,11 +79,6 @@ private AbstractSimulatedDevice createSimulatedDevice(final String id, final Str final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { switch (protocol) { case DMF_AMQP: - try { - GcpRegistryHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); - } catch (GeneralSecurityException | IOException e) { - e.printStackTrace(); - } return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); case DDI_HTTP: return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java index 3d34a36..e6c11c4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java @@ -114,7 +114,7 @@ public static class Autostart { /** * Amount of simulated devices. */ - private int amount = 20; + private int amount = 2; /** * Tenant name for the simulation. diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 58a173d..3e49ac4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -8,9 +8,14 @@ */ package org.eclipse.hawkbit.simulator; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.security.GeneralSecurityException; +import java.util.List; +import org.eclipse.hawkbit.google.gcp.GCP_OTA; +import org.eclipse.hawkbit.google.gcp.GcpRegistryHandler; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,6 +25,9 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceRegistry; + /** * Execution of operations after startup. Set up of simulations. * @@ -43,6 +51,19 @@ public class SimulatorStartup implements ApplicationListener allDevices_gcp = GcpRegistryHandler.getAllDevices(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + for(Device gcp_device : allDevices_gcp) + { + System.out.println("[GCP Device] "+gcp_device.getId()); + } + + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); simulationProperties.getAutostarts().forEach(autostart -> { diff --git a/hawkbit-device-simulator/src/main/resources/.gitignore b/hawkbit-device-simulator/src/main/resources/.gitignore index fa8832e..5de3b5b 100644 --- a/hawkbit-device-simulator/src/main/resources/.gitignore +++ b/hawkbit-device-simulator/src/main/resources/.gitignore @@ -1 +1,3 @@ /keys.json +/rsa_cert.pem +/rsa_private.pem From 5028d92c20fb0807066d45ec085e979d61e7b4c8 Mon Sep 17 00:00:00 2001 From: charbull Date: Tue, 19 Feb 2019 01:17:20 -0500 Subject: [PATCH 03/54] GCP hack --- hawkbit-device-simulator/README.md | 3 + .../hawkbit/google/gcp/GCP_IoTHandler.java | 392 ++++++++++ .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 2 +- .../google/gcp/GcpRegistryHandler.java | 222 ------ .../hawkbit/simulator/DMFSimulatedDevice.java | 3 + .../simulator/DeviceSimulatorUpdater.java | 641 ++++++++-------- .../simulator/SimulatedDeviceFactory.java | 5 +- .../simulator/SimulationController.java | 85 ++- .../hawkbit/simulator/SimulatorStartup.java | 18 - .../simulator/amqp/DmfReceiverService.java | 474 ++++++------ .../simulator/amqp/DmfSenderService.java | 691 ++++++++++-------- .../src/main/resources/logback-spring.xml | 2 +- 12 files changed, 1472 insertions(+), 1066 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 54b36ae..35a286f 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -10,6 +10,9 @@ - Add it to `src/main/resources` +## Get the devices from GCP registry + +- Set the projectId and the cloud region in the GCP_OTA.java diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java new file mode 100644 index 0000000..6419f0f --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -0,0 +1,392 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.cloudiot.v1.CloudIot; +import com.google.api.services.cloudiot.v1.CloudIotScopes; +import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayRequest; +import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayResponse; +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceCredential; +import com.google.api.services.cloudiot.v1.model.DeviceRegistry; +import com.google.api.services.cloudiot.v1.model.EventNotificationConfig; +import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; +import com.google.api.services.cloudiot.v1.model.UnbindDeviceFromGatewayRequest; +import com.google.api.services.cloudiot.v1.model.UnbindDeviceFromGatewayResponse; +import com.google.common.base.Charsets; +import com.google.common.io.Files; +import java.util.Base64; + +public class GCP_IoTHandler { + + private static GoogleCredential getCredentialsFromFile() + { + GoogleCredential credential = null; + try { + ClassLoader classLoader = GCP_IoTHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(CloudIotScopes.all()); + } catch (IOException e) { + System.out.println("Please make sure to put your keys.json in the project"); + } + return credential; + } + + + public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException + { + List allDevices_per_project = new ArrayList(); + List gcp_registries = GCP_IoTHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + for(DeviceRegistry gcp_registry : gcp_registries) + { + allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); + } + return allDevices_per_project; + } + + + /** Create a registry for Cloud IoT. */ + public static DeviceRegistry createRegistry( + String cloudRegion, String projectId, String registryName, String pubsubTopicPath) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + final String fullPubsubPath = "projects/" + projectId + "/topics/" + pubsubTopicPath; + + DeviceRegistry registry = new DeviceRegistry(); + EventNotificationConfig notificationConfig = new EventNotificationConfig(); + notificationConfig.setPubsubTopicName(fullPubsubPath); + List notificationConfigs = new ArrayList(); + notificationConfigs.add(notificationConfig); + registry.setEventNotificationConfigs(notificationConfigs); + registry.setId(registryName); + + DeviceRegistry reg = + service.projects().locations().registries().create(projectPath, registry).execute(); + System.out.println("Created registry: " + reg.getName()); + + return reg; + } + + + public static Device createDeviceWithRs256( + String deviceId, + String certificateFilePath, + String projectId, + String cloudRegion, + String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); + String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); + publicKeyCredential.setKey(key); + publicKeyCredential.setFormat("RSA_X509_PEM"); + + DeviceCredential devCredential = new DeviceCredential(); + devCredential.setPublicKey(publicKeyCredential); + + System.out.println("Creating device with id: " + deviceId); + Device device = new Device(); + device.setId(deviceId); + device.setCredentials(Arrays.asList(devCredential)); + Device createdDevice = + service + .projects() + .locations() + .registries() + .devices() + .create(registryPath, device) + .execute(); + + System.out.println("Created device: " + createdDevice.toPrettyString()); + return createdDevice; + } + + public static List listDevices(String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + List devices = + service + .projects() + .locations() + .registries() + .devices() + .list(registryPath) + .execute() + .getDevices(); + + if (devices != null) { + System.out.println("Found " + devices.size() + " devices"); + for (Device d : devices) { + System.out.println("Id: " + d.getId()); + if (d.getConfig() != null) { + // Note that this will show the device config in Base64 encoded format. + System.out.println("Config: " + d.getConfig().toPrettyString()); + } + System.out.println(); + } + } else { + System.out.println("Registry has no devices."); + } + return devices; + } + + + + /** Create a device to bind to a gateway. */ + public static void createDevice( + String projectId, String cloudRegion, String registryName, String deviceId) + throws GeneralSecurityException, IOException { + // [START create_device] + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + List devices = + service + .projects() + .locations() + .registries() + .devices() + .list(registryPath) + .setFieldMask("config,gatewayConfig") + .execute() + .getDevices(); + + if (devices != null) { + System.out.println("Found " + devices.size() + " devices"); + for (Device d : devices) { + if ((d.getId() != null && d.getId().equals(deviceId)) + || (d.getName() != null && d.getName().equals(deviceId))) { + System.out.println("Device exists, skipping."); + return; + } + } + } + } + + + public static void bindDeviceToGateway( + String projectId, String cloudRegion, String registryName, String deviceId, String gatewayId) + throws GeneralSecurityException, IOException { + // [START bind_device_to_gateway] + createDevice(projectId, cloudRegion, registryName, deviceId); + + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + BindDeviceToGatewayRequest request = new BindDeviceToGatewayRequest(); + request.setDeviceId(deviceId); + request.setGatewayId(gatewayId); + + BindDeviceToGatewayResponse response = + service + .projects() + .locations() + .registries() + .bindDeviceToGateway(registryPath, request) + .execute(); + + System.out.println(String.format("Device bound: %s", response.toPrettyString())); + // [END bind_device_to_gateway] + } + + public static void unbindDeviceFromGateway( + String projectId, String cloudRegion, String registryName, String deviceId, String gatewayId) + throws GeneralSecurityException, IOException { + // [START unbind_device_from_gateway] + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + UnbindDeviceFromGatewayRequest request = new UnbindDeviceFromGatewayRequest(); + request.setDeviceId(deviceId); + request.setGatewayId(gatewayId); + + UnbindDeviceFromGatewayResponse response = + service + .projects() + .locations() + .registries() + .unbindDeviceFromGateway(registryPath, request) + .execute(); + + System.out.println(String.format("Device unbound: %s", response.toPrettyString())); + // [END unbind_device_from_gateway] + } + + public static void attachDeviceToGateway(MqttClient client, String deviceId) + throws MqttException { + // [START attach_device] + final String attachTopic = String.format("/devices/%s/attach", deviceId); + System.out.println(String.format("Attaching: %s", attachTopic)); + String attachPayload = "{}"; + MqttMessage message = new MqttMessage(attachPayload.getBytes()); + message.setQos(1); + client.publish(attachTopic, message); + // [END attach_device] + } + + /** Detaches a bound device from the Gateway. */ + public static void detachDeviceFromGateway(MqttClient client, String deviceId) + throws MqttException { + // [START detach_device] + final String detachTopic = String.format("/devices/%s/detach", deviceId); + System.out.println(String.format("Detaching: %s", detachTopic)); + String attachPayload = "{}"; + MqttMessage message = new MqttMessage(attachPayload.getBytes()); + message.setQos(1); + client.publish(detachTopic, message); + // [END detach_device] + } + + /** Lists all of the registries associated with the given project. */ + public static List listRegistries(String projectId, String cloudRegion) + throws GeneralSecurityException, IOException { + + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + + List registries = + service + .projects() + .locations() + .registries() + .list(projectPath) + .execute() + .getDeviceRegistries(); + + if (registries != null) { + System.out.println("Found " + registries.size() + " registries"); + for (DeviceRegistry r: registries) { + System.out.println("Id: " + r.getId()); + System.out.println("Name: " + r.getName()); + if (r.getMqttConfig() != null) { + System.out.println("Config: " + r.getMqttConfig().toPrettyString()); + } + System.out.println(); + } + } else { + System.out.println("Project has no registries."); + } + return registries; + + } + + public static void sendCommand( + String deviceId, String projectId, String cloudRegion, String registryName, String data) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String devicePath = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + System.out.printf("Sending command to %s\n", devicePath); + + SendCommandToDeviceResponse res = + service + .projects() + .locations() + .registries() + .devices() + .sendCommandToDevice(devicePath, req) + .execute(); + + System.out.println("Command response: " + res.toString()); + } + + /** Retrieves registry metadata from a project. **/ + public static DeviceRegistry getRegistry( + String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String registryPath = String.format("projects/%s/locations/%s/registries/%s", + projectId, cloudRegion, registryName); + + return service.projects().locations().registries().get(registryPath).execute(); + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 66e62bc..eadf01c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -4,6 +4,6 @@ public class GCP_OTA { public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; - + public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java deleted file mode 100644 index a8fe50a..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpRegistryHandler.java +++ /dev/null @@ -1,222 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; -import com.google.api.client.http.HttpRequestInitializer; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.jackson2.JacksonFactory; -import com.google.api.services.cloudiot.v1.CloudIot; -import com.google.api.services.cloudiot.v1.CloudIotScopes; -import com.google.api.services.cloudiot.v1.model.Device; -import com.google.api.services.cloudiot.v1.model.DeviceCredential; -import com.google.api.services.cloudiot.v1.model.DeviceRegistry; -import com.google.api.services.cloudiot.v1.model.EventNotificationConfig; -import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; -import com.google.common.base.Charsets; -import com.google.common.io.Files; - - -public class GcpRegistryHandler { - - private static GoogleCredential getCredentialsFromFile() - { - GoogleCredential credential = null; - try { - ClassLoader classLoader = GcpRegistryHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(CloudIotScopes.all()); - } catch (IOException e) { - System.out.println("Please make sure to put your keys.json in the project"); - } - return credential; - } - - - public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException - { - List allDevices_per_project = new ArrayList(); - List gcp_registries = GcpRegistryHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); - for(DeviceRegistry gcp_registry : gcp_registries) - { - allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); - } - return allDevices_per_project; - } - - - /** Create a registry for Cloud IoT. */ - public static DeviceRegistry createRegistry( - String cloudRegion, String projectId, String registryName, String pubsubTopicPath) - throws GeneralSecurityException, IOException { - GoogleCredential credential = - GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; - final String fullPubsubPath = "projects/" + projectId + "/topics/" + pubsubTopicPath; - - DeviceRegistry registry = new DeviceRegistry(); - EventNotificationConfig notificationConfig = new EventNotificationConfig(); - notificationConfig.setPubsubTopicName(fullPubsubPath); - List notificationConfigs = new ArrayList(); - notificationConfigs.add(notificationConfig); - registry.setEventNotificationConfigs(notificationConfigs); - registry.setId(registryName); - - DeviceRegistry reg = - service.projects().locations().registries().create(projectPath, registry).execute(); - System.out.println("Created registry: " + reg.getName()); - - return reg; - } - - - public static Device createDeviceWithRs256( - String deviceId, - String certificateFilePath, - String projectId, - String cloudRegion, - String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); - String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); - publicKeyCredential.setKey(key); - publicKeyCredential.setFormat("RSA_X509_PEM"); - - DeviceCredential devCredential = new DeviceCredential(); - devCredential.setPublicKey(publicKeyCredential); - - System.out.println("Creating device with id: " + deviceId); - Device device = new Device(); - device.setId(deviceId); - device.setCredentials(Arrays.asList(devCredential)); - Device createdDevice = - service - .projects() - .locations() - .registries() - .devices() - .create(registryPath, device) - .execute(); - - System.out.println("Created device: " + createdDevice.toPrettyString()); - return createdDevice; - } - - public static List listDevices(String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - List devices = - service - .projects() - .locations() - .registries() - .devices() - .list(registryPath) - .execute() - .getDevices(); - - if (devices != null) { - System.out.println("Found " + devices.size() + " devices"); - for (Device d : devices) { - System.out.println("Id: " + d.getId()); - if (d.getConfig() != null) { - // Note that this will show the device config in Base64 encoded format. - System.out.println("Config: " + d.getConfig().toPrettyString()); - } - System.out.println(); - } - } else { - System.out.println("Registry has no devices."); - } - return devices; - } - - /** Lists all of the registries associated with the given project. */ - public static List listRegistries(String projectId, String cloudRegion) - throws GeneralSecurityException, IOException { - - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; - - List registries = - service - .projects() - .locations() - .registries() - .list(projectPath) - .execute() - .getDeviceRegistries(); - - if (registries != null) { - System.out.println("Found " + registries.size() + " registries"); - for (DeviceRegistry r: registries) { - System.out.println("Id: " + r.getId()); - System.out.println("Name: " + r.getName()); - if (r.getMqttConfig() != null) { - System.out.println("Config: " + r.getMqttConfig().toPrettyString()); - } - System.out.println(); - } - } else { - System.out.println("Project has no registries."); - } - return registries; - - } - - - - /** Retrieves registry metadata from a project. **/ - public static DeviceRegistry getRegistry( - String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - GoogleCredential credential = - GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String registryPath = String.format("projects/%s/locations/%s/registries/%s", - projectId, cloudRegion, registryName); - - return service.projects().locations().registries().get(registryPath).execute(); - } -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java index 0844c0d..bbd9772 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -32,11 +32,14 @@ public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderS @Override public void poll() { + System.out.println("[DMFSimulatedDevice] handling event "+super.getTenant()); + spSenderService.createOrUpdateThing(super.getTenant(), super.getId()); } @Override public void updateAttribute(final String mode, final String key, final String value) { + System.out.println("[DMFSimulatedDevice] handling updateAttribute"); final DmfUpdateMode updateMode; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index ee09771..74d278b 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -10,9 +10,13 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.security.DigestOutputStream; +import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.MessageDigest; @@ -32,6 +36,8 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; +import org.eclipse.hawkbit.google.gcp.GCP_OTA; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; @@ -43,6 +49,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import com.google.common.base.Charsets; import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; @@ -52,306 +59,338 @@ @Service public class DeviceSimulatorUpdater { - private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); - - @Autowired - private ScheduledExecutorService threadPool; - - @Autowired - private SimulatedDeviceFactory deviceFactory; - - @Autowired - private DeviceSimulatorRepository repository; - - /** - * Starting an simulated update process of an simulated device. - * - * @param tenant - * the tenant of the device - * @param id - * the ID of the simulated device - * @param modules - * the software module version from the hawkbit update server - * @param targetSecurityToken - * the target security token for download authentication - * @param gatewayToken - * as alternative to target token the gateway token for download - * authentication - * @param callback - * the callback which gets called when the simulated update - * process has been finished - * @param actionType - * indicating whether to download and install or skip - * installation due to maintenance window. - */ - public void startUpdate(final String tenant, final String id, final List modules, - final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, - final EventTopic actionType) { - - AbstractSimulatedDevice device = repository.get(tenant, id); - - // plug and play - non existing device will be auto created - if (device == null) { - device = repository - .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); - } - - device.setTargetSecurityToken(targetSecurityToken); - - threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, - TimeUnit.MILLISECONDS); - } - - private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; - - private final AbstractSimulatedDevice device; - private final UpdaterCallback callback; - private final List modules; - private final String gatewayToken; - - private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, - final List modules, final EventTopic actionType, final String gatewayToken) { - this.device = device; - this.callback = callback; - this.modules = modules; - this.actionType = actionType; - this.gatewayToken = gatewayToken; - } - - @Override - public void run() { - device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); - callback.sendFeedback(device); - - if (!CollectionUtils.isEmpty(modules)) { - device.setUpdateStatus(simulateDownloads()); - callback.sendFeedback(device); - if (isErrorResponse(device.getUpdateStatus())) { - device.clean(); - return; - } - } - - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { - device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); - callback.sendFeedback(device); - device.clean(); - } - } - - private UpdateStatus simulateDownloads() { - - device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); - callback.sendFeedback(device); - - final List status = new ArrayList<>(); - - LOGGER.info("Simulate downloads for {}", device.getId()); - - modules.forEach(module -> module.getArtifacts().forEach( - artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact))); - - final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); - result.getStatusMessages().add("Simulator: Download complete!"); - status.forEach(download -> { - result.getStatusMessages().addAll(download.getStatusMessages()); - if (isErrorResponse(download)) { - result.setResponseStatus(ResponseStatus.ERROR); - } - }); - - LOGGER.info("Download simulations complete for {}", device.getId()); - - return result; - } - - private static boolean isErrorResponse(final UpdateStatus status) { - if (status == null) { - return false; - } - - return ResponseStatus.ERROR.equals(status.getResponseStatus()); - } - - private static void handleArtifact(final String targetToken, final String gatewayToken, - final List status, final DmfArtifact artifact) { - - if (artifact.getUrls().containsKey("HTTPS")) { - status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())); - } else if (artifact.getUrls().containsKey("HTTP")) { - status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())); - } - } - - private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, - final String sha1Hash, final long size) { - - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, - hideTokenDetails(targetToken), sha1Hash, size); - } - - try { - return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); - } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { - LOGGER.error("Failed to download " + url, e); - return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); - } - - } - - private static 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.isEmpty(targetToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); - } else if (!StringUtils.isEmpty(gatewayToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); - } - - final String sha1HashResult; - try (final CloseableHttpResponse response = httpclient.execute(request)) { - - if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { - final String message = wrongStatusCode(url, response); - return new UpdateStatus(ResponseStatus.ERROR, message); - } - - if (response.getEntity().getContentLength() != size) { - final String message = wrongContentLength(url, size, response); - return new UpdateStatus(ResponseStatus.ERROR, 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(ResponseStatus.ERROR, message); - } - - sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); - } - - if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { - final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); - return new UpdateStatus(ResponseStatus.ERROR, message); - } - - final String message = "Downloaded " + url + " (" + overallread + " bytes)"; - LOGGER.debug(message); - return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, - final String sha1HashResult) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " - + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; - LOGGER.error(message); - return message; - } - - private static String incompleteRead(final String url, final long size, final long overallread) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size - + BUT_GOT_LOG_MESSAGE + overallread + ")"; - LOGGER.error(message); - return message; - } - - private static String wrongContentLength(final String url, final long size, - final CloseableHttpResponse response) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size - + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; - LOGGER.error(message); - return message; - } - - private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() - + ")"; - LOGGER.error(message); - return message; - } - - private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { - final SSLContextBuilder builder = SSLContextBuilder.create(); - builder.loadTrustMaterial(null, (chain, authType) -> true); - final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); - return HttpClients.custom().setSSLSocketFactory(sslsf).build(); - } - } - - /** - * Callback interface which is called when the simulated update process has - * been finished and the caller of starting the simulated update process can - * send the result back to the hawkBit update server. - */ - @FunctionalInterface - public interface UpdaterCallback { - /** - * Callback method to indicate that the simulated update process has - * been finished. - * - * @param device - * the device which has been updated - */ - void sendFeedback(AbstractSimulatedDevice device); - } + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); + + @Autowired + private ScheduledExecutorService threadPool; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Starting an simulated update process of an simulated device. + * + * @param tenant + * the tenant of the device + * @param id + * the ID of the simulated device + * @param modules + * the software module version from the hawkbit update server + * @param targetSecurityToken + * the target security token for download authentication + * @param gatewayToken + * as alternative to target token the gateway token for download + * authentication + * @param callback + * the callback which gets called when the simulated update + * process has been finished + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void startUpdate(final String tenant, final String id, final List modules, + final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, + final EventTopic actionType) { + + AbstractSimulatedDevice device = repository.get(tenant, id); + + // plug and play - non existing device will be auto created + if (device == null) { + device = repository + .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); + } + + device.setTargetSecurityToken(targetSecurityToken); + + threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, + TimeUnit.MILLISECONDS); + } + + private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; + + private final AbstractSimulatedDevice device; + private final UpdaterCallback callback; + private final List modules; + private final String gatewayToken; + private static String payload = ""; + + private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, + final List modules, final EventTopic actionType, final String gatewayToken) { + this.device = device; + this.callback = callback; + this.modules = modules; + this.actionType = actionType; + this.gatewayToken = gatewayToken; + } + + @Override + public void run() { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); + callback.sendFeedback(device); + + if (!CollectionUtils.isEmpty(modules)) { + device.setUpdateStatus(simulateDownloads()); + callback.sendFeedback(device); + if (isErrorResponse(device.getUpdateStatus())) { + device.clean(); + return; + } + } + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + System.out.println("[DeviceSimulator] Download & Install"); + device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); + callback.sendFeedback(device); + device.clean(); + } + } + + private UpdateStatus simulateDownloads() { + + device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); + callback.sendFeedback(device); + + final List status = new ArrayList<>(); + + LOGGER.info("Simulate downloads for {}", device.getId()); + System.out.printf("Simulate downloads for {}", device.getId()); + + modules.forEach(module -> module.getArtifacts().forEach( + artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact))); + + if(device.getId().contains("Charbel") || device.getId().contains("GCP")) + { + try { + System.out.println("==========> Attempting download to the device \n"+payload); + GCP_IoTHandler.sendCommand(device.getId(), GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME, "This is a payload from HawkBit\n"+payload); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); + result.getStatusMessages().add("Simulator: Download complete!"); + status.forEach(download -> { + result.getStatusMessages().addAll(download.getStatusMessages()); + if (isErrorResponse(download)) { + result.setResponseStatus(ResponseStatus.ERROR); + } + }); + + LOGGER.info("Download simulations complete for {}", device.getId()); + + return result; + } + + private static boolean isErrorResponse(final UpdateStatus status) { + if (status == null) { + return false; + } + + return ResponseStatus.ERROR.equals(status.getResponseStatus()); + } + + private static void handleArtifact(final String targetToken, final String gatewayToken, + final List status, final DmfArtifact artifact) { + + System.out.println("[DeviceSimulator] handleArtifact "+artifact.getSize()); + if (artifact.getUrls().containsKey("HTTPS")) { + status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } else if (artifact.getUrls().containsKey("HTTP")) { + status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } + } + + private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, + final String sha1Hash, final long size) { + System.out.println("[DeviceSimulator] downloadingUrl "+url); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, + hideTokenDetails(targetToken), sha1Hash, size); + } + + try { + return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); + } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + LOGGER.error("Failed to download " + url, e); + return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); + } + + } + + private static 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.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } else if (!StringUtils.isEmpty(gatewayToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); + } + + final String sha1HashResult; + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + final String message = wrongStatusCode(url, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + if (response.getEntity().getContentLength() != size) { + final String message = wrongContentLength(url, size, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + // Exception squid:S2070 - not used for hashing sensitive + // data + @SuppressWarnings("squid:S2070") + final MessageDigest md = MessageDigest.getInstance("SHA-1"); + + //overallread = getOverallRead(response, md); + payload = getPayload(response, md); + +// if (overallread != size) { +// final String message = incompleteRead(url, size, overallread); +// return new UpdateStatus(ResponseStatus.ERROR, message); +// } +// +// sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); + } + +// if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { +// final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); +// return new UpdateStatus(ResponseStatus.ERROR, message); +// } + + final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; + System.out.println("[DeviceSimulator] "+message); + LOGGER.debug(message); + return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 getPayload(final CloseableHttpResponse response, final MessageDigest md) + throws IOException { + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + System.out.println("Payload ==========> "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + + 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, + final String sha1HashResult) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " + + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; + LOGGER.error(message); + return message; + } + + private static String incompleteRead(final String url, final long size, final long overallread) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size + + BUT_GOT_LOG_MESSAGE + overallread + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongContentLength(final String url, final long size, + final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size + + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() + + ")"; + LOGGER.error(message); + return message; + } + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + } + + /** + * Callback interface which is called when the simulated update process has + * been finished and the caller of starting the simulated update process can + * send the result back to the hawkBit update server. + */ + @FunctionalInterface + public interface UpdaterCallback { + /** + * Callback method to indicate that the simulated update process has + * been finished. + * + * @param device + * the device which has been updated + */ + void sendFeedback(AbstractSimulatedDevice device); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index b4eb4cf..67ab1d4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -8,16 +8,12 @@ */ package org.eclipse.hawkbit.simulator; -import java.io.IOException; import java.net.URL; -import java.security.GeneralSecurityException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.eclipse.hawkbit.ddi.client.resource.RootControllerResourceClient; import org.eclipse.hawkbit.feign.core.client.IgnoreMultipleConsumersProducersSpringMvcContract; -import org.eclipse.hawkbit.google.gcp.GCP_OTA; -import org.eclipse.hawkbit.google.gcp.GcpRegistryHandler; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; @@ -79,6 +75,7 @@ private AbstractSimulatedDevice createSimulatedDevice(final String id, final Str final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { switch (protocol) { case DMF_AMQP: + System.out.println("Creating DMF device "+id); return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); case DDI_HTTP: return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index c660fe4..00ff656 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -8,6 +8,14 @@ */ package org.eclipse.hawkbit.simulator; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; +import org.eclipse.hawkbit.google.gcp.GCP_OTA; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.springframework.beans.factory.annotation.Autowired; @@ -16,8 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.net.MalformedURLException; -import java.net.URL; +import com.google.api.services.cloudiot.v1.model.Device; /** * REST endpoint for controlling the device simulator. @@ -41,6 +48,80 @@ public SimulationController(final DeviceSimulatorRepository repository, final Si this.simulationProperties = simulationProperties; } + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/gcp") + ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "1") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + + try { + List allDevices_gcp = GCP_IoTHandler.getAllDevices(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + for(Device gcp_device : allDevices_gcp) + { + System.out.println("[GCP Device] "+gcp_device.getId()); + repository.add(deviceFactory. + createSimulatedDevice(gcp_device.getId(), + simulationProperties.getDefaultTenant(), + protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + + } + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + + /** * The start resource to start a device creation. * diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 3e49ac4..da52780 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -8,14 +8,9 @@ */ package org.eclipse.hawkbit.simulator; -import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.security.GeneralSecurityException; -import java.util.List; -import org.eclipse.hawkbit.google.gcp.GCP_OTA; -import org.eclipse.hawkbit.google.gcp.GcpRegistryHandler; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,9 +20,6 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; -import com.google.api.services.cloudiot.v1.model.Device; -import com.google.api.services.cloudiot.v1.model.DeviceRegistry; - /** * Execution of operations after startup. Set up of simulations. * @@ -54,16 +46,6 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { System.out.println("AutoStarting application ..."); LOGGER.debug("{} autostarts will be executed connecting to GCP"); - try { - List allDevices_gcp = GcpRegistryHandler.getAllDevices(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); - for(Device gcp_device : allDevices_gcp) - { - System.out.println("[GCP Device] "+gcp_device.getId()); - } - - } catch (GeneralSecurityException | IOException e) { - e.printStackTrace(); - } LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); simulationProperties.getAutostarts().forEach(autostart -> { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 17c8193..43de0c9 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -39,212 +39,270 @@ */ public class DmfReceiverService extends MessageService { - private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); - - private final DmfSenderService spSenderService; - - private final DeviceSimulatorUpdater deviceUpdater; - - private final DeviceSimulatorRepository repository; - - private final Set openPings = new ConcurrentHashSet<>(); - - /** - * Constructor. - * - * @param rabbitTemplate - * for sending messages - * @param amqpProperties - * for amqp configuration - * @param spSenderService - * to send messages - * @param deviceUpdater - * simulator service for updates - * @param repository - * to manage simulated devices - */ - DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, - final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, - final DeviceSimulatorRepository repository) { - super(rabbitTemplate, amqpProperties); - this.spSenderService = spSenderService; - this.deviceUpdater = deviceUpdater; - this.repository = repository; - } - - /** - * Method to validate if content type is set in the message properties. - * - * @param message - * the message to get validated - */ - private void checkContentTypeJson(final Message message) { - if (message.getBody().length == 0) { - return; - } - final MessageProperties messageProperties = message.getMessageProperties(); - final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); - if (null != headerContentType) { - messageProperties.setContentType(headerContentType); - } - final String contentType = messageProperties.getContentType(); - if (contentType != null && contentType.contains("json")) { - return; - } - throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); - } - - /** - * Handle the incoming Message from Queue with the property - * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). - * - * @param message - * the incoming message - * @param type - * the action type - * @param thingId - * the thing id in message header - * @param tenant - * the device belongs to - */ - @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") - public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, - @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, - @Header(MessageHeaderKey.TENANT) final String tenant) { - final MessageType messageType = MessageType.valueOf(type); - - if (MessageType.EVENT.equals(messageType)) { - checkContentTypeJson(message); - handleEventMessage(message, thingId); - return; - } - - if (MessageType.THING_DELETED.equals(messageType)) { - checkContentTypeJson(message); - repository.remove(tenant, thingId); - return; - } - - if (MessageType.PING_RESPONSE.equals(messageType)) { - final String correlationId = new String(message.getMessageProperties().getCorrelationId(), - StandardCharsets.UTF_8); - if (!openPings.remove(correlationId)) { - LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); - } - - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, - correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); - } - - return; - } - - LOGGER.info("No valid message type property."); - } - - @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) - void checkDmfHealth() { - if (!amqpProperties.isCheckDmfHealth()) { - return; - } - - if (openPings.size() > 5) { - LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); - } else { - LOGGER.debug("Currently {} open pings", openPings.size()); - } - - repository.getTenants().forEach(tenant -> { - final String correlationId = UUID.randomUUID().toString(); - spSenderService.ping(tenant, correlationId); - openPings.add(correlationId); - LOGGER.debug("Ping tenant {} with correlationId {}", tenant, correlationId); - }); - } - - private void handleEventMessage(final Message message, final String thingId) { - final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); - if (eventHeader == null) { - logAndThrowMessageError(message, "Event Topic is not set"); - } - // Exception squid:S2259 - Checked before - @SuppressWarnings({ "squid:S2259" }) - final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); - switch (eventTopic) { - case DOWNLOAD_AND_INSTALL: - case DOWNLOAD: - handleUpdateProcess(message, thingId, eventTopic); - break; - case CANCEL_DOWNLOAD: - handleCancelDownloadAction(message, thingId); - break; - case REQUEST_ATTRIBUTES_UPDATE: - handleAttributeUpdateRequest(message, thingId); - break; - default: - LOGGER.info("No valid event property."); - break; - } - } - - private void handleAttributeUpdateRequest(final Message message, final String thingId) { - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - - spSenderService.updateAttributesOfThing(tenant, thingId); - } - - private void handleCancelDownloadAction(final Message message, final String thingId) { - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - final Long actionId = convertMessage(message, Long.class); - - final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); - spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); - } - - private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - - final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, - DmfDownloadAndUpdateRequest.class); - final Long actionId = downloadAndUpdateRequest.getActionId(); - final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); - - deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, - null, device -> sendFeedback(actionId, device), actionType); - } - - private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { - switch (device.getUpdateStatus().getResponseStatus()) { - case SUCCESSFUL: - spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), - device.getUpdateStatus().getStatusMessages()); - break; - case ERROR: - spSenderService.finishUpdateProcessWithError( - new SimulatedUpdate(device.getTenant(), device.getId(), actionId), - device.getUpdateStatus().getStatusMessages()); - break; - case DOWNLOADING: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - case DOWNLOADED: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - case RUNNING: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - default: - break; - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); + + private final DmfSenderService spSenderService; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final DeviceSimulatorRepository repository; + + private final Set openPings = new ConcurrentHashSet<>(); + + /** + * Constructor. + * + * @param rabbitTemplate + * for sending messages + * @param amqpProperties + * for amqp configuration + * @param spSenderService + * to send messages + * @param deviceUpdater + * simulator service for updates + * @param repository + * to manage simulated devices + */ + DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + super(rabbitTemplate, amqpProperties); + this.spSenderService = spSenderService; + this.deviceUpdater = deviceUpdater; + this.repository = repository; + System.out.println("[DmfReceiverService] Init"); + } + + /** + * Method to validate if content type is set in the message properties. + * + * @param message + * the message to get validated + */ + private void checkContentTypeJson(final Message message) { + System.out.println("[DmfReceiverService] checkJson "+message.getBody()); + if (message.getBody().length == 0) { + return; + } + final MessageProperties messageProperties = message.getMessageProperties(); + final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); + if (null != headerContentType) { + messageProperties.setContentType(headerContentType); + } + final String contentType = messageProperties.getContentType(); + if (contentType != null && contentType.contains("json")) { + return; + } + throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); + } + + /** + * Handle the incoming Message from Queue with the property + * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). + * + * @param message + * the incoming message + * @param type + * the action type + * @param thingId + * the thing id in message header + * @param tenant + * the device belongs to + */ + @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") + public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, + @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, + @Header(MessageHeaderKey.TENANT) final String tenant) { + + try { + + final MessageType messageType = MessageType.valueOf(type); + + System.out.println("[DmfReceiverService] Message received "+toStringMessage(message)); + + if (MessageType.EVENT.equals(messageType)) { + checkContentTypeJson(message); + handleEventMessage(message, thingId); + return; + } + + if (MessageType.THING_DELETED.equals(messageType)) { + checkContentTypeJson(message); + repository.remove(tenant, thingId); + return; + } + + if (MessageType.PING_RESPONSE.equals(messageType)) { + final String correlationId = new String(message.getMessageProperties().getCorrelationId(), + StandardCharsets.UTF_8); + if (!openPings.remove(correlationId)) { + LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, + correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); + } + + return; + } + + LOGGER.info("No valid message type property."); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + + String toStringMessage(Message m) + { + StringBuilder sb = new StringBuilder("Message content:\n"); + MessageProperties prop = m.getMessageProperties(); +// sb.append("-AppId: ").append(prop.getAppId()). +// append("\n-MessageId: ").append(prop.getMessageId()). +// append("\n-Type: ").append(prop.getType()). +// append("\n-ClutsterId: ").append(prop.getClusterId()). +// append("\n-ConsumerQueue: ").append(prop.getConsumerQueue()). +// append("\n-ContentType: ").append(prop.getContentType()). +// append("\n-CorrelationString: ").append(prop.getCorrelationIdString()). +// append("\n-ConsumerTag: ").append(prop.getConsumerTag()). +// append("\n-DeliveryTag: ").append(prop.getDeliveryTag()). +// append("\n-TimeStamp: ").append(prop.getTimestamp()). +// append("\n-ReceivedExchange: ").append(prop.getReceivedExchange()). +// append("\n-UserId: ").append(prop.getUserId()). + //append("\n-DeliveryMode: ").append(prop.getDeliveryMode().name()). + sb.append("\n-RoutingKey: ").append(prop.getReceivedRoutingKey()); + + prop.getHeaders().entrySet().stream().forEach( item -> + { + sb.append("\n--").append(item).append(":").append(prop.getHeaders().get(item)); + }); + + + return sb.toString(); + } + + @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) + void checkDmfHealth() { + System.out.println("[DmfReceiverService] Message CheckDmfHealth "); + + if (!amqpProperties.isCheckDmfHealth()) { + return; + } + + if (openPings.size() > 5) { + LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); + } else { + LOGGER.debug("Currently {} open pings", openPings.size()); + } + + repository.getTenants().forEach(tenant -> { + final String correlationId = UUID.randomUUID().toString(); + spSenderService.ping(tenant, correlationId); + openPings.add(correlationId); + LOGGER.debug("Ping tenant {} with correlationId {}", tenant, correlationId); + }); + } + + private void handleEventMessage(final Message message, final String thingId) { + + System.out.println("[DmfReceiverService] handling event "+thingId); + + final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); + if (eventHeader == null) { + logAndThrowMessageError(message, "Event Topic is not set"); + } + + // Exception squid:S2259 - Checked before + @SuppressWarnings({ "squid:S2259" }) + final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); + System.out.println("[DmfReceiverService] EventTopic "+eventTopic); + + switch (eventTopic) { + case DOWNLOAD_AND_INSTALL: + case DOWNLOAD: + System.out.println("[DmfReceiverService] ===============> Download"); + System.out.println(toStringMessage(message)); + handleUpdateProcess(message, thingId, eventTopic); + break; + case CANCEL_DOWNLOAD: + handleCancelDownloadAction(message, thingId); + break; + case REQUEST_ATTRIBUTES_UPDATE: + handleAttributeUpdateRequest(message, thingId); + break; + default: + LOGGER.info("No valid event property."); + break; + } + } + + private void handleAttributeUpdateRequest(final Message message, final String thingId) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + System.out.println("[DmfReceiverService] handleAttributeUpdateRequest event "+thingId); + System.out.println(toStringMessage(message)); + spSenderService.updateAttributesOfThing(tenant, thingId); + } + + private void handleCancelDownloadAction(final Message message, final String thingId) { + System.out.println("[DmfReceiverService] handling Cancel/Download Action "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + final Long actionId = convertMessage(message, Long.class); + + final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); + spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + } + + private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { + System.out.println("[DmfReceiverService] handling update "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + + final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, + DmfDownloadAndUpdateRequest.class); + final Long actionId = downloadAndUpdateRequest.getActionId(); + final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); + System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, + null, device -> sendFeedback(actionId, device), actionType); + } + + private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { + System.out.println("[DmfReceiverService] sendFeedback event "+device.getId()); + + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case ERROR: + spSenderService.finishUpdateProcessWithError( + new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case DOWNLOADING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case DOWNLOADED: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case RUNNING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + default: + break; + } + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index 4c45354..6964732 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.simulator.amqp; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; @@ -37,313 +36,387 @@ */ public class DmfSenderService extends MessageService { - private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); - - private final String spExchange; - - private final SimulationProperties simulationProperties; - - /** - * - * @param rabbitTemplate - * the rabbit template - * @param amqpProperties - * the amqp properties - * @param simulationProperties - * for attributes update class - */ - DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, - final SimulationProperties simulationProperties) { - super(rabbitTemplate, amqpProperties); - spExchange = AmqpSettings.DMF_EXCHANGE; - this.simulationProperties = simulationProperties; - } - - public void ping(final String tenant, final String correlationId) { - final MessageProperties messageProperties = new MessageProperties(); - messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); - messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); - messageProperties.setCorrelationId(correlationId.getBytes()); - messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); - messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); - - sendMessage(spExchange, new Message(null, messageProperties)); - } - - /** - * Finish the update process. This will send a action status to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * a description according the update process - * @param actionType - * indicating whether to download and install or skip - * installation due to maintenance window. - */ - public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { - final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, - updateResultMessages); - sendMessage(spExchange, updateResultMessage); - } - - /** - * Finish update process with error and send error to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * list of messages for error - */ - public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { - sendErrorgMessage(update, updateResultMessages); - LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, - update.getThingId()); - } - - /** - * Send a message if the message is not null. - * - * @param address - * the exchange name - * @param message - * the amqp message which will be send if its not null - */ - public void sendMessage(final String address, final Message message) { - if (message == null) { - return; - } - message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); - - final String correlationId = UUID.randomUUID().toString(); - - if (isCorrelationIdEmpty(message)) { - message.getMessageProperties().setCorrelationId(correlationId.getBytes(StandardCharsets.UTF_8)); - } - - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); - } else { - LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); - } - - rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); - } - - private static boolean isCorrelationIdEmpty(final Message message) { - return message.getMessageProperties().getCorrelationId() == null - || message.getMessageProperties().getCorrelationId().length <= 0; - } - - /** - * Convert object and message properties to message. - * - * @param object - * to get converted - * @param messageProperties - * to get converted - * @return converted message - */ - public Message convertMessage(final Object object, final MessageProperties messageProperties) { - return rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); - } - - /** - * Send an error message to SP. - * - * @param tenant - * the tenant - * @param updateResultMessages - * the error message description to send - * @param actionId - * the ID of the action for the error message - */ - public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { - final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, - actionId); - sendMessage(spExchange, message); - } - - /** - * Send a warning message to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * a warning description - */ - public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { - final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); - sendMessage(spExchange, message); - } - - /** - * Method to send a action status to SP. - * - * @param tenant - * the tenant - * @param actionStatus - * the action status - * @param updateResultMessages - * the message to get send - * @param actionId - * the cached value - */ - public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, - final List updateResultMessages, final Long actionId) { - final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); - sendMessage(message); - - } - - /** - * Create new thing created message and send to update server. - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - */ - public void createOrUpdateThing(final String tenant, final String targetId) { - sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); - - LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); - } - - /** - * Create new attribute update message and send to update server. - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - */ - public void updateAttributesOfThing(final String tenant, final String targetId) { - sendMessage(spExchange, updateAttributes(tenant, targetId, DmfUpdateMode.MERGE, - simulationProperties.getAttributes().stream().collect(Collectors - .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); - - LOGGER.debug("Create update attributes message and send to update server for Thing \"{}\"", targetId); - } - - /** - * Create new attribute update message for specific attribute and send to - * update server - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - * @param mode - * the update mode ('merge', 'replace', or 'remove') - * @param key - * the key of the attribute - * @param value - * the value of the attribute - */ - public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, - final String key, final String value) { - sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); - } - - private Message thingCreatedMessage(final String tenant, final String targetId) { - final MessageProperties messagePropertiesForSP = new MessageProperties(); - messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); - messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); - messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); - messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); - messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); - messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); - return new Message(null, messagePropertiesForSP); - } - - private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { - final MessageProperties messagePropertiesForSP = new MessageProperties(); - messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); - messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); - messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); - messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); - messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); - return messagePropertiesForSP; - } - - private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, - final Map attributes) { - final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); - final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); - attributeUpdate.setMode(mode); - attributeUpdate.getAttributes().putAll(attributes); - - return convertMessage(attributeUpdate, messagePropertiesForSP); - } - - /** - * Send a created message to SP. - * - * @param message - * the message to get send - */ - private void sendMessage(final Message message) { - sendMessage(spExchange, message); - } - - /** - * Send error message to SP. - * - * @param context - * the current context - * @param updateResultMessages - * a list of descriptions according the update process - */ - private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { - final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); - sendMessage(spExchange, message); - } - - /** - * Create a action status message. - * - * @param actionStatus - * the ActionStatus - * @param actionMessage - * the message description - * @param actionId - * the action id - * @param cacheValue - * the cacheValue value - */ - private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, - final List updateResultMessages, final Long actionId) { - final MessageProperties messageProperties = new MessageProperties(); - final Map headers = messageProperties.getHeaders(); - final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); - headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - headers.put(MessageHeaderKey.TENANT, tenant); - headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); - headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); - actionUpdateStatus.addMessage(updateResultMessages); - - return convertMessage(actionUpdateStatus, messageProperties); - } - - private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, - final List updateResultMessages) { - final MessageProperties messageProperties = new MessageProperties(); - final Map headers = messageProperties.getHeaders(); - final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), - actionStatus); - headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); - headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); - headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); - actionUpdateStatus.addMessage(updateResultMessages); - return convertMessage(actionUpdateStatus, messageProperties); - } - - private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, - final DmfActionStatus status) { - return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); - } + private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); + + private final String spExchange; + + private final SimulationProperties simulationProperties; + + /** + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param simulationProperties + * for attributes update class + */ + DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + super(rabbitTemplate, amqpProperties); + spExchange = AmqpSettings.DMF_EXCHANGE; + this.simulationProperties = simulationProperties; + System.out.println("[DmfSenderService] init"); + } + + public void ping(final String tenant, final String correlationId) { + System.out.println("[DmfSenderService] ping with correlationId "+correlationId); + + final MessageProperties messageProperties = new MessageProperties(); + messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); + messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); + messageProperties.setCorrelationIdString(correlationId); + messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); + messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); + + sendMessage(spExchange, new Message(null, messageProperties)); + } + + /** + * Finish the update process. This will send a action status to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a description according the update process + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { System.out.println("[DmfSenderService] init"); + System.out.println("[DmfSenderService] Update Process"); + final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, + updateResultMessages); + sendMessage(spExchange, updateResultMessage); + } + + /** + * Finish update process with error and send error to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * list of messages for error + */ + public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] update error"); + sendErrorgMessage(update, updateResultMessages); + LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, + update.getThingId()); + } + + /** + * Send a message if the message is not null. + * + * @param address + * the exchange name + * @param message + * the amqp message which will be send if its not null + */ + public void sendMessage(final String address, final Message message) { + System.out.println("[DmfSenderService] send message "+toStringMessage(message)); + + if (message == null) { + return; + } + message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); + + final String correlationId = UUID.randomUUID().toString(); + + if (isCorrelationIdEmpty(message)) { + message.getMessageProperties().setCorrelationIdString(correlationId); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); + } else { + LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); + } + + rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); + } + + private static boolean isCorrelationIdEmpty(final Message message) { + System.out.println("[DmfSenderService] coorelation"); + + return message.getMessageProperties().getCorrelationIdString() == null + || message.getMessageProperties().getCorrelationIdString().length() <= 0; + } + + /** + * Convert object and message properties to message. + * + * @param object + * to get converted + * @param messageProperties + * to get converted + * @return converted message + */ + public Message convertMessage(final Object object, final MessageProperties messageProperties) { + System.out.println("[DmfSenderService] convert Message"); + + + Message m = rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); + System.out.println("Converted Message "+toStringMessage(m)); + return m; + } + + /** + * Send an error message to SP. + * + * @param tenant + * the tenant + * @param updateResultMessages + * the error message description to send + * @param actionId + * the ID of the action for the error message + */ + public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { + + + final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, + actionId); + System.out.println("[DmfSenderService] send error message "); + + sendMessage(spExchange, message); + } + + /** + * Send a warning message to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a warning description + */ + public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); + System.out.println("[DmfSenderService] warning message "); + + sendMessage(spExchange, message); + } + + /** + * Method to send a action status to SP. + * + * @param tenant + * the tenant + * @param actionStatus + * the action status + * @param updateResultMessages + * the message to get send + * @param actionId + * the cached value + */ + public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] send action message"); + + final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); + sendMessage(message); + + } + + /** + * Create new thing created message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void createOrUpdateThing(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] create/update "); + + sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); + + LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void updateAttributesOfThing(final String tenant, final String targetId) { + System.out.printf("Create update attributes message and send to update server for Thing \"{}\"", targetId); + sendMessage(spExchange, updateAttributes(tenant, targetId, DmfUpdateMode.MERGE, + simulationProperties.getAttributes().stream().collect(Collectors + .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); + + LOGGER.info("Create update attributes message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message for specific attribute and send to + * update server + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + * @param mode + * the update mode ('merge', 'replace', or 'remove') + * @param key + * the key of the attribute + * @param value + * the value of the attribute + */ + public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, + final String key, final String value) { + System.out.println("[DmfSenderService] updateAttributesOfThing"); + + sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); + } + + private Message thingCreatedMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] thingCreatedMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return new Message(null, messagePropertiesForSP); + } + + private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] createAttributeUpdateMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return messagePropertiesForSP; + } + + private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, + final Map attributes) { + System.out.println("[DmfSenderService] AttributeUpdateMessage"); + + final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); + final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); + attributeUpdate.setMode(mode); + attributeUpdate.getAttributes().putAll(attributes); + + Message m = convertMessage(attributeUpdate, messagePropertiesForSP); + System.out.println("Converted Message "+toStringMessage(m)); + return m; + } + + String toStringMessage(Message m) + { + StringBuilder sb = new StringBuilder("Message content:\n"); + MessageProperties prop = m.getMessageProperties(); +// sb.append("-AppId: ").append(prop.getAppId()). +// append("\n-MessageId: ").append(prop.getMessageId()). +// append("\n-Type: ").append(prop.getType()). +// append("\n-ClutsterId: ").append(prop.getClusterId()). +// append("\n-ConsumerQueue: ").append(prop.getConsumerQueue()). +// append("\n-ContentType: ").append(prop.getContentType()). +// append("\n-CorrelationString: ").append(prop.getCorrelationIdString()). +// append("\n-ConsumerTag: ").append(prop.getConsumerTag()). +// append("\n-DeliveryTag: ").append(prop.getDeliveryTag()). +// append("\n-TimeStamp: ").append(prop.getTimestamp()). +// append("\n-ReceivedExchange: ").append(prop.getReceivedExchange()). +// append("\n-UserId: ").append(prop.getUserId()). +// append("\n-DeliveryMode: ").append(prop.getDeliveryMode().name()). + sb.append("\n-RoutingKey: ").append(prop.getReceivedRoutingKey()); + + prop.getHeaders().entrySet().stream().forEach( item -> + { + sb.append("\n--").append(item).append(":").append(prop.getHeaders().get(item)); + }); + + + return sb.toString(); + } + + /** + * Send a created message to SP. + * + * @param message + * the message to get send + */ + private void sendMessage(final Message message) { + LOGGER.info("[DmfSenderService] sending "+toStringMessage(message)); + + sendMessage(spExchange, message); + } + + /** + * Send error message to SP. + * + * @param context + * the current context + * @param updateResultMessages + * a list of descriptions according the update process + */ + private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] error"); + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); + sendMessage(spExchange, message); + } + + /** + * Create a action status message. + * + * @param actionStatus + * the ActionStatus + * @param actionMessage + * the message description + * @param actionId + * the action id + * @param cacheValue + * the cacheValue value + */ + private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, tenant); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, + final List updateResultMessages) { + System.out.println("[DmfSenderService] createUpdateResultMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), + actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, + final DmfActionStatus status) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); + } } diff --git a/hawkbit-device-simulator/src/main/resources/logback-spring.xml b/hawkbit-device-simulator/src/main/resources/logback-spring.xml index 9403ee4..4a77556 100644 --- a/hawkbit-device-simulator/src/main/resources/logback-spring.xml +++ b/hawkbit-device-simulator/src/main/resources/logback-spring.xml @@ -12,7 +12,7 @@ - + From adab692a91cea485cabc8d3ff583f6d163a13139 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 20 Feb 2019 18:23:13 -0500 Subject: [PATCH 04/54] app engine nature --- hawkbit-device-simulator/pom.xml | 8 ++++++++ hawkbit-device-simulator/src/main/appengine/app.yaml | 8 ++++++++ .../eclipse/hawkbit/simulator/SimulationController.java | 2 +- .../hawkbit/simulator/amqp/DmfReceiverService.java | 3 +-- .../src/main/webapp/WEB-INF/appengine-web.xml | 4 ++++ 5 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/appengine/app.yaml create mode 100644 hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index fc76be5..097d48f 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -35,6 +35,14 @@ + + com.google.cloud.tools + appengine-maven-plugin + 1.3.2 + + 1 + + diff --git a/hawkbit-device-simulator/src/main/appengine/app.yaml b/hawkbit-device-simulator/src/main/appengine/app.yaml new file mode 100644 index 0000000..c4eabc0 --- /dev/null +++ b/hawkbit-device-simulator/src/main/appengine/app.yaml @@ -0,0 +1,8 @@ +runtime: java +runtime_config: + jdk: openjdk8 +env: flex + +handlers: +- url: /.* + script: this field is required, but ignored \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index 00ff656..5faaba2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -231,7 +231,7 @@ ResponseEntity update(@RequestParam(value = "tenant", required = false) * if not found. */ @GetMapping("/remove") - ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, @RequestParam(value = "controllerid") final String controllerId) { final AbstractSimulatedDevice controller = repository diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 43de0c9..c704dae 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -133,8 +133,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP } if (MessageType.PING_RESPONSE.equals(messageType)) { - final String correlationId = new String(message.getMessageProperties().getCorrelationId(), - StandardCharsets.UTF_8); + final String correlationId = message.getMessageProperties().getCorrelationIdString(); if (!openPings.remove(correlationId)) { LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); } diff --git a/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml b/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 0000000..fa7858a --- /dev/null +++ b/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,4 @@ + + true + java8 + \ No newline at end of file From a64c912c0307ab3e75c6e5eb6788fb6b4ddeaad3 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 20 Feb 2019 18:23:28 -0500 Subject: [PATCH 05/54] script --- hawkbit-device-simulator/runSpring.sh | 1 + 1 file changed, 1 insertion(+) create mode 100755 hawkbit-device-simulator/runSpring.sh diff --git a/hawkbit-device-simulator/runSpring.sh b/hawkbit-device-simulator/runSpring.sh new file mode 100755 index 0000000..9c2972f --- /dev/null +++ b/hawkbit-device-simulator/runSpring.sh @@ -0,0 +1 @@ +mvn spring-boot:run From a9ad0991bd25f1022d3810f44fdbc7fd43aff8a4 Mon Sep 17 00:00:00 2001 From: charbull Date: Fri, 22 Feb 2019 13:45:05 -0500 Subject: [PATCH 06/54] integrating bucket --- hawkbit-device-simulator/.gitignore | 3 + hawkbit-device-simulator/README.md | 6 +- hawkbit-device-simulator/appEngineDeploy.sh | 1 + hawkbit-device-simulator/logAppEngine.sh | 1 + hawkbit-device-simulator/pom.xml | 20 ++--- .../src/main/appengine/app.yaml | 1 + .../hawkbit/google/gcp/BucketHandler.java | 77 +++++++++++++++++++ .../eclipse/hawkbit/google/gcp/GCP_Init.java | 31 -------- .../hawkbit/google/gcp/GCP_IoTHandler.java | 25 +++++- .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 2 +- .../simulator/SimulatedDeviceFactory.java | 1 + .../simulator/SimulationController.java | 7 ++ .../simulator/SimulationProperties.java | 2 +- .../hawkbit/simulator/SimulatorStartup.java | 23 +++++- .../simulator/amqp/DmfReceiverService.java | 5 +- .../src/main/webapp/WEB-INF/appengine-web.xml | 8 +- pom.xml | 33 ++------ 17 files changed, 169 insertions(+), 77 deletions(-) create mode 100755 hawkbit-device-simulator/appEngineDeploy.sh create mode 100755 hawkbit-device-simulator/logAppEngine.sh create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java diff --git a/hawkbit-device-simulator/.gitignore b/hawkbit-device-simulator/.gitignore index 09e3bc9..cfb6698 100644 --- a/hawkbit-device-simulator/.gitignore +++ b/hawkbit-device-simulator/.gitignore @@ -1,2 +1,5 @@ /bin/ /target/ +/pom2.xml +/pom3.xml +/pom4.xml diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 35a286f..391ea82 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -13,7 +13,7 @@ ## Get the devices from GCP registry - Set the projectId and the cloud region in the GCP_OTA.java - +- Create a bucket: gsutil mb gs://FW/ # hawkBit Device Simulator @@ -21,6 +21,10 @@ The device simulator handles software update commands from the update server. It is designed to be used very conveniently, for example, from within a browser. Hence, all the endpoints use the GET verb. + + + + ## Run on your own workstation ``` java -jar examples/hawkbit-device-simulator/target/hawkbit-device-simulator-*-SNAPSHOT.jar diff --git a/hawkbit-device-simulator/appEngineDeploy.sh b/hawkbit-device-simulator/appEngineDeploy.sh new file mode 100755 index 0000000..fdbb0eb --- /dev/null +++ b/hawkbit-device-simulator/appEngineDeploy.sh @@ -0,0 +1 @@ +mvn appengine:deploy diff --git a/hawkbit-device-simulator/logAppEngine.sh b/hawkbit-device-simulator/logAppEngine.sh new file mode 100755 index 0000000..27c3c7e --- /dev/null +++ b/hawkbit-device-simulator/logAppEngine.sh @@ -0,0 +1 @@ + gcloud app logs tail -s default diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 097d48f..8e1071b 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -1,7 +1,3 @@ - hawkbit-examples-parent - hawkbit-device-simulator - hawkBit :: Examples :: Device Simulator - Device Management Federation API based simulator + hawkbit-gcp-integration + hawkbit-gcp-manager + hawkBit :: GCP :: Manager + Device Management Federation API with GCP @@ -181,7 +178,12 @@ commons-cli 1.4 - + + + com.google.apis + google-api-services-storage + v1-rev149-1.25.0 + junit @@ -196,4 +198,4 @@ test - + \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/appengine/app.yaml b/hawkbit-device-simulator/src/main/appengine/app.yaml index c4eabc0..0e6bd11 100644 --- a/hawkbit-device-simulator/src/main/appengine/app.yaml +++ b/hawkbit-device-simulator/src/main/appengine/app.yaml @@ -1,3 +1,4 @@ +service: default runtime: java runtime_config: jdk: openjdk8 diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java new file mode 100644 index 0000000..5b742fb --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java @@ -0,0 +1,77 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.List; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.iam.v1.IamScopes; +import com.google.api.services.storage.Storage; +import com.google.api.services.storage.model.Bucket; +import com.google.api.services.storage.model.Buckets; +import com.google.api.services.storage.model.Objects; +import com.google.api.services.storage.model.StorageObject; + + + +public class BucketHandler { + + + private static HttpTransport httpTransport; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + + public static void init() throws FileNotFoundException, IOException, GeneralSecurityException { + + ClassLoader classLoader = BucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println(bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println(object.getName()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + + + } + + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java deleted file mode 100644 index bf517d0..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Init.java +++ /dev/null @@ -1,31 +0,0 @@ -//package org.eclipse.hawkbit.google.gcp; -// -//import java.io.IOException; -//import java.security.GeneralSecurityException; -// -//import com.google.api.services.cloudiot.v1.model.Device; -//import com.google.api.services.cloudiot.v1.model.DeviceRegistry; -// -//public class GCP_Init { -// -// -// //create a registry with 2 devices -// -// -// private static DeviceRegistry registry; -// -// public static void init(String projectId, String cloudRegion, String registryName) -// { -// try { -// registry = -// GcpRegistryHandler.createRegistry(cloudRegion, projectId, registryName, "HawkBitRegistry"); -// Device charbelk = GcpRegistryHandler.createDeviceWithRs256("charbelDevice", -// "/Users/charbelk/dev/hawkbit-google/hawkbit-examples/hawkbit-device-simulator/src/main/resources/rsa_cert.pem", -// projectId, -// cloudRegion, -// registry.getName()); -// } catch (GeneralSecurityException | IOException e) { -// e.printStackTrace(); -// } -// } -//} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java index 6419f0f..42b1bb7 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -34,9 +34,11 @@ import com.google.common.io.Files; import java.util.Base64; + + public class GCP_IoTHandler { - private static GoogleCredential getCredentialsFromFile() + public static GoogleCredential getCredentialsFromFile() { GoogleCredential credential = null; try { @@ -338,6 +340,27 @@ public static List listRegistries(String projectId, String cloud return registries; } + + + /* @SuppressWarnings("deprecation") + public static String uploadFile(Part filePart, final String bucketName) throws IOException { + DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); + DateTime dt = DateTime.now(DateTimeZone.UTC); + String dtString = dt.toString(dtf); + final String fileName = filePart.getSubmittedFileName() + dtString; + + // the inputstream is closed by default, so we don't need to close it here + BlobInfo blobInfo = + storage.create( + BlobInfo + .newBuilder(bucketName, fileName) + // Modify access list to allow all users with link to read file + .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) + .build(), + filePart.getInputStream()); + // return the public download link + return blobInfo.getMediaLink(); + }*/ public static void sendCommand( String deviceId, String projectId, String cloudRegion, String registryName, String data) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index eadf01c..d32ae3e 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -5,5 +5,5 @@ public class GCP_OTA { public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; - + public final static String BUCKET_NAME = "firmware-ota"; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index 67ab1d4..e5a1bc8 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -8,6 +8,7 @@ */ package org.eclipse.hawkbit.simulator; + import java.net.URL; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index 5faaba2..dc9bf8a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -243,6 +243,13 @@ ResponseEntity remove(@RequestParam(value = "tenant", required = false) return ResponseEntity.ok("Deleted"); } + + + @GetMapping("/hi") + ResponseEntity hi() { + return ResponseEntity.ok("hi"); + } + /** * Reset the device simulator by removing all simulated devices diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java index e6c11c4..a30833e 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java @@ -114,7 +114,7 @@ public static class Autostart { /** * Amount of simulated devices. */ - private int amount = 2; + private int amount = 0; /** * Tenant name for the simulation. diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index da52780..75870ec 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -8,10 +8,16 @@ */ package org.eclipse.hawkbit.simulator; +import java.io.FileNotFoundException; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.security.GeneralSecurityException; +import org.eclipse.hawkbit.google.gcp.BucketHandler; +import org.eclipse.hawkbit.simulator.SimulationProperties.Autostart; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +//import org.eclipse.hawkbit.google.gcp.BucketHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -45,9 +51,24 @@ public class SimulatorStartup implements ApplicationListener { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); for (int i = 0; i < autostart.getAmount(); i++) { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index c704dae..087b584 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -22,7 +23,6 @@ import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; -import org.eclipse.jetty.util.ConcurrentHashSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.amqp.AmqpRejectAndDontRequeueException; @@ -33,6 +33,7 @@ import org.springframework.messaging.handler.annotation.Header; import org.springframework.scheduling.annotation.Scheduled; + /** * Handle all incoming Messages from hawkBit update server. * @@ -47,7 +48,7 @@ public class DmfReceiverService extends MessageService { private final DeviceSimulatorRepository repository; - private final Set openPings = new ConcurrentHashSet<>(); + private final Set openPings = new HashSet(); /** * Constructor. diff --git a/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml b/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml index fa7858a..8beb8e5 100644 --- a/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml +++ b/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml @@ -1,4 +1,6 @@ - - true - java8 + + true + java8 + 1 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 08473af..9defbc6 100644 --- a/pom.xml +++ b/pom.xml @@ -79,36 +79,15 @@ true + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M2 + com.mycila license-maven-plugin - - org.apache.maven.plugins - maven-enforcer-plugin - 1.4.1 - - - - enforce-no-snapshots - - enforce - - - ${snapshotDependencyAllowed} - - - No Snapshots Allowed! - - - No Snapshots Allowed! - - - - - - org.codehaus.mojo versions-maven-plugin @@ -215,4 +194,4 @@ - + \ No newline at end of file From 5b60c57455dc5712a213d6b792314e8864dd4ca4 Mon Sep 17 00:00:00 2001 From: charbull Date: Fri, 22 Feb 2019 17:18:25 -0500 Subject: [PATCH 07/54] working on bucket upload --- hawkbit-device-simulator/README.md | 42 +++++ hawkbit-device-simulator/pom.xml | 5 + .../hawkbit/google/gcp/BucketHandler.java | 174 +++++++++++++----- .../simulator/DeviceSimulatorUpdater.java | 6 +- .../hawkbit/simulator/SimulatorStartup.java | 1 - 5 files changed, 183 insertions(+), 45 deletions(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 391ea82..dbe3b39 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -1,6 +1,14 @@ # hawkBit GCP Device Simulator +## Spin a VM + +install the following: +- git +- java openjdk8 +- docker +- maven + ## First Credentials for GCP - Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) @@ -22,8 +30,42 @@ The device simulator handles software update commands from the update server. It for example, from within a browser. Hence, all the endpoints use the GET verb. +# Open Ports + +- 8080/tcp —> hawkbit +- 8083/tcp —> gcp manager +- 3306/tcp, 33060/tcp —> Mysql +- 4369/tcp, 5671-5672/tcp, 15671-15672/tcp, 25672/tcp —> rabbitMQ +# docker on debian +if you had any difficulty installing docker compose follow the following + +1- Open `docker-compose-stack.yml` and remove the hawkBit simulator part, since we want to run the GCP Manager on the same port +``` + + image: "hawkbit/hawkbit-device-simulator:latest" + networks: + - hawknet + ports: + - "8083:8083" + deploy: + restart_policy: + condition: on-failure + environment: + - 'HAWKBIT_DEVICE_SIMULATOR_AUTOSTARTS_[0]_TENANT=DEFAULT' + - 'SPRING_RABBITMQ_VIRTUALHOST=/' + - 'SPRING_RABBITMQ_HOST=rabbitmq' + - 'SPRING_RABBITMQ_PORT=5672' + - 'SPRING_RABBITMQ_USERNAME=guest' + - 'SPRING_RABBITMQ_PASSWORD=guest' +``` + +2 - Run the following to start it +``` +sudo docker swarm init +sudo docker stack deploy -c docker-compose-stack.yml hawkbit +``` ## Run on your own workstation ``` diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 8e1071b..3f9538e 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -184,6 +184,11 @@ google-api-services-storage v1-rev149-1.25.0 + + com.google.cloud + google-cloud-storage + 1.63.0 + junit diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java index 5b742fb..05a7585 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java @@ -1,15 +1,27 @@ package org.eclipse.hawkbit.google.gcp; +import java.io.ByteArrayInputStream; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.InputStreamContent; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.jackson2.JacksonFactory; import com.google.api.services.iam.v1.IamScopes; @@ -21,57 +33,137 @@ + public class BucketHandler { + + private static Storage storage = null; + + private static HttpTransport httpTransport; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + private static Storage getStorage() throws FileNotFoundException, IOException, GeneralSecurityException { + if(storage == null) + { + ClassLoader classLoader = BucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + } + return storage; + } + + + private static Path getFile(String url) { + ClassLoader classLoader = BucketHandler.class.getClassLoader(); + URI uri; + Path path = null; + try { + uri = classLoader.getResource("logback-spring.xml").toURI(); + path = Paths.get(uri); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + return path; + } + + private static void createFWBucket() throws FileNotFoundException, IOException, GeneralSecurityException { + Storage gcs = getStorage(); + Stream lines = Files.lines(getFile(null)); + String data = lines.collect(Collectors.joining("\n")); + lines.close(); + System.out.println("[BucketHander] adding file to the bucket "+data); - private static HttpTransport httpTransport; - private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + uploadSimple(gcs, GCP_OTA.BUCKET_NAME, "newfirmware",data); + } - public static void init() throws FileNotFoundException, IOException, GeneralSecurityException { - + private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { ClassLoader classLoader = BucketHandler.class.getClassLoader(); String path = classLoader.getResource("keys.json").getPath(); GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); - - - Storage storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); - - Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); - Buckets buckets; - do { - buckets = bucketsList.execute(); - List items = buckets.getItems(); - if (items != null) { - for (Bucket bucket: items) { - System.out.println(bucket.getName()); - } - } - bucketsList.setPageToken(buckets.getNextPageToken()); - } while (buckets.getNextPageToken() != null); - - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - System.out.println(object.getName()); - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - - + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println(bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println(" ObjectName"+object.getName()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); } + public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + String data) throws UnsupportedEncodingException, IOException { + return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( + data.getBytes("UTF-8")), "text/plain"); + } + + public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + File data) throws FileNotFoundException, IOException { + return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), + "application/octet-stream"); + } + + public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + InputStream data, String contentType) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(contentType, data); + Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) + .setName(objectName); + // The media uploader gzips content by default, and alters the Content-Encoding accordingly. + // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, + // so the service stores exactly what is in the InputStream, without transformation. + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + public static StorageObject uploadWithMetadata(Storage storage, StorageObject object, + InputStream data) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); + Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, + mediaContent); + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + public static void init() throws FileNotFoundException, IOException, GeneralSecurityException { + createFWBucket(); + } + + } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index 74d278b..f88576a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -173,8 +173,8 @@ private UpdateStatus simulateDownloads() { modules.forEach(module -> module.getArtifacts().forEach( artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact))); - if(device.getId().contains("Charbel") || device.getId().contains("GCP")) - { +// if(device.getId().contains("Charbel") || device.getId().contains("GCP")) +// { try { System.out.println("==========> Attempting download to the device \n"+payload); GCP_IoTHandler.sendCommand(device.getId(), GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, @@ -184,7 +184,7 @@ private UpdateStatus simulateDownloads() { } catch (IOException e) { e.printStackTrace(); } - } + //} final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); result.getStatusMessages().add("Simulator: Download complete!"); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 75870ec..2a6e66b 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -67,7 +67,6 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { } catch (GeneralSecurityException e) { e.printStackTrace(); } -// simulationProperties.getAutostarts().forEach(autostart -> { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); From d3e34dc25be9b98e8847af2ba4d12d1714d54904 Mon Sep 17 00:00:00 2001 From: charbull Date: Fri, 22 Feb 2019 17:29:50 -0500 Subject: [PATCH 08/54] adding config and state devices retrieval --- hawkbit-device-simulator/pom.xml | 5 - .../hawkbit/google/gcp/GCP_IoTHandler.java | 134 +++++++++++++++--- 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 3f9538e..8e1071b 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -184,11 +184,6 @@ google-api-services-storage v1-rev149-1.25.0 - - com.google.cloud - google-cloud-storage - 1.63.0 - junit diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java index 42b1bb7..1e70058 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -6,6 +6,7 @@ import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.List; import org.eclipse.paho.client.mqttv3.MqttClient; @@ -22,9 +23,13 @@ import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayRequest; import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayResponse; import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceConfig; import com.google.api.services.cloudiot.v1.model.DeviceCredential; import com.google.api.services.cloudiot.v1.model.DeviceRegistry; +import com.google.api.services.cloudiot.v1.model.DeviceState; import com.google.api.services.cloudiot.v1.model.EventNotificationConfig; +import com.google.api.services.cloudiot.v1.model.ListDeviceStatesResponse; +import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest; import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; @@ -32,7 +37,6 @@ import com.google.api.services.cloudiot.v1.model.UnbindDeviceFromGatewayResponse; import com.google.common.base.Charsets; import com.google.common.io.Files; -import java.util.Base64; @@ -342,26 +346,116 @@ public static List listRegistries(String projectId, String cloud } - /* @SuppressWarnings("deprecation") - public static String uploadFile(Part filePart, final String bucketName) throws IOException { - DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); - DateTime dt = DateTime.now(DateTimeZone.UTC); - String dtString = dt.toString(dtf); - final String fileName = filePart.getSubmittedFileName() + dtString; - - // the inputstream is closed by default, so we don't need to close it here - BlobInfo blobInfo = - storage.create( - BlobInfo - .newBuilder(bucketName, fileName) - // Modify access list to allow all users with link to read file - .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) - .build(), - filePart.getInputStream()); - // return the public download link - return blobInfo.getMediaLink(); - }*/ +// @SuppressWarnings("deprecation") +// public static String uploadFile(Part filePart, final String bucketName) throws IOException { +// DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); +// DateTime dt = DateTime.now(DateTimeZone.UTC); +// String dtString = dt.toString(dtf); +// final String fileName = filePart.getSubmittedFileName() + dtString; +// +// // the inputstream is closed by default, so we don't need to close it here +// BlobInfo blobInfo = +// storage.create( +// BlobInfo +// .newBuilder(bucketName, fileName) +// // Modify access list to allow all users with link to read file +// .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) +// .build(), +// filePart.getInputStream()); +// // return the public download link +// return blobInfo.getMediaLink(); +// } + + /** List all of the configs for the given device. */ + public static void listDeviceConfigs( + String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + System.out.println("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + for (DeviceConfig config : deviceConfigs) { + System.out.println("Config version: " + config.getVersion()); + System.out.println("Contents: " + config.getBinaryData()); + System.out.println(); + } + } + + + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ + public static void setDeviceConfiguration( + String deviceId, String projectId, String cloudRegion, String registryName, + String data, long version) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); + req.setVersionToUpdate(version); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + + DeviceConfig config = + service + .projects() + .locations() + .registries() + .devices() + .modifyCloudToDeviceConfig(devicePath, req).execute(); + + System.out.println("Updated: " + config.getVersion()); + } + + /** Retrieves device metadata from a registry. **/ + public static List getDeviceStates( + String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + System.out.println("Retrieving device states " + devicePath); + + ListDeviceStatesResponse resp = service + .projects() + .locations() + .registries() + .devices() + .states() + .list(devicePath).execute(); + + return resp.getDeviceStates(); + } + public static void sendCommand( String deviceId, String projectId, String cloudRegion, String registryName, String data) throws GeneralSecurityException, IOException { From 85c8274de50685b652995c803abdff46c94c5f23 Mon Sep 17 00:00:00 2001 From: charbull Date: Sat, 23 Feb 2019 12:33:33 -0500 Subject: [PATCH 09/54] adding bucket uploader --- hawkbit-device-simulator/README.md | 2 +- hawkbit-device-simulator/pom.xml | 5 + .../hawkbit/google/gcp/BucketHandler.java | 228 +++++++++------ .../hawkbit/google/gcp/GCP_IoTHandler.java | 275 +++++++++--------- .../gcp/HawkbitSoftwareModuleHandler.java | 78 +++++ .../simulator/DeviceSimulatorUpdater.java | 40 ++- .../hawkbit/simulator/SimulatorStartup.java | 79 ++--- 7 files changed, 429 insertions(+), 278 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index dbe3b39..88fe6af 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -28,7 +28,7 @@ install the following: The device simulator handles software update commands from the update server. It is designed to be used very conveniently, for example, from within a browser. Hence, all the endpoints use the GET verb. - +-Dhawkbit.device.simulator.amqp.enabled=true # Open Ports diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 8e1071b..e6cc018 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -66,6 +66,11 @@ hawkbit-example-ddi-feign-client ${project.version} + org.springframework.amqp spring-rabbit diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java index 05a7585..053ef0d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java @@ -7,16 +7,16 @@ import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.net.URL; +import java.net.URLConnection; import java.security.GeneralSecurityException; +import java.util.Base64; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.Scanner; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; @@ -30,18 +30,23 @@ import com.google.api.services.storage.model.Buckets; import com.google.api.services.storage.model.Objects; import com.google.api.services.storage.model.StorageObject; +import com.google.gson.Gson; +import com.google.gson.JsonObject; public class BucketHandler { - + + private static final Logger LOGGER = LoggerFactory.getLogger(BucketHandler.class); private static Storage storage = null; private static HttpTransport httpTransport; private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); - + private static Gson gson = new Gson(); + + private static Storage getStorage() throws FileNotFoundException, IOException, GeneralSecurityException { if(storage == null) { @@ -61,58 +66,57 @@ private static Storage getStorage() throws FileNotFoundException, IOException, G } - private static Path getFile(String url) { - ClassLoader classLoader = BucketHandler.class.getClassLoader(); - URI uri; - Path path = null; + private static String getFile(String urlString, String fileName) { + String data = null; try { - uri = classLoader.getResource("logback-spring.xml").toURI(); - path = Paths.get(uri); - } catch (URISyntaxException e) { + URLConnection uc = new URL(urlString).openConnection(); + String userpass = "admin:admin"; //FIXME: this is bad ! + String basicAuth = "Basic " + new String(Base64.getEncoder().encode(userpass.getBytes())); + uc.setRequestProperty ("Authorization", basicAuth); + InputStream inputStream = uc.getInputStream(); + Scanner scanner = new Scanner(inputStream, "UTF-8"); + scanner.useDelimiter("\\A").next(); + scanner.close(); + } catch (IOException e) { e.printStackTrace(); } - return path; - } - - private static void createFWBucket() throws FileNotFoundException, IOException, GeneralSecurityException { - Storage gcs = getStorage(); - Stream lines = Files.lines(getFile(null)); - String data = lines.collect(Collectors.joining("\n")); - lines.close(); - System.out.println("[BucketHander] adding file to the bucket "+data); - - uploadSimple(gcs, GCP_OTA.BUCKET_NAME, "newfirmware",data); + return data; } - - private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { - ClassLoader classLoader = BucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { + listBuckets(); + Storage gcs = getStorage(); + String data = HawkbitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); + if(!checkIfExists(artifactName)) + { + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GCP_OTA.BUCKET_NAME, artifactName, data); + } + } - Storage storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); + private static boolean checkIfExists(String artifactName) throws IOException { - Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); - Buckets buckets; + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; do { - buckets = bucketsList.execute(); - List items = buckets.getItems(); + objects = objectsList.execute(); + List items = objects.getItems(); if (items != null) { - for (Bucket bucket: items) { - System.out.println(bucket.getName()); + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.debug(artifactName+" already exists!"); + return true; + } } } - bucketsList.setPageToken(buckets.getNextPageToken()); - } while (buckets.getNextPageToken() != null); + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + return false; + } + private static String getStorageObjectInfo(String artifactName) throws IOException { Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); Objects objects; do { @@ -120,50 +124,98 @@ private static void listBuckets() throws FileNotFoundException, IOException, Gen List items = objects.getItems(); if (items != null) { for (StorageObject object : items) { - System.out.println(" ObjectName"+object.getName()); + if(object.getName().equalsIgnoreCase(artifactName)) + { + JsonObject jsonObject = new JsonObject(); + LOGGER.debug(artifactName+" exists!"); + jsonObject.addProperty("ObjectName", object.getName()); + jsonObject.addProperty("Url", object.getMediaLink()); + jsonObject.addProperty("Md5Hash", object.getMd5Hash()); + return gson.toJson(jsonObject); + } } } objectsList.setPageToken(objects.getNextPageToken()); } while (objects.getNextPageToken() != null); + LOGGER.warn(artifactName+" not found"); + return null; } - - public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - String data) throws UnsupportedEncodingException, IOException { - return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( - data.getBytes("UTF-8")), "text/plain"); - } - - public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - File data) throws FileNotFoundException, IOException { - return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), - "application/octet-stream"); - } - - public static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - InputStream data, String contentType) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(contentType, data); - Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) - .setName(objectName); - // The media uploader gzips content by default, and alters the Content-Encoding accordingly. - // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, - // so the service stores exactly what is in the InputStream, without transformation. - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } - - public static StorageObject uploadWithMetadata(Storage storage, StorageObject object, - InputStream data) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); - Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, - mediaContent); - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } - - - public static void init() throws FileNotFoundException, IOException, GeneralSecurityException { - createFWBucket(); - } -} + private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { + ClassLoader classLoader = BucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println("[BucketHandler] BucketName : "+bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println("[BucketHandler] ObjectName: "+object.getName()); + System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); + System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + String data) throws UnsupportedEncodingException, IOException { + return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( + data.getBytes("UTF-8")), "text/plain"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + File data) throws FileNotFoundException, IOException { + return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), + "application/octet-stream"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + InputStream data, String contentType) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(contentType, data); + Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) + .setName(objectName); + // The media uploader gzips content by default, and alters the Content-Encoding accordingly. + // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, + // so the service stores exactly what is in the InputStream, without transformation. + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, + InputStream data) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); + Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, + mediaContent); + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java index 1e70058..b96c86f 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -344,151 +344,156 @@ public static List listRegistries(String projectId, String cloud return registries; } - - -// @SuppressWarnings("deprecation") -// public static String uploadFile(Part filePart, final String bucketName) throws IOException { -// DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); -// DateTime dt = DateTime.now(DateTimeZone.UTC); -// String dtString = dt.toString(dtf); -// final String fileName = filePart.getSubmittedFileName() + dtString; -// -// // the inputstream is closed by default, so we don't need to close it here -// BlobInfo blobInfo = -// storage.create( -// BlobInfo -// .newBuilder(bucketName, fileName) -// // Modify access list to allow all users with link to read file -// .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) -// .build(), -// filePart.getInputStream()); -// // return the public download link -// return blobInfo.getMediaLink(); -// } - - + + + // @SuppressWarnings("deprecation") + // public static String uploadFile(Part filePart, final String bucketName) throws IOException { + // DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); + // DateTime dt = DateTime.now(DateTimeZone.UTC); + // String dtString = dt.toString(dtf); + // final String fileName = filePart.getSubmittedFileName() + dtString; + // + // // the inputstream is closed by default, so we don't need to close it here + // BlobInfo blobInfo = + // storage.create( + // BlobInfo + // .newBuilder(bucketName, fileName) + // // Modify access list to allow all users with link to read file + // .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) + // .build(), + // filePart.getInputStream()); + // // return the public download link + // return blobInfo.getMediaLink(); + // } + + /** List all of the configs for the given device. */ public static void listDeviceConfigs( - String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - System.out.println("Listing device configs for " + devicePath); - List deviceConfigs = - service - .projects() - .locations() - .registries() - .devices() - .configVersions() - .list(devicePath) - .execute() - .getDeviceConfigs(); - - for (DeviceConfig config : deviceConfigs) { - System.out.println("Config version: " + config.getVersion()); - System.out.println("Contents: " + config.getBinaryData()); - System.out.println(); - } + String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + System.out.println("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + for (DeviceConfig config : deviceConfigs) { + System.out.println("Config version: " + config.getVersion()); + System.out.println("Contents: " + config.getBinaryData()); + System.out.println(); + } } - - + + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ public static void setDeviceConfiguration( - String deviceId, String projectId, String cloudRegion, String registryName, - String data, long version) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); - req.setVersionToUpdate(version); - - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - - DeviceConfig config = - service - .projects() - .locations() - .registries() - .devices() - .modifyCloudToDeviceConfig(devicePath, req).execute(); - - System.out.println("Updated: " + config.getVersion()); + String deviceId, String projectId, String cloudRegion, String registryName, + String data, long version) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); + req.setVersionToUpdate(version); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + + DeviceConfig config = + service + .projects() + .locations() + .registries() + .devices() + .modifyCloudToDeviceConfig(devicePath, req).execute(); + + System.out.println("Updated: " + config.getVersion()); } - + /** Retrieves device metadata from a registry. **/ public static List getDeviceStates( - String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - System.out.println("Retrieving device states " + devicePath); - - ListDeviceStatesResponse resp = service - .projects() - .locations() - .registries() - .devices() - .states() - .list(devicePath).execute(); - - return resp.getDeviceStates(); + String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + System.out.println("Retrieving device states " + devicePath); + + ListDeviceStatesResponse resp = service + .projects() + .locations() + .registries() + .devices() + .states() + .list(devicePath).execute(); + + return resp.getDeviceStates(); } - + public static void sendCommand( - String deviceId, String projectId, String cloudRegion, String registryName, String data) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String devicePath = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); - - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - System.out.printf("Sending command to %s\n", devicePath); - - SendCommandToDeviceResponse res = - service - .projects() - .locations() - .registries() - .devices() - .sendCommandToDevice(devicePath, req) - .execute(); - - System.out.println("Command response: " + res.toString()); - } + String deviceId, String projectId, String cloudRegion, String registryName, String data) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String devicePath = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + System.out.printf("Sending command to %s\n", devicePath); + try { + + SendCommandToDeviceResponse res = + service + .projects() + .locations() + .registries() + .devices() + .sendCommandToDevice(devicePath, req) + .execute(); + + System.out.println("Command response: " + res.toString()); + + } catch (Exception e) { + e.printStackTrace(); + } + } /** Retrieves registry metadata from a project. **/ public static DeviceRegistry getRegistry( diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java new file mode 100644 index 0000000..26b9091 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java @@ -0,0 +1,78 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; +import org.eclipse.hawkbit.simulator.UpdateStatus; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +//TODO: +/** + * Use the Hawkbit Management Client to download + * software modules and put them on the bucket + * */ +public class HawkbitSoftwareModuleHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(HawkbitSoftwareModuleHandler.class); + + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + + + + protected static String downloadFileData(final String url,final String targetToken) + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { + + final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts(); + final HttpGet request = new HttpGet(url); + + if (!StringUtils.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } + + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + String message = "download "+url+" failed (" + response.getStatusLine().getStatusCode()+ ")"; + LOGGER.error(message); + return null; + } + String payload = null; + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + System.out.println("Payload ==========> "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + } + + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index f88576a..e02ca1c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -10,10 +10,9 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; -import java.io.BufferedReader; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.security.DigestOutputStream; import java.security.GeneralSecurityException; @@ -36,6 +35,7 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.google.gcp.BucketHandler; import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; import org.eclipse.hawkbit.google.gcp.GCP_OTA; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; @@ -50,7 +50,6 @@ import org.springframework.util.StringUtils; import com.google.common.base.Charsets; -import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; /** @@ -133,6 +132,18 @@ private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final this.actionType = actionType; this.gatewayToken = gatewayToken; } + + private void uploadModulesToGCS() { + modules.forEach(module -> { + long moduleId = module.getModuleId(); + String moduleVersion = module.getModuleVersion(); + String moduleType = module.getModuleType(); + module.getArtifacts().forEach( artifact -> { + String fileName = artifact.getFilename(); + //artifact.getUrls() + }); + }); + } @Override public void run() { @@ -170,8 +181,23 @@ private UpdateStatus simulateDownloads() { LOGGER.info("Simulate downloads for {}", device.getId()); System.out.printf("Simulate downloads for {}", device.getId()); - modules.forEach(module -> module.getArtifacts().forEach( - artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact))); + + modules.forEach(module -> { + module.getArtifacts().forEach( + artifact -> + { + try { + BucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), device.getTargetSecurityToken()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } + handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact); + }); + }); // if(device.getId().contains("Charbel") || device.getId().contains("GCP")) // { @@ -270,7 +296,7 @@ private static UpdateStatus readAndCheckDownloadUrl(final String url, final Stri final MessageDigest md = MessageDigest.getInstance("SHA-1"); //overallread = getOverallRead(response, md); - payload = getPayload(response, md); + payload = getPayload(response); // if (overallread != size) { // final String message = incompleteRead(url, size, overallread); @@ -308,7 +334,7 @@ private static long getOverallRead(final CloseableHttpResponse response, final M } - private static String getPayload(final CloseableHttpResponse response, final MessageDigest md) + private static String getPayload(final CloseableHttpResponse response) throws IOException { try { InputStream is = response.getEntity().getContent(); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 2a6e66b..f4aeb5a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -15,7 +15,6 @@ import java.security.GeneralSecurityException; import org.eclipse.hawkbit.google.gcp.BucketHandler; -import org.eclipse.hawkbit.simulator.SimulationProperties.Autostart; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; //import org.eclipse.hawkbit.google.gcp.BucketHandler; import org.slf4j.Logger; @@ -33,57 +32,43 @@ @Component @ConditionalOnProperty(prefix = "hawkbit.device.simulator", name = "autostart", matchIfMissing = true) public class SimulatorStartup implements ApplicationListener { - private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); + private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); - @Autowired - private SimulationProperties simulationProperties; + @Autowired + private SimulationProperties simulationProperties; - @Autowired - private DeviceSimulatorRepository repository; + @Autowired + private DeviceSimulatorRepository repository; - @Autowired - private SimulatedDeviceFactory deviceFactory; + @Autowired + private SimulatedDeviceFactory deviceFactory; - @Autowired - private AmqpProperties amqpProperties; + @Autowired + private AmqpProperties amqpProperties; - @Override - public void onApplicationEvent(final ApplicationReadyEvent event) { - System.out.println("AutoStarting application ..."); - LOGGER.debug("{} autostarts will be executed connecting to GCP"); - LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); + @Override + public void onApplicationEvent(final ApplicationReadyEvent event) { + System.out.println("AutoStarting application ..."); + LOGGER.debug("{} autostarts will be executed connecting to GCP"); + LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); + + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket + simulationProperties.getAutostarts().forEach(autostart -> { + LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); + for (int i = 0; i < autostart.getAmount(); i++) { + final String deviceId = autostart.getName() + i; + try { + if (amqpProperties.isEnabled()) { + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), + new URL(autostart.getEndpoint()), autostart.getGatewayToken())); + } -// simulationProperties.setDefaultTenant("GCP"); -// Autostart autoStart = new Autostart(); -// autoStart.setTenant("GCP"); -// -// simulationProperties.getAutostarts().add(e) - try { - BucketHandler.init(); - } catch (FileNotFoundException e1) { - e1.printStackTrace(); - } catch (IOException e1) { - e1.printStackTrace(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } - - simulationProperties.getAutostarts().forEach(autostart -> { - LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); - for (int i = 0; i < autostart.getAmount(); i++) { - final String deviceId = autostart.getName() + i; - try { - if (amqpProperties.isEnabled()) { - repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, - autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), - new URL(autostart.getEndpoint()), autostart.getGatewayToken())); - } - - } catch (final MalformedURLException e) { - LOGGER.error("Creation of simulated device at startup failed.", e); - } - } - }); - } + } catch (final MalformedURLException e) { + LOGGER.error("Creation of simulated device at startup failed.", e); + } + } + }); + } } From f7e69e3eb388310b80db76ac1c7af53968361cee Mon Sep 17 00:00:00 2001 From: charbull Date: Sun, 24 Feb 2019 22:12:45 -0500 Subject: [PATCH 10/54] async download reading device state from pubsub and updating hawkbit --- hawkbit-device-simulator/README.md | 5 +- hawkbit-device-simulator/pom.xml | 12 +- .../hawkbit/google/gcp/BucketHandler.java | 221 --------- .../hawkbit/google/gcp/GCPBucketHandler.java | 222 +++++++++ .../hawkbit/google/gcp/GCP_IoTHandler.java | 131 +++-- .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 2 + .../hawkbit/google/gcp/GCP_Subscriber.java | 147 ++++++ .../gcp/HawkbitSoftwareModuleHandler.java | 1 - .../simulator/DeviceSimulatorUpdater.java | 142 +++--- .../simulator/SimulationController.java | 449 +++++++++--------- .../hawkbit/simulator/SimulatorStartup.java | 6 +- .../simulator/amqp/DmfReceiverService.java | 23 +- .../simulator/amqp/DmfSenderService.java | 2 +- 13 files changed, 792 insertions(+), 571 deletions(-) delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 88fe6af..e0370c3 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -18,10 +18,11 @@ install the following: - Add it to `src/main/resources` -## Get the devices from GCP registry +## GCP Config - Set the projectId and the cloud region in the GCP_OTA.java -- Create a bucket: gsutil mb gs://FW/ +- Create a `state` subscription on the state topic +- Create a bucket: gsutil mb gs:/// # hawkBit Device Simulator diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index e6cc018..c5712be 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -66,11 +66,8 @@ hawkbit-example-ddi-feign-client ${project.version} - + org.springframework.amqp spring-rabbit @@ -183,6 +180,11 @@ commons-cli 1.4 + + com.google.cloud + google-cloud-pubsub + 1.63.0 + com.google.apis diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java deleted file mode 100644 index 053ef0d..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/BucketHandler.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; -import java.net.URL; -import java.net.URLConnection; -import java.security.GeneralSecurityException; -import java.util.Base64; -import java.util.Collections; -import java.util.List; -import java.util.Scanner; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.InputStreamContent; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.jackson2.JacksonFactory; -import com.google.api.services.iam.v1.IamScopes; -import com.google.api.services.storage.Storage; -import com.google.api.services.storage.model.Bucket; -import com.google.api.services.storage.model.Buckets; -import com.google.api.services.storage.model.Objects; -import com.google.api.services.storage.model.StorageObject; -import com.google.gson.Gson; -import com.google.gson.JsonObject; - - - - -public class BucketHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(BucketHandler.class); - - private static Storage storage = null; - - private static HttpTransport httpTransport; - private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); - private static Gson gson = new Gson(); - - - private static Storage getStorage() throws FileNotFoundException, IOException, GeneralSecurityException { - if(storage == null) - { - ClassLoader classLoader = BucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); - storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); - } - return storage; - } - - - private static String getFile(String urlString, String fileName) { - String data = null; - try { - URLConnection uc = new URL(urlString).openConnection(); - String userpass = "admin:admin"; //FIXME: this is bad ! - String basicAuth = "Basic " + new String(Base64.getEncoder().encode(userpass.getBytes())); - uc.setRequestProperty ("Authorization", basicAuth); - InputStream inputStream = uc.getInputStream(); - Scanner scanner = new Scanner(inputStream, "UTF-8"); - scanner.useDelimiter("\\A").next(); - scanner.close(); - } catch (IOException e) { - e.printStackTrace(); - } - return data; - } - - public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { - - listBuckets(); - Storage gcs = getStorage(); - String data = HawkbitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); - if(!checkIfExists(artifactName)) - { - LOGGER.info("Uploading to GCS artifact: "+artifactName); - uploadSimple(gcs, GCP_OTA.BUCKET_NAME, artifactName, data); - } - } - - private static boolean checkIfExists(String artifactName) throws IOException { - - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - if(object.getName().equalsIgnoreCase(artifactName)) - { - LOGGER.debug(artifactName+" already exists!"); - return true; - } - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - return false; - } - - private static String getStorageObjectInfo(String artifactName) throws IOException { - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - if(object.getName().equalsIgnoreCase(artifactName)) - { - JsonObject jsonObject = new JsonObject(); - LOGGER.debug(artifactName+" exists!"); - jsonObject.addProperty("ObjectName", object.getName()); - jsonObject.addProperty("Url", object.getMediaLink()); - jsonObject.addProperty("Md5Hash", object.getMd5Hash()); - return gson.toJson(jsonObject); - } - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - LOGGER.warn(artifactName+" not found"); - return null; - } - - - private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { - ClassLoader classLoader = BucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); - - - Storage storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); - - Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); - Buckets buckets; - do { - buckets = bucketsList.execute(); - List items = buckets.getItems(); - if (items != null) { - for (Bucket bucket: items) { - System.out.println("[BucketHandler] BucketName : "+bucket.getName()); - } - } - bucketsList.setPageToken(buckets.getNextPageToken()); - } while (buckets.getNextPageToken() != null); - - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - System.out.println("[BucketHandler] ObjectName: "+object.getName()); - System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); - System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - String data) throws UnsupportedEncodingException, IOException { - return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( - data.getBytes("UTF-8")), "text/plain"); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - File data) throws FileNotFoundException, IOException { - return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), - "application/octet-stream"); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - InputStream data, String contentType) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(contentType, data); - Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) - .setName(objectName); - // The media uploader gzips content by default, and alters the Content-Encoding accordingly. - // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, - // so the service stores exactly what is in the InputStream, without transformation. - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } - - private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, - InputStream data) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); - Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, - mediaContent); - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } - } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java new file mode 100644 index 0000000..e12bdad --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java @@ -0,0 +1,222 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.InputStreamContent; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.iam.v1.IamScopes; +import com.google.api.services.storage.Storage; +import com.google.api.services.storage.model.Bucket; +import com.google.api.services.storage.model.Buckets; +import com.google.api.services.storage.model.Objects; +import com.google.api.services.storage.model.StorageObject; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + + + + +public class GCPBucketHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GCPBucketHandler.class); + + private static Storage storage = null; + static Gson gson = new Gson(); + + private static HttpTransport httpTransport; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + + + private static Storage getStorage() { + try { + if(storage == null) + { + ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + } + } catch (Exception e) { + e.printStackTrace(); + } + return storage; + } + + public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { + + listBuckets(); + Storage gcs = getStorage(); + String data = HawkbitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); + if(!checkIfExists(artifactName)) + { + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GCP_OTA.BUCKET_NAME, artifactName, data); + } + } + + public static String getFirmwareInfoBucket(String artifactName) + { + try { + StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + JsonObject jsonObject = new JsonObject(); + LOGGER.debug(artifactName+" exists!"); + jsonObject.addProperty("ObjectName", storageObject.getName()); + jsonObject.addProperty("Url", storageObject.getMediaLink()); + jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); + + JsonObject jsonConfig = new JsonObject(); + jsonConfig.add("firmware-update", jsonObject); + return gson.toJson(jsonConfig); + } + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + private static boolean checkIfExists(String artifactName) throws IOException { + + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.debug(artifactName+" already exists!"); + return true; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + return false; + } + + public static StorageObject getStorageObjectInfo(String artifactName) throws IOException { + Storage.Objects.List objectsList = getStorage().objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.debug(artifactName+" exists!"); + return object; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + LOGGER.warn(artifactName+" not found"); + return null; + } + + + private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { + ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println("[BucketHandler] BucketName : "+bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println("[BucketHandler] ObjectName: "+object.getName()); + System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); + System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + String data) throws UnsupportedEncodingException, IOException { + return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( + data.getBytes("UTF-8")), "text/plain"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + File data) throws FileNotFoundException, IOException { + return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), + "application/octet-stream"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + InputStream data, String contentType) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(contentType, data); + Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) + .setName(objectName); + // The media uploader gzips content by default, and alters the Content-Encoding accordingly. + // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, + // so the service stores exactly what is in the InputStream, without transformation. + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, + InputStream data) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); + Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, + mediaContent); + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java index b96c86f..f32bead 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -370,7 +370,8 @@ public static List listRegistries(String projectId, String cloud /** List all of the configs for the given device. */ public static void listDeviceConfigs( String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { + { + try { JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); final CloudIot service = new CloudIot.Builder( @@ -396,14 +397,19 @@ public static void listDeviceConfigs( System.out.println("Contents: " + config.getBinaryData()); System.out.println(); } + + } catch (Exception e) { + e.printStackTrace(); + } } - /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ - public static void setDeviceConfiguration( - String deviceId, String projectId, String cloudRegion, String registryName, - String data, long version) - throws GeneralSecurityException, IOException { + /** List all of the configs for the given device. */ + public static long getLatestConfig( + String deviceId, String projectId, String cloudRegion, String registryName) + { + long configVersion = 0; + try { JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); final CloudIot service = new CloudIot.Builder( @@ -412,53 +418,108 @@ public static void setDeviceConfiguration( final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", projectId, cloudRegion, registryName, deviceId); - ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); - req.setVersionToUpdate(version); - - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - - DeviceConfig config = + System.out.println("Listing device configs for " + devicePath); + List deviceConfigs = service .projects() .locations() .registries() .devices() - .modifyCloudToDeviceConfig(devicePath, req).execute(); + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + + for (DeviceConfig config : deviceConfigs) { + System.out.println("Config version: " + config.getVersion()); + System.out.println("Contents: " + config.getBinaryData()); + if(configVersion < config.getVersion()) + { + configVersion = config.getVersion(); + } + } + + } catch (Exception e) { + e.printStackTrace(); + } + + return configVersion; + } + + + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ + public static void setDeviceConfiguration( + String deviceId, String projectId, String cloudRegion, String registryName, + String data, long version) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); + req.setVersionToUpdate(version); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + + DeviceConfig config = + service + .projects() + .locations() + .registries() + .devices() + .modifyCloudToDeviceConfig(devicePath, req).execute(); + + System.out.println("Updated: " + config.getVersion()); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + - System.out.println("Updated: " + config.getVersion()); } /** Retrieves device metadata from a registry. **/ public static List getDeviceStates( String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + { - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - System.out.println("Retrieving device states " + devicePath); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); - ListDeviceStatesResponse resp = service - .projects() - .locations() - .registries() - .devices() - .states() - .list(devicePath).execute(); + System.out.println("Retrieving device states " + devicePath); - return resp.getDeviceStates(); + ListDeviceStatesResponse resp = service + .projects() + .locations() + .registries() + .devices() + .states() + .list(devicePath).execute(); + + return resp.getDeviceStates(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } } public static void sendCommand( String deviceId, String projectId, String cloudRegion, String registryName, String data) - throws GeneralSecurityException, IOException { + { + try { JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); final CloudIot service = @@ -477,8 +538,6 @@ public static void sendCommand( String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); req.setBinaryData(encPayload); System.out.printf("Sending command to %s\n", devicePath); - try { - SendCommandToDeviceResponse res = service .projects() diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index d32ae3e..3c4faa1 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -6,4 +6,6 @@ public class GCP_OTA { public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; public final static String BUCKET_NAME = "firmware-ota"; + public final static String SUBSCRIPTION_STATE_ID = "state"; + public final static boolean FW_VIA_COMMAND = false; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java new file mode 100644 index 0000000..75db33e --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -0,0 +1,147 @@ +package org.eclipse.hawkbit.google.gcp; + + + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.eclipse.hawkbit.simulator.UpdateStatus; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsub.v1.Subscriber; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; + +public class GCP_Subscriber { + + private static final BlockingQueue messages = new LinkedBlockingDeque<>(); + private static Map mapCallbacks = new HashMap(); + + private static Map mapDevices = new HashMap(); + + private static Gson gson = new Gson(); + + private static final Logger LOGGER = LoggerFactory.getLogger(GCP_Subscriber.class); + + static class StateMessageReceiver implements MessageReceiver { + + @Override + public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { + messages.offer(message); + consumer.ack(); + } + } + + /** Receive messages over a subscription. */ + public static void init() { + ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of( + GCP_OTA.PROJECT_ID, GCP_OTA.SUBSCRIPTION_STATE_ID); + Subscriber subscriber = null; + try { + // create a subscriber bound to the asynchronous message receiver + subscriber = + Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).build(); + subscriber.startAsync().awaitRunning(); + // Continue to listen to messages + while (true) { + PubsubMessage message = messages.take(); + updateHawkbitStatus(message); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + if (subscriber != null) { + subscriber.stopAsync(); + } + } + } + + private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { + String data = GCPBucketHandler.getFirmwareInfoBucket(artifactName); + if(data != null) { + long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME); + GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME, data, configVersion); + } + else LOGGER.error(artifactName+" not found in bucket for device "+deviceId); + } + + + public static void updateHawkbitStatus(PubsubMessage message){ + System.out.println("Message Id: " + message.getMessageId()); + //{"deviceId":"CharbelDevice","fw-state":"installed"} + System.out.println("Data: " + message.getData().toStringUtf8()); + JsonObject stateFromDevice = gson.fromJson(message.getData() + .toStringUtf8(), JsonObject.class); + String deviceId = stateFromDevice.get("deviceId").getAsString(); + String fw_state = stateFromDevice.get("fw-state").getAsString(); + + if(deviceId != null && fw_state != null) { + AbstractSimulatedDevice device = mapDevices.get(deviceId); + UpdaterCallback callback = mapCallbacks.get(deviceId); + + + UpdateStatus updateStatus = null; + + switch (fw_state) { + case "msg-received" : + updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + break; + case "installing" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + break; + case "downloading" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + break; + case "installed": + updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + //remove device and callback + mapCallbacks.remove(deviceId); + mapDevices.remove(deviceId); + break; + default: + LOGGER.error("Unknown fw-state: "+fw_state); + break; + } + device.setUpdateStatus(updateStatus); + callback.sendFeedback(device); + } + + } + + public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback callback, + List modules, EventTopic actionType) { + + LOGGER.info("Update device with eventTopic: %s",actionType); + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + LOGGER.info("[GCP Async] Download & Install"); + + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> art.getFilename()) + .collect(Collectors.toList()); + + fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); + + mapCallbacks.put(device.getId(), callback); + mapDevices.put(device.getId(), device); + //device.clean(); + } + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java index 26b9091..fc6b952 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java @@ -13,7 +13,6 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContextBuilder; -import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; import org.eclipse.hawkbit.simulator.UpdateStatus; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index e02ca1c..6830d43 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -10,12 +10,10 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.security.DigestOutputStream; -import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.MessageDigest; @@ -35,9 +33,10 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.eclipse.hawkbit.google.gcp.BucketHandler; +import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; import org.eclipse.hawkbit.google.gcp.GCP_OTA; +import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; @@ -104,8 +103,14 @@ public void startUpdate(final String tenant, final String id, final List { - long moduleId = module.getModuleId(); - String moduleVersion = module.getModuleVersion(); - String moduleType = module.getModuleType(); - module.getArtifacts().forEach( artifact -> { - String fileName = artifact.getFilename(); - //artifact.getUrls() - }); - }); - } @Override public void run() { - device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); - callback.sendFeedback(device); - if (!CollectionUtils.isEmpty(modules)) { - device.setUpdateStatus(simulateDownloads()); + + if(GCP_OTA.FW_VIA_COMMAND) + { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); callback.sendFeedback(device); - if (isErrorResponse(device.getUpdateStatus())) { + + if (!CollectionUtils.isEmpty(modules)) { + device.setUpdateStatus(simulateDownloads()); + callback.sendFeedback(device); + if (isErrorResponse(device.getUpdateStatus())) { + device.clean(); + return; + } + } + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + System.out.println("[DeviceSimulator] Download & Install"); + device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); + callback.sendFeedback(device); device.clean(); - return; } } + } - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { - System.out.println("[DeviceSimulator] Download & Install"); - device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); - callback.sendFeedback(device); - device.clean(); - } + + + private void syncDownloadGCP(String deviceId, String data) + { + System.out.println("==========> Attempting download to the device \n"+data); + GCP_IoTHandler.sendCommand(device.getId(), GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); } private UpdateStatus simulateDownloads() { @@ -181,36 +188,13 @@ private UpdateStatus simulateDownloads() { LOGGER.info("Simulate downloads for {}", device.getId()); System.out.printf("Simulate downloads for {}", device.getId()); - + modules.forEach(module -> { module.getArtifacts().forEach( - artifact -> - { - try { - BucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), device.getTargetSecurityToken()); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } - handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact); - }); - }); - -// if(device.getId().contains("Charbel") || device.getId().contains("GCP")) -// { - try { - System.out.println("==========> Attempting download to the device \n"+payload); - GCP_IoTHandler.sendCommand(device.getId(), GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME, "This is a payload from HawkBit\n"+payload); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - //} + artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact)); + }); + + syncDownloadGCP(device.getId(), payload); final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); result.getStatusMessages().add("Simulator: Download complete!"); @@ -298,18 +282,18 @@ private static UpdateStatus readAndCheckDownloadUrl(final String url, final Stri //overallread = getOverallRead(response, md); payload = getPayload(response); -// if (overallread != size) { -// final String message = incompleteRead(url, size, overallread); -// return new UpdateStatus(ResponseStatus.ERROR, message); -// } -// -// sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); + // if (overallread != size) { + // final String message = incompleteRead(url, size, overallread); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } + // + // sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); } -// if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { -// final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); -// return new UpdateStatus(ResponseStatus.ERROR, message); -// } + // if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { + // final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; System.out.println("[DeviceSimulator] "+message); @@ -317,23 +301,23 @@ private static UpdateStatus readAndCheckDownloadUrl(final String url, final Stri return new UpdateStatus(ResponseStatus.SUCCESSFUL, message); } - private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md) - throws IOException { + private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md) + throws IOException { + + long overallread; - long overallread; + try (final OutputStream os = ByteStreams.nullOutputStream(); + final BufferedOutputStream bos = new BufferedOutputStream(new DigestOutputStream(os, md))) { - 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); + } + } - try (BufferedInputStream bis = new BufferedInputStream(response.getEntity().getContent())) { - overallread = ByteStreams.copy(bis, bos); - } - } + return overallread; + } - return overallread; - } - private static String getPayload(final CloseableHttpResponse response) throws IOException { try { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index dc9bf8a..8cc8529 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; import com.google.api.services.cloudiot.v1.model.Device; +import com.google.gson.Gson; /** * REST endpoint for controlling the device simulator. @@ -32,76 +33,78 @@ @RestController public class SimulationController { - private final DeviceSimulatorRepository repository; - - private final SimulatedDeviceFactory deviceFactory; - - private final AmqpProperties amqpProperties; - - private final SimulationProperties simulationProperties; - - @Autowired - public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { - this.repository = repository; - this.deviceFactory = deviceFactory; - this.amqpProperties = amqpProperties; - this.simulationProperties = simulationProperties; - } - - - /** - * The start resource to start a device creation. - * - * @param name - * the name prefix of the generated device naming - * @param amount - * the amount of devices to be created - * @param tenant - * the tenant to create the device to - * @param api - * the api-protocol to be used either {@code dmf} or {@code ddi} - * @param endpoint - * the URL endpoint to be used of the hawkbit-update-server for - * DDI devices - * @param pollDelay - * number of delay in seconds to delay polling of DDI - * devices - * @param gatewayToken - * the hawkbit-update-server gatewaytoken in case authentication - * is enforced in hawkbit - * @return a response string that devices has been created - * @throws MalformedURLException - */ - @GetMapping("/gcp") - ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, - @RequestParam(value = "amount", defaultValue = "1") final int amount, - @RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "api", defaultValue = "dmf") final String api, - @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, - @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, - @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) - throws MalformedURLException { - - final Protocol protocol; - switch (api.toLowerCase()) { - case "dmf": - protocol = Protocol.DMF_AMQP; - break; - case "ddi": - protocol = Protocol.DDI_HTTP; - break; - default: - return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); - } - - if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { - return ResponseEntity.badRequest() - .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" - + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); - } - - - try { + private final DeviceSimulatorRepository repository; + + private final SimulatedDeviceFactory deviceFactory; + + private final AmqpProperties amqpProperties; + + private final SimulationProperties simulationProperties; + + Gson gson = new Gson(); + + @Autowired + public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { + this.repository = repository; + this.deviceFactory = deviceFactory; + this.amqpProperties = amqpProperties; + this.simulationProperties = simulationProperties; + } + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/gcp") + ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "1") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + + try { List allDevices_gcp = GCP_IoTHandler.getAllDevices(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); for(Device gcp_device : allDevices_gcp) { @@ -110,161 +113,161 @@ ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulat createSimulatedDevice(gcp_device.getId(), simulationProperties.getDefaultTenant(), protocol, pollDelay, - new URL(endpoint), gatewayToken)); + new URL(endpoint), gatewayToken)); } - + } catch (GeneralSecurityException | IOException e) { e.printStackTrace(); - + + } + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/start") + ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "20") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); } - return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); - } - - - - /** - * The start resource to start a device creation. - * - * @param name - * the name prefix of the generated device naming - * @param amount - * the amount of devices to be created - * @param tenant - * the tenant to create the device to - * @param api - * the api-protocol to be used either {@code dmf} or {@code ddi} - * @param endpoint - * the URL endpoint to be used of the hawkbit-update-server for - * DDI devices - * @param pollDelay - * number of delay in seconds to delay polling of DDI - * devices - * @param gatewayToken - * the hawkbit-update-server gatewaytoken in case authentication - * is enforced in hawkbit - * @return a response string that devices has been created - * @throws MalformedURLException - */ - @GetMapping("/start") - ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, - @RequestParam(value = "amount", defaultValue = "20") final int amount, - @RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "api", defaultValue = "dmf") final String api, - @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, - @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, - @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) - throws MalformedURLException { - - final Protocol protocol; - switch (api.toLowerCase()) { - case "dmf": - protocol = Protocol.DMF_AMQP; - break; - case "ddi": - protocol = Protocol.DDI_HTTP; - break; - default: - return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); - } - - if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { - return ResponseEntity.badRequest() - .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" - + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); - } - - for (int i = 0; i < amount; i++) { - final String deviceId = name + i; - repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, - (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, - new URL(endpoint), gatewayToken)); - } - - return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); - } - - /** - * Update an attribute of a device. - * - * NOTE: This represents not the expected client behaviour for DDI, since a - * DDI client shall only update its attributes if requested by hawkBit. - * - * @param tenant - * The tenant the device belongs to - * @param controllerId - * The controller id of the device that should be updated. - * @param mode - * Update mode ('merge', 'replace', or 'remove') - * @param key - * Key of the attribute to be updated - * @param value - * Value of the attribute - * @return HTTP OK (200) if the update has been triggered. - */ - @GetMapping("/attributes") - ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "controllerid") final String controllerId, - @RequestParam(value = "mode", defaultValue = "merge") final String mode, - @RequestParam(value = "key") final String key, - @RequestParam(value = "value", required = false) final String value) { - - final AbstractSimulatedDevice simulatedDevice = repository - .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); - - if (simulatedDevice == null) { - return ResponseEntity.notFound().build(); - } - - simulatedDevice.updateAttribute(mode, key, value); - - return ResponseEntity.ok("Update triggered"); - } - - /** - * Remove a simulated device - * - * @param tenant - * The tenant the device belongs to - * @param controllerId - * The controller id of the device that should be removed. - * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) - * if not found. - */ - @GetMapping("/remove") - ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "controllerid") final String controllerId) { - - final AbstractSimulatedDevice controller = repository - .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); - - if (controller == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok("Deleted"); - } - - - @GetMapping("/hi") - ResponseEntity hi() { - return ResponseEntity.ok("hi"); - } - - - /** - * Reset the device simulator by removing all simulated devices - * - * @return A response string that the simulator has been reset - */ - @GetMapping("/reset") - ResponseEntity reset() { - - repository.clear(); - - return ResponseEntity.ok("All simulated devices have been removed."); - } - - private boolean isDmfDisabled() { - return !amqpProperties.isEnabled(); - } + + for (int i = 0; i < amount; i++) { + final String deviceId = name + i; + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + /** + * Update an attribute of a device. + * + * NOTE: This represents not the expected client behaviour for DDI, since a + * DDI client shall only update its attributes if requested by hawkBit. + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be updated. + * @param mode + * Update mode ('merge', 'replace', or 'remove') + * @param key + * Key of the attribute to be updated + * @param value + * Value of the attribute + * @return HTTP OK (200) if the update has been triggered. + */ + @GetMapping("/attributes") + ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId, + @RequestParam(value = "mode", defaultValue = "merge") final String mode, + @RequestParam(value = "key") final String key, + @RequestParam(value = "value", required = false) final String value) { + + final AbstractSimulatedDevice simulatedDevice = repository + .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (simulatedDevice == null) { + return ResponseEntity.notFound().build(); + } + + simulatedDevice.updateAttribute(mode, key, value); + + return ResponseEntity.ok("Update triggered"); + } + + /** + * Remove a simulated device + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be removed. + * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) + * if not found. + */ + @GetMapping("/remove") + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId) { + + final AbstractSimulatedDevice controller = repository + .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (controller == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok("Deleted"); + } + + + @GetMapping("/hi") + ResponseEntity hi() { + return ResponseEntity.ok("hi"); + } + + + /** + * Reset the device simulator by removing all simulated devices + * + * @return A response string that the simulator has been reset + */ + @GetMapping("/reset") + ResponseEntity reset() { + + repository.clear(); + + return ResponseEntity.ok("All simulated devices have been removed."); + } + + private boolean isDmfDisabled() { + return !amqpProperties.isEnabled(); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index f4aeb5a..4fc1fa1 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -14,7 +14,8 @@ import java.net.URL; import java.security.GeneralSecurityException; -import org.eclipse.hawkbit.google.gcp.BucketHandler; +import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; +import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; //import org.eclipse.hawkbit.google.gcp.BucketHandler; import org.slf4j.Logger; @@ -49,9 +50,10 @@ public class SimulatorStartup implements ApplicationListener { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 087b584..e9bcbe3 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -8,7 +8,10 @@ */ package org.eclipse.hawkbit.simulator.amqp; +import java.io.FileNotFoundException; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.util.Arrays; import java.util.HashSet; import java.util.Map; @@ -20,6 +23,7 @@ import org.eclipse.hawkbit.dmf.amqp.api.MessageType; import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; +import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; @@ -201,7 +205,7 @@ void checkDmfHealth() { final String correlationId = UUID.randomUUID().toString(); spSenderService.ping(tenant, correlationId); openPings.add(correlationId); - LOGGER.debug("Ping tenant {} with correlationId {}", tenant, correlationId); + LOGGER.debug("Ping tenant {%s} with correlationId {%s}", tenant, correlationId); }); } @@ -272,6 +276,23 @@ private void handleUpdateProcess(final Message message, final String thingId, fi final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); + downloadAndUpdateRequest.getSoftwareModules().forEach(module -> { + module.getArtifacts().forEach( + artifact -> + { + try { + GCPBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } + }); + }); + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, null, device -> sendFeedback(actionId, device), actionType); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index 6964732..a8424fd 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -83,7 +83,7 @@ public void ping(final String tenant, final String correlationId) { * indicating whether to download and install or skip * installation due to maintenance window. */ - public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { System.out.println("[DmfSenderService] init"); + public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { System.out.println("[DmfSenderService] Update Process"); final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, updateResultMessages); From 07c18ea3f3944801db38e47068ca831d566e5798 Mon Sep 17 00:00:00 2001 From: charbull Date: Sun, 24 Feb 2019 22:13:13 -0500 Subject: [PATCH 11/54] pubsub and device state fw to update hawkbit --- .../java/org/eclipse/hawkbit/simulator/SimulatorStartup.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 4fc1fa1..6a89e3d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -53,7 +53,7 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); GCP_Subscriber.init(); - + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket simulationProperties.getAutostarts().forEach(autostart -> { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); From fbe3d8dca7804f38f2a154b2c3bd1787bc701905 Mon Sep 17 00:00:00 2001 From: charbull Date: Sun, 24 Feb 2019 22:27:37 -0500 Subject: [PATCH 12/54] fixed sync vs async --- .../src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java | 2 +- .../java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 3c4faa1..044d25e 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -7,5 +7,5 @@ public class GCP_OTA { public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; public final static String BUCKET_NAME = "firmware-ota"; public final static String SUBSCRIPTION_STATE_ID = "state"; - public final static boolean FW_VIA_COMMAND = false; + public final static boolean FW_VIA_COMMAND = true; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index 75db33e..5c23ac6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -60,7 +60,9 @@ public static void init() { // Continue to listen to messages while (true) { PubsubMessage message = messages.take(); - updateHawkbitStatus(message); + if(!GCP_OTA.FW_VIA_COMMAND) { + updateHawkbitStatus(message); + } } } catch (InterruptedException e) { e.printStackTrace(); From 5ec97e5e267a82758479bac048f2575f85321143 Mon Sep 17 00:00:00 2001 From: charbull Date: Sun, 24 Feb 2019 22:59:49 -0500 Subject: [PATCH 13/54] update readme --- hawkbit-device-simulator/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index e0370c3..826f76c 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -22,7 +22,8 @@ install the following: - Set the projectId and the cloud region in the GCP_OTA.java - Create a `state` subscription on the state topic -- Create a bucket: gsutil mb gs:/// +- Create a bucket: gsutil mb gs:/firmware-ota/ +- enable the Token Service API: `cloud iot token` # hawkBit Device Simulator From 0b3de0358cdf5423edba31590c06cfcd7561c4ca Mon Sep 17 00:00:00 2001 From: charbull Date: Mon, 25 Feb 2019 16:27:14 -0500 Subject: [PATCH 14/54] bug fix on rollout --- hawkbit-device-simulator/README.md | 6 +++ .../hawkbit/google/gcp/GCPBucketHandler.java | 1 - .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 4 +- .../hawkbit/google/gcp/GCP_Subscriber.java | 38 +++++++++++++++---- .../gcp/HawkbitSoftwareModuleHandler.java | 3 -- .../simulator/amqp/DmfReceiverService.java | 4 +- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 826f76c..aaa6a10 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -69,6 +69,12 @@ sudo docker swarm init sudo docker stack deploy -c docker-compose-stack.yml hawkbit ``` + +## MySQL Info + MYSQL_DATABASE: "hawkbit" + MYSQL_USER: "root" + port : 3306 + ## Run on your own workstation ``` java -jar examples/hawkbit-device-simulator/target/hawkbit-device-simulator-*-SNAPSHOT.jar diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java index e12bdad..8184eb1 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java @@ -68,7 +68,6 @@ private static Storage getStorage() { public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { - listBuckets(); Storage gcs = getStorage(); String data = HawkbitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); if(!checkIfExists(artifactName)) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 044d25e..2114513 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -5,7 +5,7 @@ public class GCP_OTA { public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; - public final static String BUCKET_NAME = "firmware-ota"; + public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; public final static String SUBSCRIPTION_STATE_ID = "state"; - public final static boolean FW_VIA_COMMAND = true; + public final static boolean FW_VIA_COMMAND = false; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index 5c23ac6..d893d00 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -13,6 +13,7 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; import org.eclipse.hawkbit.simulator.UpdateStatus; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; @@ -78,6 +79,7 @@ private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { if(data != null) { long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, GCP_OTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, GCP_OTA.REGISTRY_NAME, data, configVersion); } @@ -85,6 +87,22 @@ private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { } + private static void sendUpate(String deviceId, UpdateStatus updateStatus) { + AbstractSimulatedDevice device = mapDevices.get(deviceId); + UpdaterCallback callback = mapCallbacks.get(deviceId); + if(device != null && callback != null) { + device.setUpdateStatus(updateStatus); + callback.sendFeedback(device); + } else { + if(device == null) { + LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); + } + if(callback == null) { + LOGGER.error("Map didnt find callback on "+ updateStatus.getResponseStatus().toString()); + } + } + } + public static void updateHawkbitStatus(PubsubMessage message){ System.out.println("Message Id: " + message.getMessageId()); //{"deviceId":"CharbelDevice","fw-state":"installed"} @@ -95,24 +113,25 @@ public static void updateHawkbitStatus(PubsubMessage message){ String fw_state = stateFromDevice.get("fw-state").getAsString(); if(deviceId != null && fw_state != null) { - AbstractSimulatedDevice device = mapDevices.get(deviceId); - UpdaterCallback callback = mapCallbacks.get(deviceId); - - UpdateStatus updateStatus = null; switch (fw_state) { case "msg-received" : updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + sendUpate(deviceId, updateStatus); break; case "installing" : updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + sendUpate(deviceId, updateStatus); break; case "downloading" : updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + sendUpate(deviceId, updateStatus); break; case "installed": updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + sendUpate(deviceId, updateStatus); + //remove device and callback mapCallbacks.remove(deviceId); mapDevices.remove(deviceId); @@ -121,8 +140,9 @@ public static void updateHawkbitStatus(PubsubMessage message){ LOGGER.error("Unknown fw-state: "+fw_state); break; } - device.setUpdateStatus(updateStatus); - callback.sendFeedback(device); + + } else { + LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); } } @@ -132,7 +152,8 @@ public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback LOGGER.info("Update device with eventTopic: %s",actionType); - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + //if the device is still updating, wait until it is finished + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL && !mapDevices.containsKey(device.getId())) { LOGGER.info("[GCP Async] Download & Install"); List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) @@ -145,5 +166,8 @@ public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback mapDevices.put(device.getId(), device); //device.clean(); } + else { + LOGGER.error("Unsupported actionType: "+actionType); + } } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java index fc6b952..576d7f6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkbitSoftwareModuleHandler.java @@ -4,7 +4,6 @@ import java.io.InputStream; import java.security.KeyManagementException; import java.security.KeyStoreException; -import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import org.apache.http.client.methods.CloseableHttpResponse; @@ -13,8 +12,6 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContextBuilder; -import org.eclipse.hawkbit.simulator.UpdateStatus; -import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index e9bcbe3..2bcfab7 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -226,7 +226,7 @@ private void handleEventMessage(final Message message, final String thingId) { switch (eventTopic) { case DOWNLOAD_AND_INSTALL: case DOWNLOAD: - System.out.println("[DmfReceiverService] ===============> Download"); + System.out.println("[DmfReceiverService] Download"); System.out.println(toStringMessage(message)); handleUpdateProcess(message, thingId, eventTopic); break; @@ -251,7 +251,7 @@ private void handleAttributeUpdateRequest(final Message message, final String th spSenderService.updateAttributesOfThing(tenant, thingId); } - private void handleCancelDownloadAction(final Message message, final String thingId) { + public void handleCancelDownloadAction(final Message message, final String thingId) { System.out.println("[DmfReceiverService] handling Cancel/Download Action "+thingId); final MessageProperties messageProperties = message.getMessageProperties(); From 059cd92355fb91b4c9573f4ee92a356e0fdaa4c8 Mon Sep 17 00:00:00 2001 From: charbull Date: Mon, 25 Feb 2019 17:08:54 -0500 Subject: [PATCH 15/54] firestore init --- hawkbit-device-simulator/pom.xml | 5 ++ .../hawkbit/google/gcp/GCP_FireStore.java | 67 +++++++++++++++++++ .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 1 + .../hawkbit/simulator/SimulatorStartup.java | 13 +++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index c5712be..6e63cc0 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -191,6 +191,11 @@ google-api-services-storage v1-rev149-1.25.0 + + com.google.cloud + google-cloud-firestore + 0.70.0-beta + junit diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java new file mode 100644 index 0000000..03591f0 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -0,0 +1,67 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.core.ApiFuture; +import com.google.api.services.iam.v1.IamScopes; +import com.google.api.services.storage.model.StorageObject; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.FirestoreOptions; +import com.google.cloud.firestore.WriteResult; + +public class GCP_FireStore { + + private static Firestore db; + + public static void init() { + + try { + ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + FirestoreOptions firestoreOptions = + FirestoreOptions.newBuilder().setTimestampsInSnapshotsEnabled(true) + .setProjectId(GCP_OTA.PROJECT_ID).setCredentials(credentials) + .build(); + db = firestoreOptions.getService(); + + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void addDocument(String deviceId, StorageObject storageObject) { + + + try { + DocumentReference docRef = db.collection(GCP_OTA.FIRESTORE_STATE_COLLECTION).document(deviceId); + Map data = new HashMap<>(); + data.put("first", "Ada"); + data.put("last", "Lovelace"); + data.put("born", 1815); + //asynchronously write data + ApiFuture result = docRef.set(data); + System.out.println("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (ExecutionException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 2114513..f9a7f03 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -8,4 +8,5 @@ public class GCP_OTA { public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; public final static String SUBSCRIPTION_STATE_ID = "state"; public final static boolean FW_VIA_COMMAND = false; + public final static String FIRESTORE_STATE_COLLECTION = "State"; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 6a89e3d..162fe06 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -15,6 +15,7 @@ import java.security.GeneralSecurityException; import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; +import org.eclipse.hawkbit.google.gcp.GCP_FireStore; import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; //import org.eclipse.hawkbit.google.gcp.BucketHandler; @@ -52,8 +53,16 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { System.out.println("AutoStarting application ..."); LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); - GCP_Subscriber.init(); + + System.out.println("Trying Firestore ... "); + GCP_FireStore.init(); + GCP_FireStore.addDocument("GCP_Test"); + + + + + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket simulationProperties.getAutostarts().forEach(autostart -> { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); @@ -71,6 +80,8 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { } } }); + + GCP_Subscriber.init(); } } From 5856a5bc3ea96779505c08064602124b8543ad49 Mon Sep 17 00:00:00 2001 From: charbull Date: Mon, 25 Feb 2019 17:11:15 -0500 Subject: [PATCH 16/54] init firestore --- .../java/org/eclipse/hawkbit/simulator/SimulatorStartup.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 162fe06..ff59add 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -57,7 +57,7 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { System.out.println("Trying Firestore ... "); GCP_FireStore.init(); - GCP_FireStore.addDocument("GCP_Test"); + GCP_FireStore.addDocument("GCP_Test", null); From d949ac749d02de10ed480731106b6abdafcb65cb Mon Sep 17 00:00:00 2001 From: charbull Date: Tue, 26 Feb 2019 13:20:18 -0500 Subject: [PATCH 17/54] firestore integration bug fixing for fw rollout --- hawkbit-device-simulator/pom.xml | 14 +- .../hawkbit/google/gcp/GCPBucketHandler.java | 24 ++ .../hawkbit/google/gcp/GCP_FireStore.java | 25 +- .../hawkbit/google/gcp/GCP_IoTHandler.java | 214 ++++++++++-------- .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 5 +- .../hawkbit/google/gcp/GCP_Subscriber.java | 142 +++++++----- .../simulator/DeviceSimulatorUpdater.java | 1 - .../hawkbit/simulator/SimulatorStartup.java | 17 +- .../simulator/amqp/DmfSenderService.java | 2 +- 9 files changed, 249 insertions(+), 195 deletions(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 6e63cc0..acdebe6 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -144,21 +144,11 @@ google-api-services-cloudiot v1-rev20181120-1.27.0 - - com.google.cloud - google-cloud-pubsub - 0.24.0-beta - - + com.google.api-client google-api-client diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java index 8184eb1..59c5120 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java @@ -9,7 +9,9 @@ import java.io.UnsupportedEncodingException; import java.security.GeneralSecurityException; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -99,6 +101,28 @@ public static String getFirmwareInfoBucket(String artifactName) return null; } + + public static Map> getFirmwareInfoBucket_Map(String artifactName) + { + try { + StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map> fw_update = new HashMap<>(1); + Map mapContent = new HashMap<>(3); + LOGGER.debug(artifactName+" exists!"); + mapContent.put("ObjectName", storageObject.getName()); + mapContent.put("Url", storageObject.getMediaLink()); + mapContent.put("Md5Hash", storageObject.getMd5Hash()); + fw_update.put("firmware-update", mapContent); + return fw_update; + } + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + private static boolean checkIfExists(String artifactName) throws IOException { Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 03591f0..37c7311 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -4,14 +4,11 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.util.Collections; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.core.ApiFuture; import com.google.api.services.iam.v1.IamScopes; -import com.google.api.services.storage.model.StorageObject; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; @@ -41,24 +38,20 @@ public static void init() { e.printStackTrace(); } } - - public static void addDocument(String deviceId, StorageObject storageObject) { - - + + + public static void addDocument(String deviceId, Map> map) { try { - DocumentReference docRef = db.collection(GCP_OTA.FIRESTORE_STATE_COLLECTION).document(deviceId); - Map data = new HashMap<>(); - data.put("first", "Ada"); - data.put("last", "Lovelace"); - data.put("born", 1815); - //asynchronously write data - ApiFuture result = docRef.set(data); + DocumentReference docRef = db + .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(map); System.out.println("Update time : " + result.get().getUpdateTime()); } catch (InterruptedException e) { - // TODO Auto-generated catch block e.printStackTrace(); } catch (ExecutionException e) { - // TODO Auto-generated catch block e.printStackTrace(); } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java index f32bead..4cb8fb3 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java @@ -9,9 +9,11 @@ import java.util.Base64; import java.util.List; +import org.apache.log4j.spi.LoggerFactory; import org.eclipse.paho.client.mqttv3.MqttClient; import org.eclipse.paho.client.mqttv3.MqttException; import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.logging.Logger; import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; @@ -39,7 +41,6 @@ import com.google.common.io.Files; - public class GCP_IoTHandler { public static GoogleCredential getCredentialsFromFile() @@ -180,6 +181,57 @@ public static List listDevices(String projectId, String cloudRegion, Str return devices; } + public static boolean atLeastOnceConnected(String deviceId) { + try { + return atLeastOnceConnected(deviceId, + GCP_OTA.PROJECT_ID, + GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + return false; + } + + + private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + String lastTimeEvent = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastEventTime(); + + String lastHRbeat = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastHeartbeatTime(); + System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); + return (lastTimeEvent !=null || lastHRbeat!=null); + } + + + /** Create a device to bind to a gateway. */ @@ -345,58 +397,36 @@ public static List listRegistries(String projectId, String cloud } - - // @SuppressWarnings("deprecation") - // public static String uploadFile(Part filePart, final String bucketName) throws IOException { - // DateTimeFormatter dtf = DateTimeFormat.forPattern("-YYYY-MM-dd-HHmmssSSS"); - // DateTime dt = DateTime.now(DateTimeZone.UTC); - // String dtString = dt.toString(dtf); - // final String fileName = filePart.getSubmittedFileName() + dtString; - // - // // the inputstream is closed by default, so we don't need to close it here - // BlobInfo blobInfo = - // storage.create( - // BlobInfo - // .newBuilder(bucketName, fileName) - // // Modify access list to allow all users with link to read file - // .setAcl(new ArrayList<>(Arrays.asList(Acl.of(User.ofAllUsers(), Role.READER)))) - // .build(), - // filePart.getInputStream()); - // // return the public download link - // return blobInfo.getMediaLink(); - // } - - /** List all of the configs for the given device. */ public static void listDeviceConfigs( String deviceId, String projectId, String cloudRegion, String registryName) - { + { try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - System.out.println("Listing device configs for " + devicePath); - List deviceConfigs = - service - .projects() - .locations() - .registries() - .devices() - .configVersions() - .list(devicePath) - .execute() - .getDeviceConfigs(); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); - for (DeviceConfig config : deviceConfigs) { - System.out.println("Config version: " + config.getVersion()); - System.out.println("Contents: " + config.getBinaryData()); - System.out.println(); - } + System.out.println("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + for (DeviceConfig config : deviceConfigs) { + System.out.println("Config version: " + config.getVersion()); + System.out.println("Contents: " + config.getBinaryData()); + System.out.println(); + } } catch (Exception e) { e.printStackTrace(); @@ -407,47 +437,47 @@ public static void listDeviceConfigs( /** List all of the configs for the given device. */ public static long getLatestConfig( String deviceId, String projectId, String cloudRegion, String registryName) - { + { long configVersion = 0; try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); - System.out.println("Listing device configs for " + devicePath); - List deviceConfigs = - service - .projects() - .locations() - .registries() - .devices() - .configVersions() - .list(devicePath) - .execute() - .getDeviceConfigs(); - - - for (DeviceConfig config : deviceConfigs) { - System.out.println("Config version: " + config.getVersion()); - System.out.println("Contents: " + config.getBinaryData()); - if(configVersion < config.getVersion()) - { - configVersion = config.getVersion(); + System.out.println("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + + for (DeviceConfig config : deviceConfigs) { + System.out.println("Config version: " + config.getVersion()); + System.out.println("Contents: " + config.getBinaryData()); + if(configVersion < config.getVersion()) + { + configVersion = config.getVersion(); + } } - } } catch (Exception e) { e.printStackTrace(); } - + return configVersion; } - + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ public static void setDeviceConfiguration( String deviceId, String projectId, String cloudRegion, String registryName, @@ -518,26 +548,26 @@ public static List getDeviceStates( public static void sendCommand( String deviceId, String projectId, String cloudRegion, String registryName, String data) - { + { try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); - final String devicePath = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); + final String devicePath = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); - SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); + SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - System.out.printf("Sending command to %s\n", devicePath); + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + System.out.printf("Sending command to %s\n", devicePath); SendCommandToDeviceResponse res = service .projects() diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index f9a7f03..7d39762 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -8,5 +8,8 @@ public class GCP_OTA { public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; public final static String SUBSCRIPTION_STATE_ID = "state"; public final static boolean FW_VIA_COMMAND = false; - public final static String FIRESTORE_STATE_COLLECTION = "State"; + public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; + public final static String FIRESTORE_CONFIG_COLLECTION = "config"; + } + diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index d893d00..35f351c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -13,7 +13,6 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; -import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; import org.eclipse.hawkbit.simulator.UpdateStatus; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; @@ -61,6 +60,9 @@ public static void init() { // Continue to listen to messages while (true) { PubsubMessage message = messages.take(); + System.out.println("Message Id: " + message.getMessageId()); + //{"deviceId":"CharbelDevice","fw-state":"installed"} + System.out.println("Data: " + message.getData().toStringUtf8()); if(!GCP_OTA.FW_VIA_COMMAND) { updateHawkbitStatus(message); } @@ -74,6 +76,62 @@ public static void init() { } } + + + public static void updateHawkbitStatus(PubsubMessage message){ + JsonObject payloadJson = gson.fromJson(message.getData() + .toStringUtf8(), JsonObject.class); + if(payloadJson.has("fw-state") && payloadJson.has("deviceId")) { + String deviceId = payloadJson.get("deviceId").getAsString(); + String fw_state = payloadJson.get("fw-state").getAsString(); + + if(deviceId != null && fw_state != null) { + UpdateStatus updateStatus = null; + + switch (fw_state) { + case "msg-received" : + updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + sendUpate(deviceId, updateStatus); + break; + case "installing" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + sendUpate(deviceId, updateStatus); + break; + case "downloading" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + sendUpate(deviceId, updateStatus); + break; + case "installed": + updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + sendUpate(deviceId, updateStatus); + + //remove device and callback + mapCallbacks.remove(deviceId); + mapDevices.remove(deviceId); + + break; + default: + LOGGER.error("Unknown fw-state: "+fw_state); + updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); + sendUpate(deviceId, updateStatus); + break; + } + + } else { + LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); + + //Device never connected + if(!GCP_IoTHandler.atLeastOnceConnected(deviceId)) { + LOGGER.error(deviceId+" : device was never connected"); + sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); + } + } + } else { + LOGGER.debug("Ignoring message"); + } + + } + private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { String data = GCPBucketHandler.getFirmwareInfoBucket(artifactName); if(data != null) { @@ -82,6 +140,9 @@ private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, GCP_OTA.REGISTRY_NAME, data, configVersion); + + LOGGER.info("Writing to Firestore "); + GCP_FireStore.addDocument(deviceId, GCPBucketHandler.getFirmwareInfoBucket_Map(artifactName)); } else LOGGER.error(artifactName+" not found in bucket for device "+deviceId); } @@ -95,79 +156,42 @@ private static void sendUpate(String deviceId, UpdateStatus updateStatus) { callback.sendFeedback(device); } else { if(device == null) { - LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); + LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); } if(callback == null) { LOGGER.error("Map didnt find callback on "+ updateStatus.getResponseStatus().toString()); - } - } - } - - public static void updateHawkbitStatus(PubsubMessage message){ - System.out.println("Message Id: " + message.getMessageId()); - //{"deviceId":"CharbelDevice","fw-state":"installed"} - System.out.println("Data: " + message.getData().toStringUtf8()); - JsonObject stateFromDevice = gson.fromJson(message.getData() - .toStringUtf8(), JsonObject.class); - String deviceId = stateFromDevice.get("deviceId").getAsString(); - String fw_state = stateFromDevice.get("fw-state").getAsString(); - - if(deviceId != null && fw_state != null) { - UpdateStatus updateStatus = null; - - switch (fw_state) { - case "msg-received" : - updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); - sendUpate(deviceId, updateStatus); - break; - case "installing" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); - sendUpate(deviceId, updateStatus); - break; - case "downloading" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); - sendUpate(deviceId, updateStatus); - break; - case "installed": - updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); - sendUpate(deviceId, updateStatus); - - //remove device and callback - mapCallbacks.remove(deviceId); - mapDevices.remove(deviceId); - break; - default: - LOGGER.error("Unknown fw-state: "+fw_state); - break; } - - } else { - LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); } - } public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback callback, List modules, EventTopic actionType) { - LOGGER.info("Update device with eventTopic: %s",actionType); + LOGGER.info("Update device with eventTopic: "+actionType); //if the device is still updating, wait until it is finished - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL && !mapDevices.containsKey(device.getId())) { - LOGGER.info("[GCP Async] Download & Install"); - - List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) - .map(art -> art.getFilename()) - .collect(Collectors.toList()); - - fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); - - mapCallbacks.put(device.getId(), callback); - mapDevices.put(device.getId(), device); - //device.clean(); + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL + || actionType == EventTopic.DOWNLOAD) { + if(!mapDevices.containsKey(device.getId())) { + LOGGER.info("ActionType "+actionType); + + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> art.getFilename()) + .collect(Collectors.toList()); + + fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); + + mapCallbacks.put(device.getId(), callback); + mapDevices.put(device.getId(), device); + } else { + LOGGER.error("Device ID already exist on actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.RUNNING, "Payload Reached")); + } } else { LOGGER.error("Unsupported actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.ERROR, "Unsupported Action")); + } } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index 6830d43..d5b5850 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -33,7 +33,6 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; import org.eclipse.hawkbit.google.gcp.GCP_OTA; import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index ff59add..487e26e 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -8,13 +8,9 @@ */ package org.eclipse.hawkbit.simulator; -import java.io.FileNotFoundException; -import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.security.GeneralSecurityException; -import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; import org.eclipse.hawkbit.google.gcp.GCP_FireStore; import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; @@ -55,14 +51,11 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { - System.out.println("Trying Firestore ... "); + LOGGER.debug("Init Firestore ... "); GCP_FireStore.init(); - GCP_FireStore.addDocument("GCP_Test", null); - - - - - + LOGGER.debug("Init Subscriber ... "); + GCP_Subscriber.init(); + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket simulationProperties.getAutostarts().forEach(autostart -> { LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); @@ -80,8 +73,6 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { } } }); - - GCP_Subscriber.init(); } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index a8424fd..d8016de 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -243,7 +243,7 @@ public void createOrUpdateThing(final String tenant, final String targetId) { * the ID of the target to create or update */ public void updateAttributesOfThing(final String tenant, final String targetId) { - System.out.printf("Create update attributes message and send to update server for Thing \"{}\"", targetId); + System.out.printf("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); sendMessage(spExchange, updateAttributes(tenant, targetId, DmfUpdateMode.MERGE, simulationProperties.getAttributes().stream().collect(Collectors .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); From 50921ca225e86aed194b935938903a287a9446e9 Mon Sep 17 00:00:00 2001 From: charbull Date: Tue, 26 Feb 2019 16:12:38 -0500 Subject: [PATCH 18/54] fixed NPE on cancel download updated message print --- .../simulator/amqp/DmfReceiverService.java | 94 ++++++++----------- .../simulator/amqp/DmfSenderService.java | 43 ++------- 2 files changed, 49 insertions(+), 88 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 2bcfab7..b5d69be 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -37,6 +37,9 @@ import org.springframework.messaging.handler.annotation.Header; import org.springframework.scheduling.annotation.Scheduled; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + /** * Handle all incoming Messages from hawkBit update server. @@ -54,6 +57,8 @@ public class DmfReceiverService extends MessageService { private final Set openPings = new HashSet(); + private Gson gson = new Gson(); + /** * Constructor. * @@ -123,7 +128,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP final MessageType messageType = MessageType.valueOf(type); - System.out.println("[DmfReceiverService] Message received "+toStringMessage(message)); + System.out.println("[DmfReceiverService] Message received :\n"+message.toString()); if (MessageType.EVENT.equals(messageType)) { checkContentTypeJson(message); @@ -158,35 +163,6 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP } } - - String toStringMessage(Message m) - { - StringBuilder sb = new StringBuilder("Message content:\n"); - MessageProperties prop = m.getMessageProperties(); -// sb.append("-AppId: ").append(prop.getAppId()). -// append("\n-MessageId: ").append(prop.getMessageId()). -// append("\n-Type: ").append(prop.getType()). -// append("\n-ClutsterId: ").append(prop.getClusterId()). -// append("\n-ConsumerQueue: ").append(prop.getConsumerQueue()). -// append("\n-ContentType: ").append(prop.getContentType()). -// append("\n-CorrelationString: ").append(prop.getCorrelationIdString()). -// append("\n-ConsumerTag: ").append(prop.getConsumerTag()). -// append("\n-DeliveryTag: ").append(prop.getDeliveryTag()). -// append("\n-TimeStamp: ").append(prop.getTimestamp()). -// append("\n-ReceivedExchange: ").append(prop.getReceivedExchange()). -// append("\n-UserId: ").append(prop.getUserId()). - //append("\n-DeliveryMode: ").append(prop.getDeliveryMode().name()). - sb.append("\n-RoutingKey: ").append(prop.getReceivedRoutingKey()); - - prop.getHeaders().entrySet().stream().forEach( item -> - { - sb.append("\n--").append(item).append(":").append(prop.getHeaders().get(item)); - }); - - - return sb.toString(); - } - @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) void checkDmfHealth() { System.out.println("[DmfReceiverService] Message CheckDmfHealth "); @@ -226,14 +202,15 @@ private void handleEventMessage(final Message message, final String thingId) { switch (eventTopic) { case DOWNLOAD_AND_INSTALL: case DOWNLOAD: - System.out.println("[DmfReceiverService] Download"); - System.out.println(toStringMessage(message)); + System.out.println("[DmfReceiverService] Download with message:\n"+message.toString()); handleUpdateProcess(message, thingId, eventTopic); break; case CANCEL_DOWNLOAD: + System.out.println("[DmfReceiverService] Cancel Download with message:\n"+message.toString()); handleCancelDownloadAction(message, thingId); break; case REQUEST_ATTRIBUTES_UPDATE: + System.out.println("[DmfReceiverService] Attributes update with message:\n"+message.toString()); handleAttributeUpdateRequest(message, thingId); break; default: @@ -246,8 +223,7 @@ private void handleAttributeUpdateRequest(final Message message, final String th final MessageProperties messageProperties = message.getMessageProperties(); final Map headers = messageProperties.getHeaders(); final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - System.out.println("[DmfReceiverService] handleAttributeUpdateRequest event "+thingId); - System.out.println(toStringMessage(message)); + System.out.println("[DmfReceiverService] handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); spSenderService.updateAttributesOfThing(tenant, thingId); } @@ -257,10 +233,16 @@ public void handleCancelDownloadAction(final Message message, final String thing final MessageProperties messageProperties = message.getMessageProperties(); final Map headers = messageProperties.getHeaders(); final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - final Long actionId = convertMessage(message, Long.class); - - final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); - spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + System.out.println(message.toString()); + //final Long actionId = convertMessage(message, Long.class); + JsonObject actionIdJson = gson.fromJson(message.getBody().toString(), JsonObject.class); + if(actionIdJson.has("actionId")) { + final long actionId = actionIdJson.get("actionId").getAsLong(); + final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); + spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + } else { + LOGGER.error("Action ID does not exist in message: "+message.toString()); + } } private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { @@ -276,23 +258,29 @@ private void handleUpdateProcess(final Message message, final String thingId, fi final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); + + //TODO: This does not work if the there are two or more fws (os and app) + // Following options to consider: + //1- merge into one ZIP and upload to Bucket instead and point the device to it + //2- upload each file separately and upload it to a folder which is the device id, and point the device to the folder downloadAndUpdateRequest.getSoftwareModules().forEach(module -> { module.getArtifacts().forEach( - artifact -> - { - try { - GCPBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } - }); - }); - - + artifact -> + { + try { + System.out.println("Handling artifact : "+artifact.getFilename()); + GCPBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } + }); + }); + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, null, device -> sendFeedback(actionId, device), actionType); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index d8016de..d35ac3b 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -114,11 +114,13 @@ public void finishUpdateProcessWithError(final SimulatedUpdate update, final Lis * the amqp message which will be send if its not null */ public void sendMessage(final String address, final Message message) { - System.out.println("[DmfSenderService] send message "+toStringMessage(message)); + if (message == null) { + System.out.println("[DmfSenderService] received a null message"); return; } + System.out.println("[DmfSenderService] send message "+message.toString()); message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); final String correlationId = UUID.randomUUID().toString(); @@ -153,11 +155,8 @@ private static boolean isCorrelationIdEmpty(final Message message) { * @return converted message */ public Message convertMessage(final Object object, final MessageProperties messageProperties) { - System.out.println("[DmfSenderService] convert Message"); - - Message m = rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); - System.out.println("Converted Message "+toStringMessage(m)); + System.out.println("[DmfSenderService] Converted Message "+m.toString()); return m; } @@ -248,7 +247,7 @@ public void updateAttributesOfThing(final String tenant, final String targetId) simulationProperties.getAttributes().stream().collect(Collectors .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); - LOGGER.info("Create update attributes message and send to update server for Thing \"{}\"", targetId); + LOGGER.info("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); } /** @@ -309,37 +308,11 @@ private Message updateAttributes(final String tenant, final String targetId, fin attributeUpdate.getAttributes().putAll(attributes); Message m = convertMessage(attributeUpdate, messagePropertiesForSP); - System.out.println("Converted Message "+toStringMessage(m)); + System.out.println("Converted Message "+m.toString()); return m; } - String toStringMessage(Message m) - { - StringBuilder sb = new StringBuilder("Message content:\n"); - MessageProperties prop = m.getMessageProperties(); -// sb.append("-AppId: ").append(prop.getAppId()). -// append("\n-MessageId: ").append(prop.getMessageId()). -// append("\n-Type: ").append(prop.getType()). -// append("\n-ClutsterId: ").append(prop.getClusterId()). -// append("\n-ConsumerQueue: ").append(prop.getConsumerQueue()). -// append("\n-ContentType: ").append(prop.getContentType()). -// append("\n-CorrelationString: ").append(prop.getCorrelationIdString()). -// append("\n-ConsumerTag: ").append(prop.getConsumerTag()). -// append("\n-DeliveryTag: ").append(prop.getDeliveryTag()). -// append("\n-TimeStamp: ").append(prop.getTimestamp()). -// append("\n-ReceivedExchange: ").append(prop.getReceivedExchange()). -// append("\n-UserId: ").append(prop.getUserId()). -// append("\n-DeliveryMode: ").append(prop.getDeliveryMode().name()). - sb.append("\n-RoutingKey: ").append(prop.getReceivedRoutingKey()); - - prop.getHeaders().entrySet().stream().forEach( item -> - { - sb.append("\n--").append(item).append(":").append(prop.getHeaders().get(item)); - }); - - - return sb.toString(); - } + /** * Send a created message to SP. @@ -348,7 +321,7 @@ String toStringMessage(Message m) * the message to get send */ private void sendMessage(final Message message) { - LOGGER.info("[DmfSenderService] sending "+toStringMessage(message)); + LOGGER.info("[DmfSenderService] sending "+message.toString()); sendMessage(spExchange, message); } From c49f8a4cbcad987bbdc2ee3aea4a946d08bdfb4a Mon Sep 17 00:00:00 2001 From: charbull Date: Tue, 26 Feb 2019 21:28:03 -0500 Subject: [PATCH 19/54] support for multi os/app upload send firebase to MQTT working --- .../hawkbit/google/gcp/GCPBucketHandler.java | 119 +++++++++++------- .../hawkbit/google/gcp/GCP_FireStore.java | 17 +++ .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 11 +- .../hawkbit/google/gcp/GCP_Subscriber.java | 57 ++++++++- 4 files changed, 150 insertions(+), 54 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java index 59c5120..5452cec 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java @@ -8,11 +8,14 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.security.GeneralSecurityException; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,7 +43,7 @@ public class GCPBucketHandler { private static Storage storage = null; static Gson gson = new Gson(); - + private static HttpTransport httpTransport; private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); @@ -78,51 +81,72 @@ public static void uploadFirmwareToBucket(String fileUrl, String artifactName, S uploadSimple(gcs, GCP_OTA.BUCKET_NAME, artifactName, data); } } - + public static String getFirmwareInfoBucket(String artifactName) { - try { - StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); - if(storageObject != null) - { - JsonObject jsonObject = new JsonObject(); - LOGGER.debug(artifactName+" exists!"); - jsonObject.addProperty("ObjectName", storageObject.getName()); - jsonObject.addProperty("Url", storageObject.getMediaLink()); - jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); + StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + JsonObject jsonObject = new JsonObject(); + LOGGER.debug(artifactName+" exists!"); + jsonObject.addProperty("ObjectName", storageObject.getName()); + jsonObject.addProperty("Url", storageObject.getMediaLink()); + jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); - JsonObject jsonConfig = new JsonObject(); - jsonConfig.add("firmware-update", jsonObject); - return gson.toJson(jsonConfig); - } - } catch (IOException e) { - e.printStackTrace(); + JsonObject jsonConfig = new JsonObject(); + jsonConfig.add("firmware-update", jsonObject); + return gson.toJson(jsonConfig); } return null; } - + public static Map> getFirmwareInfoBucket_Map(String artifactName) { - try { + StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map> fw_update = new HashMap<>(1); + Map mapContent = new HashMap<>(3); + LOGGER.debug(artifactName+" exists!"); + mapContent.put(GCP_OTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GCP_OTA.URL, storageObject.getMediaLink()); + mapContent.put(GCP_OTA.MD5HASH, storageObject.getMd5Hash()); + fw_update.put(GCP_OTA.FW_UPDATE, mapContent); + return fw_update; + } + return null; + } + + + public static Map>> getFirmwareInfoBucket_MapList(List modules) + { + Map>> fw_update_Map = + new HashMap>>(1); + + + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> art.getFilename()) + .collect(Collectors.toList()); + + List> list_fw_update = new ArrayList<>(fwNameList.size()); + + fwNameList.forEach(artifactName -> { StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); if(storageObject != null) { - Map> fw_update = new HashMap<>(1); Map mapContent = new HashMap<>(3); LOGGER.debug(artifactName+" exists!"); - mapContent.put("ObjectName", storageObject.getName()); - mapContent.put("Url", storageObject.getMediaLink()); - mapContent.put("Md5Hash", storageObject.getMd5Hash()); - fw_update.put("firmware-update", mapContent); - return fw_update; + mapContent.put(GCP_OTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GCP_OTA.URL, storageObject.getMediaLink()); + mapContent.put(GCP_OTA.MD5HASH, storageObject.getMd5Hash()); + list_fw_update.add(mapContent); } - } catch (IOException e) { - e.printStackTrace(); - } - return null; + }); + fw_update_Map.put(GCP_OTA.FW_UPDATE, list_fw_update); + return fw_update_Map; } - + private static boolean checkIfExists(String artifactName) throws IOException { Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); @@ -144,24 +168,27 @@ private static boolean checkIfExists(String artifactName) throws IOException { return false; } - public static StorageObject getStorageObjectInfo(String artifactName) throws IOException { - Storage.Objects.List objectsList = getStorage().objects().list(GCP_OTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - if(object.getName().equalsIgnoreCase(artifactName)) - { - LOGGER.debug(artifactName+" exists!"); - return object; + public static StorageObject getStorageObjectInfo(String artifactName) { + try { + Storage.Objects.List objectsList = getStorage().objects().list(GCP_OTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.debug(artifactName+" exists!"); + return object; + } } } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - LOGGER.warn(artifactName+" not found"); + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + LOGGER.warn(artifactName+" not found"); + } catch (Exception e) { + } return null; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 37c7311..1aa4551 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -4,6 +4,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -40,6 +41,22 @@ public static void init() { } + public static void addDocumentMapList(String deviceId, Map>> mapList) { + try { + DocumentReference docRef = db + .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(mapList); + System.out.println("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + public static void addDocument(String deviceId, Map> map) { try { DocumentReference docRef = db diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 7d39762..b2e019f 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -5,11 +5,18 @@ public class GCP_OTA { public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; + public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; public final static String SUBSCRIPTION_STATE_ID = "state"; - public final static boolean FW_VIA_COMMAND = false; + public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; public final static String FIRESTORE_CONFIG_COLLECTION = "config"; - + public final static String FW_UPDATE = "firmware-update"; + + public final static String OBJECT_NAME = "ObjectName"; + public final static String URL = "Url"; + public final static String MD5HASH = "Md5Hash"; + + public final static boolean FW_VIA_COMMAND = false; } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index 35f351c..e0212fe 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -23,6 +23,7 @@ import com.google.cloud.pubsub.v1.MessageReceiver; import com.google.cloud.pubsub.v1.Subscriber; import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.pubsub.v1.ProjectSubscriptionName; import com.google.pubsub.v1.PubsubMessage; @@ -132,6 +133,44 @@ public static void updateHawkbitStatus(PubsubMessage message){ } + + private static String getStringFromListMap(Map>> listMap) { + JsonObject fw_update = new JsonObject(); + JsonArray fw_update_list = new JsonArray(); + + listMap.get(GCP_OTA.FW_UPDATE).forEach(map -> { + JsonObject mapJsonObject = new JsonObject(); + mapJsonObject.addProperty(GCP_OTA.OBJECT_NAME, map.get(GCP_OTA.OBJECT_NAME)); + mapJsonObject.addProperty(GCP_OTA.URL, map.get(GCP_OTA.URL)); + mapJsonObject.addProperty(GCP_OTA.MD5HASH, map.get(GCP_OTA.MD5HASH)); + fw_update_list.add(mapJsonObject); + }); + fw_update.add(GCP_OTA.FW_UPDATE,fw_update_list); + return gson.toJson(fw_update); + } + + + + private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { + Map>> data = + GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); + if(data != null) { +// long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, +// GCP_OTA.REGISTRY_NAME); +// LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); +// GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, +// GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + + LOGGER.info("Writing to Firestore "); + GCP_FireStore.addDocumentMapList(deviceId + , GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); + } + else LOGGER.error("Artifacts is empty for device "+deviceId); + } + + + + private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { String data = GCPBucketHandler.getFirmwareInfoBucket(artifactName); if(data != null) { @@ -175,12 +214,18 @@ public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback if(!mapDevices.containsKey(device.getId())) { LOGGER.info("ActionType "+actionType); - List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) - .map(art -> art.getFilename()) - .collect(Collectors.toList()); - - fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); - + + + //TODO:uncomment + sendAsyncFwUpgradeList(device.getId(), modules); + + //TODO:comment below +// List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) +// .map(art -> art.getFilename()) +// .collect(Collectors.toList()); +// fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); + //End of comment + mapCallbacks.put(device.getId(), callback); mapDevices.put(device.getId(), device); } else { From 38cd026f6dcac8c9b52e08bf896f267dbfba8fb8 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 27 Feb 2019 16:20:04 +0100 Subject: [PATCH 20/54] mergeOptions set to true when writing to firestore comment sending config to iot core --- .../org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 3 ++- .../org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 1aa4551..9ab40f2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -14,6 +14,7 @@ import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; import com.google.cloud.firestore.FirestoreOptions; +import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.WriteResult; public class GCP_FireStore { @@ -48,7 +49,7 @@ public static void addDocumentMapList(String deviceId, Map result = docRef.set(mapList); + ApiFuture result = docRef.set(mapList, SetOptions.merge()); System.out.println("Update time : " + result.get().getUpdateTime()); } catch (InterruptedException e) { e.printStackTrace(); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index e0212fe..a466271 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -174,11 +174,11 @@ private static void sendAsyncFwUpgradeList(String deviceId, List Date: Wed, 27 Feb 2019 21:00:20 +0100 Subject: [PATCH 21/54] adding firebase admin --- hawkbit-device-simulator/README.md | 4 +++- hawkbit-device-simulator/pom.xml | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index aaa6a10..9d4aad9 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -8,9 +8,11 @@ install the following: - java openjdk8 - docker - maven +- create a service account +- add the service account to the VM in the configuration ## First Credentials for GCP - +- use the same service account - Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) - Rename the downloaded file to `keys.json` diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index acdebe6..653dd3f 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -144,11 +144,8 @@ google-api-services-cloudiot v1-rev20181120-1.27.0 - + com.google.api-client google-api-client @@ -186,6 +183,11 @@ google-cloud-firestore 0.70.0-beta + + com.google.firebase + firebase-admin + 6.7.0 + junit From fb6efff073f52d408b523ea9811a9d64bac779f1 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 27 Feb 2019 21:45:58 +0100 Subject: [PATCH 22/54] firebase ID --- hawkbit-device-simulator/pom.xml | 7 +++++-- .../java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 653dd3f..221896d 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -144,8 +144,11 @@ google-api-services-cloudiot v1-rev20181120-1.27.0 - + + com.google.oauth-client + google-oauth-client + 1.23.0 + com.google.api-client google-api-client diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 9ab40f2..68ce478 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -31,6 +31,7 @@ public static void init() { FirestoreOptions firestoreOptions = FirestoreOptions.newBuilder().setTimestampsInSnapshotsEnabled(true) .setProjectId(GCP_OTA.PROJECT_ID).setCredentials(credentials) + .setDatabaseId("https://ota-iot-231619.firebaseio.com") .build(); db = firestoreOptions.getService(); From 2560d4c90e13aa51a3fb67091ff9363efc2df055 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 14:47:10 +0100 Subject: [PATCH 23/54] updating the following: - stable demo env - readme - vmInstallDependencies to deploy on a new VM --- hawkbit-device-simulator/README.md | 35 ++++- .../hawkbit/google/gcp/GCP_FireStore.java | 1 - .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 7 +- .../hawkbit/google/gcp/GCP_Subscriber.java | 145 +++++++++--------- .../vmInstallDependencies.sh | 41 +++++ 5 files changed, 150 insertions(+), 79 deletions(-) create mode 100644 hawkbit-device-simulator/vmInstallDependencies.sh diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 9d4aad9..87edfc2 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -4,12 +4,31 @@ ## Spin a VM install the following: -- git -- java openjdk8 -- docker -- maven -- create a service account -- add the service account to the VM in the configuration +### git: +`sudo apt-get install git` + +### java 8 + +- `sudo apt-get install openjdk-8-jdk` + +- `sudo update-alternatives --config java` + +### docker + +Please read the following if you want to know more about how to install it [here](https://docs.docker.com/install/linux/docker-ce/debian/) + +- `sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common` +- `curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -` +- `sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"` +- `sudo apt-get update` +- `sudo apt-get install docker-ce docker-ce-cli containerd.io` + +### maven + +`sudo apt-get install maven` + +### create a service account +### add the service account to the VM in the configuration ## First Credentials for GCP - use the same service account @@ -72,6 +91,10 @@ sudo docker stack deploy -c docker-compose-stack.yml hawkbit ``` +## Firebase config +Follow these steps to configurate firebase with the java sdk [steps](https://firebase.google.com/docs/admin/setup) +Generate the file and place it in `src/main/resources` and name it `firebasekeys.json` + ## MySQL Info MYSQL_DATABASE: "hawkbit" MYSQL_USER: "root" diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 68ce478..9ab40f2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -31,7 +31,6 @@ public static void init() { FirestoreOptions firestoreOptions = FirestoreOptions.newBuilder().setTimestampsInSnapshotsEnabled(true) .setProjectId(GCP_OTA.PROJECT_ID).setCredentials(credentials) - .setDatabaseId("https://ota-iot-231619.firebaseio.com") .build(); db = firestoreOptions.getService(); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index b2e019f..6ac0e3a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -2,12 +2,17 @@ public class GCP_OTA { + //TODO: Configurations to take outside of here public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; - public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; + + public final static String SUBSCRIPTION_STATE_ID = "state"; + public final static String SUBSCRIPTION_FW_STATE = "fw-state"; + public final static String SUBSCRIPTION_FW_DEVICE_ID = "deviceId"; + public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; public final static String FIRESTORE_CONFIG_COLLECTION = "config"; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index a466271..fdd5396 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -8,7 +8,6 @@ import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingDeque; -import java.util.stream.Collectors; import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; @@ -80,52 +79,56 @@ public static void init() { public static void updateHawkbitStatus(PubsubMessage message){ - JsonObject payloadJson = gson.fromJson(message.getData() - .toStringUtf8(), JsonObject.class); - if(payloadJson.has("fw-state") && payloadJson.has("deviceId")) { - String deviceId = payloadJson.get("deviceId").getAsString(); - String fw_state = payloadJson.get("fw-state").getAsString(); - - if(deviceId != null && fw_state != null) { - UpdateStatus updateStatus = null; - - switch (fw_state) { - case "msg-received" : - updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); - sendUpate(deviceId, updateStatus); - break; - case "installing" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); - sendUpate(deviceId, updateStatus); - break; - case "downloading" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); - sendUpate(deviceId, updateStatus); - break; - case "installed": - updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); - sendUpate(deviceId, updateStatus); - - //remove device and callback - mapCallbacks.remove(deviceId); - mapDevices.remove(deviceId); - - break; - default: - LOGGER.error("Unknown fw-state: "+fw_state); - updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); - sendUpate(deviceId, updateStatus); - break; + if(message.getData().toStringUtf8().contains(GCP_OTA.SUBSCRIPTION_FW_STATE)) { + JsonObject payloadJson = gson.fromJson(message.getData() + .toStringUtf8(), JsonObject.class); + if(payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_STATE) && payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_DEVICE_ID)) { + String deviceId = payloadJson.get(GCP_OTA.SUBSCRIPTION_FW_DEVICE_ID).getAsString(); + String fw_state = payloadJson.get(GCP_OTA.SUBSCRIPTION_FW_STATE).getAsString(); + + if(deviceId != null && fw_state != null) { + UpdateStatus updateStatus = null; + System.out.println("====> New state received "+fw_state+ " from device "+deviceId); + switch (fw_state) { + case "msg-received" : + updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + sendUpate(deviceId, updateStatus); + break; + case "installing" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + sendUpate(deviceId, updateStatus); + break; + case "downloading" : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + sendUpate(deviceId, updateStatus); + break; + case "installed": + updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + sendUpate(deviceId, updateStatus); + + //remove device and callback + mapCallbacks.remove(deviceId); + mapDevices.remove(deviceId); + + break; + default: + LOGGER.error("Unknown fw-state: "+fw_state); + updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); + sendUpate(deviceId, updateStatus); + break; + } + + } else { + LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); + + //Device never connected + if(!GCP_IoTHandler.atLeastOnceConnected(deviceId)) { + LOGGER.error(deviceId+" : device was never connected"); + sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); + } } - } else { - LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); - - //Device never connected - if(!GCP_IoTHandler.atLeastOnceConnected(deviceId)) { - LOGGER.error(deviceId+" : device was never connected"); - sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); - } + LOGGER.debug("Ignoring message"); } } else { LOGGER.debug("Ignoring message"); @@ -133,11 +136,11 @@ public static void updateHawkbitStatus(PubsubMessage message){ } - + private static String getStringFromListMap(Map>> listMap) { JsonObject fw_update = new JsonObject(); JsonArray fw_update_list = new JsonArray(); - + listMap.get(GCP_OTA.FW_UPDATE).forEach(map -> { JsonObject mapJsonObject = new JsonObject(); mapJsonObject.addProperty(GCP_OTA.OBJECT_NAME, map.get(GCP_OTA.OBJECT_NAME)); @@ -148,18 +151,18 @@ private static String getStringFromListMap(Map> fw_update.add(GCP_OTA.FW_UPDATE,fw_update_list); return gson.toJson(fw_update); } - - - + + + private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { Map>> data = GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); if(data != null) { -// long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, -// GCP_OTA.REGISTRY_NAME); -// LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); -// GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, -// GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); LOGGER.info("Writing to Firestore "); GCP_FireStore.addDocumentMapList(deviceId @@ -167,18 +170,18 @@ private static void sendAsyncFwUpgradeList(String deviceId, List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) -// .map(art -> art.getFilename()) -// .collect(Collectors.toList()); -// fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); + // List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + // .map(art -> art.getFilename()) + // .collect(Collectors.toList()); + // fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); //End of comment - + mapCallbacks.put(device.getId(), callback); mapDevices.put(device.getId(), device); } else { diff --git a/hawkbit-device-simulator/vmInstallDependencies.sh b/hawkbit-device-simulator/vmInstallDependencies.sh new file mode 100644 index 0000000..055a75a --- /dev/null +++ b/hawkbit-device-simulator/vmInstallDependencies.sh @@ -0,0 +1,41 @@ +echo 'Installing git' +sudo apt-get install git + +echo 'Installing jdk 8' +sudo apt-get install openjdk-8-jdk + +echo 'Installing maven' +sudo apt-get install maven +sudo update-alternatives --config java + +echo 'Installing docker' +sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common +curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - +sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" +sudo apt-get update +sudo apt-get install docker-ce docker-ce-cli containerd.io + +echo 'cloning hawbkit and installing it' +mkdir hawkbit +git clone https://github.com/eclipse/hawkbit.git +cd hawkbit +mvn clean install + +echo 'cloning gcp hawkbit module' +git clone https://github.com/charbull/hawkbit-examples.git +cd hawkbit-examples +mvn clean install +cd hawkbit-device-simulator/ +chmod 777 runSpring.sh +#./runSpring.sh + +echo 'Creating topic state and subscription state' +gcloud pubsub topics create state +gcloud pubsub subscriptions create --topic state state + +echo 'things you should do manually' +echo '- Creating a service account hawkbit-poc' +echo '- Get the keys and put it in: hawkbit-examples/hawkbit-device-simulator/src/main/resources' + +#sudo docker swarm init +#sudo docker stack deploy -c docker-compose-stack.yml hawkbit \ No newline at end of file From 9ed620445aeb236c3f48fd88e82e9ea3bb46b2a5 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:35:24 +0100 Subject: [PATCH 24/54] update readme and adding an image --- hawkbit-device-simulator/README.md | 61 +++++++----------- .../images/rolloutConfig.png | Bin 0 -> 171370 bytes 2 files changed, 24 insertions(+), 37 deletions(-) create mode 100644 hawkbit-device-simulator/images/rolloutConfig.png diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 87edfc2..8ac1f43 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -3,6 +3,9 @@ ## Spin a VM +Use the installation script: [vmInstallDependencies.sh](./vmInstallDependencies.sh) + +or install the following: ### git: `sudo apt-get install git` @@ -102,17 +105,28 @@ Generate the file and place it in `src/main/resources` and name it `firebasekeys ## Run on your own workstation ``` -java -jar examples/hawkbit-device-simulator/target/hawkbit-device-simulator-*-SNAPSHOT.jar -``` -Or: +mvn spring-boot:run ``` -run org.eclipse.hawkbit.simulator.DeviceSimulator +or use the the [runSpring.sh](./runSpring.sh) ``` -## Deploy to cloud foundry environment +## Create Software Distribution + +## Tag your Devices + +## Create a Target Filter + +## Configure the Rollout + +- Error threshold 0 +- Trigger threshold 100 + + + +Follow the same config as in the +![image](./images/rolloutConfig.png) + -- Go to ```target``` subfolder. -- Run ```cf push``` ## Notes @@ -124,38 +138,11 @@ This can be configured/disabled by spring boot properties ## hawkBit APIs -The simulator supports `DDI` as well as the `DMF` integration APIs. - In case there is no AMQP message broker (like rabbitMQ) running, you can disable the AMQP support for the device simulator, so the simulator is not trying to connect to an amqp message broker. Configuration property `hawkbit.device.simulator.amqp.enabled=false` -## Usage - -### REST API -The device simulator exposes an REST-API which can be used to trigger device creation. - -Optional parameters: -* name : name prefix simulated devices (default: "dmfSimulated"), followed by counter -* amount : number of simulated devices (default: 20, capped at: 4000) -* tenant : in a multi-tenant ready hawkBit installation (default: "DEFAULT") -* api : the API which should be used for the simulated device either `dmf` or `ddi` (default: "dmf") -* endpoint : URL which defines the hawkbit DDI base endpoint (default: "http://localhost:8080") -* polldelay : number in seconds of the delay when DDI simulated devices should poll the endpoint (default: "30") -* gatewaytoken : an hawkbit gateway token to be used in case hawkbit does not allow anonymous access for DDI devices (default: "") - - -Example: for 20 simulated devices by DMF API (default) -``` -http://localhost:8083/start -``` - -Example: for 10 simulated devices that start with the name prefix "activeSim": -``` -http://localhost:8083/start?amount=10&name=activeSim -``` - -Example: for 5 simulated devices that start with the name prefix "ddi" using the Direct Device Integration API (http) authenticated by given gateway token, a pool interval of 10 seconds and a custom port for the DDI service.: -``` -http://localhost:8083/start?amount=5&name=ddi&api=ddi&gatewaytoken=d5F2mmlARiMuMOquRmLlxW4xZFHy4mEV&polldelay=10&endpoint=http://localhost:8085 +## Populate GCP devices ``` +http://localhost:8083/gcp +``` \ No newline at end of file diff --git a/hawkbit-device-simulator/images/rolloutConfig.png b/hawkbit-device-simulator/images/rolloutConfig.png new file mode 100644 index 0000000000000000000000000000000000000000..e69428718589b84232fb8b8a4ea8920adebd272b GIT binary patch literal 171370 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 z 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℘-n0 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 z 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 literal 0 HcmV?d00001 From 3dfeea69406963a5715b31d8717a1d70d036877c Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:35:41 +0100 Subject: [PATCH 25/54] adding image --- hawkbit-device-simulator/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 8ac1f43..97c150f 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -119,7 +119,7 @@ or use the the [runSpring.sh](./runSpring.sh) ## Configure the Rollout - Error threshold 0 -- Trigger threshold 100 +- Trigger threshold 100 From 2324b3f5c71d30e301df1dc56e9d4c3d3ad91d8c Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:37:31 +0100 Subject: [PATCH 26/54] fixed readme --- hawkbit-device-simulator/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 97c150f..ca09ec0 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -108,7 +108,6 @@ Generate the file and place it in `src/main/resources` and name it `firebasekeys mvn spring-boot:run ``` or use the the [runSpring.sh](./runSpring.sh) -``` ## Create Software Distribution From 1986370880b86bc39a591fdfff3407121fe58338 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:42:58 +0100 Subject: [PATCH 27/54] FIRESTORE Stuff --- .../main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 6ac0e3a..259ccc6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -8,6 +8,11 @@ public class GCP_OTA { public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; +// +// public final static String PROJECT_ID = "ikea-homesmart-workshop"; +// public final static String CLOUD_REGION = "europe-west1"; +// public final static String REGISTRY_NAME = "tradfri"; +// public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; //TODO: public final static String SUBSCRIPTION_STATE_ID = "state"; public final static String SUBSCRIPTION_FW_STATE = "fw-state"; From 1187e0e838184eb6f13da513cc39d1062256bd32 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:46:49 +0100 Subject: [PATCH 28/54] trying with default credentials --- .../org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 9ab40f2..33b3ed4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -25,12 +25,16 @@ public static void init() { try { ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + String path = classLoader.getResource("firestoreKeys.json").getPath(); + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + +// GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) +// .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); +// FirestoreOptions firestoreOptions = FirestoreOptions.newBuilder().setTimestampsInSnapshotsEnabled(true) .setProjectId(GCP_OTA.PROJECT_ID).setCredentials(credentials) + //.setDatabaseId(databaseId) .build(); db = firestoreOptions.getService(); From de2f9ff30bdf8621399339b4715f6e78d2af4b20 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:49:29 +0100 Subject: [PATCH 29/54] update mvn --- hawkbit-device-simulator/mvn.sh | 1 + .../java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 hawkbit-device-simulator/mvn.sh diff --git a/hawkbit-device-simulator/mvn.sh b/hawkbit-device-simulator/mvn.sh new file mode 100644 index 0000000..5cac501 --- /dev/null +++ b/hawkbit-device-simulator/mvn.sh @@ -0,0 +1 @@ +mvn clean install \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 33b3ed4..67d5276 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -24,8 +24,8 @@ public class GCP_FireStore { public static void init() { try { - ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("firestoreKeys.json").getPath(); +// ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); +// String path = classLoader.getResource("firestoreKeys.json").getPath(); GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) From a4c8088ba95fd096887ca44d5605612d502a63ed Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:56:28 +0100 Subject: [PATCH 30/54] update firestore testing --- hawkbit-device-simulator/pom.xml | 4 +-- .../hawkbit/google/gcp/GCP_FireStore.java | 35 ++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 221896d..bd3f399 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -181,11 +181,11 @@ google-api-services-storage v1-rev149-1.25.0 - + com.google.firebase firebase-admin diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 67d5276..34d76f5 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -1,21 +1,20 @@ package org.eclipse.hawkbit.google.gcp; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import com.google.api.core.ApiFuture; -import com.google.api.services.iam.v1.IamScopes; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; -import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.WriteResult; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.cloud.FirestoreClient; public class GCP_FireStore { @@ -24,28 +23,30 @@ public class GCP_FireStore { public static void init() { try { -// ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); -// String path = classLoader.getResource("firestoreKeys.json").getPath(); + // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + // String path = classLoader.getResource("firestoreKeys.json").getPath(); GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); -// GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) -// .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); -// - FirestoreOptions firestoreOptions = - FirestoreOptions.newBuilder().setTimestampsInSnapshotsEnabled(true) - .setProjectId(GCP_OTA.PROJECT_ID).setCredentials(credentials) - //.setDatabaseId(databaseId) + // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) + // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + // + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(credentials) + .setProjectId(GCP_OTA.PROJECT_ID) .build(); - db = firestoreOptions.getService(); + FirebaseApp.initializeApp(options); + db = FirestoreClient.getFirestore(); + + } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } - - + + public static void addDocumentMapList(String deviceId, Map>> mapList) { try { DocumentReference docRef = db @@ -61,7 +62,7 @@ public static void addDocumentMapList(String deviceId, Map> map) { try { DocumentReference docRef = db From ad16e47a0a92c8ed0d19256ce29129db89c0c9ef Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 15:57:52 +0100 Subject: [PATCH 31/54] new pullCompileRun script --- hawkbit-device-simulator/pullCompileRun.sh | 3 +++ hawkbit-device-simulator/src/main/resources/.gitignore | 1 + 2 files changed, 4 insertions(+) create mode 100644 hawkbit-device-simulator/pullCompileRun.sh diff --git a/hawkbit-device-simulator/pullCompileRun.sh b/hawkbit-device-simulator/pullCompileRun.sh new file mode 100644 index 0000000..786f15f --- /dev/null +++ b/hawkbit-device-simulator/pullCompileRun.sh @@ -0,0 +1,3 @@ +git pull +mvn clean install +./runSpring.sh \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/resources/.gitignore b/hawkbit-device-simulator/src/main/resources/.gitignore index 5de3b5b..81dd324 100644 --- a/hawkbit-device-simulator/src/main/resources/.gitignore +++ b/hawkbit-device-simulator/src/main/resources/.gitignore @@ -1,3 +1,4 @@ /keys.json /rsa_cert.pem /rsa_private.pem +/firestorekeys.json From b7bd39580edac7de8153cf054543943a168b418d Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:10:20 +0100 Subject: [PATCH 32/54] updating firestore --- hawkbit-device-simulator/pom.xml | 6 +++--- .../hawkbit/google/gcp/GCP_FireStore.java | 16 +++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index bd3f399..d625ac2 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -181,11 +181,11 @@ google-api-services-storage v1-rev149-1.25.0 - + 0.81.0-beta + com.google.firebase firebase-admin diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 34d76f5..d713ac4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -10,6 +10,7 @@ import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.WriteResult; import com.google.firebase.FirebaseApp; @@ -29,14 +30,15 @@ public static void init() { // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - // - FirebaseOptions options = new FirebaseOptions.Builder() - .setCredentials(credentials) - .setProjectId(GCP_OTA.PROJECT_ID) - .build(); - FirebaseApp.initializeApp(options); + // + + FirestoreOptions firestoreOptions = + FirestoreOptions.getDefaultInstance().toBuilder() + .setProjectId(GCP_OTA.PROJECT_ID) + .build(); + db = firestoreOptions.getService(); + - db = FirestoreClient.getFirestore(); } catch (FileNotFoundException e) { From a334b4b60abf5bd4f0756d31e3921a5b08eb1e1d Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:13:15 +0100 Subject: [PATCH 33/54] update --- .../java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index d713ac4..592a4c9 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -33,7 +33,10 @@ public static void init() { // FirestoreOptions firestoreOptions = - FirestoreOptions.getDefaultInstance().toBuilder() + FirestoreOptions + .getDefaultInstance() + .toBuilder() + .setCredentials(credentials) .setProjectId(GCP_OTA.PROJECT_ID) .build(); db = firestoreOptions.getService(); From 43bb634ae4e66d56364111d835cfdb74ac3cd292 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:16:17 +0100 Subject: [PATCH 34/54] added guava --- hawkbit-device-simulator/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index d625ac2..f65e821 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -191,6 +191,13 @@ firebase-admin 6.7.0 + + + com.google.guava + guava + 27.0.1-jre + + junit From 84505e9b48ac06174a4a193a723da7364800388c Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:18:19 +0100 Subject: [PATCH 35/54] guava update --- hawkbit-device-simulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index f65e821..2d0fe95 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -195,7 +195,7 @@ com.google.guava guava - 27.0.1-jre + 23.6-jre From 485726640e4485b2a4b76a099a307c862cd167b6 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:20:48 +0100 Subject: [PATCH 36/54] no firestore --- .../hawkbit/google/gcp/GCP_FireStore.java | 176 +++++++++--------- .../hawkbit/google/gcp/GCP_Subscriber.java | 6 +- 2 files changed, 91 insertions(+), 91 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 592a4c9..473e7b0 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -1,88 +1,88 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; - -import com.google.api.core.ApiFuture; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.cloud.firestore.DocumentReference; -import com.google.cloud.firestore.Firestore; -import com.google.cloud.firestore.FirestoreOptions; -import com.google.cloud.firestore.SetOptions; -import com.google.cloud.firestore.WriteResult; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.cloud.FirestoreClient; - -public class GCP_FireStore { - - private static Firestore db; - - public static void init() { - - try { - // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); - // String path = classLoader.getResource("firestoreKeys.json").getPath(); - GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); - - // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) - // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - // - - FirestoreOptions firestoreOptions = - FirestoreOptions - .getDefaultInstance() - .toBuilder() - .setCredentials(credentials) - .setProjectId(GCP_OTA.PROJECT_ID) - .build(); - db = firestoreOptions.getService(); - - - - - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - - public static void addDocumentMapList(String deviceId, Map>> mapList) { - try { - DocumentReference docRef = db - .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) - .document(deviceId) - .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) - .document(deviceId); - ApiFuture result = docRef.set(mapList, SetOptions.merge()); - System.out.println("Update time : " + result.get().getUpdateTime()); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - } - - public static void addDocument(String deviceId, Map> map) { - try { - DocumentReference docRef = db - .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) - .document(deviceId) - .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) - .document(deviceId); - ApiFuture result = docRef.set(map); - System.out.println("Update time : " + result.get().getUpdateTime()); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - } - - -} +//package org.eclipse.hawkbit.google.gcp; +// +//import java.io.FileNotFoundException; +//import java.io.IOException; +//import java.util.List; +//import java.util.Map; +//import java.util.concurrent.ExecutionException; +// +//import com.google.api.core.ApiFuture; +//import com.google.auth.oauth2.GoogleCredentials; +//import com.google.cloud.firestore.DocumentReference; +//import com.google.cloud.firestore.Firestore; +//import com.google.cloud.firestore.FirestoreOptions; +//import com.google.cloud.firestore.SetOptions; +//import com.google.cloud.firestore.WriteResult; +//import com.google.firebase.FirebaseApp; +//import com.google.firebase.FirebaseOptions; +//import com.google.firebase.cloud.FirestoreClient; +// +//public class GCP_FireStore { +// +// private static Firestore db; +// +// public static void init() { +// +// try { +// // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); +// // String path = classLoader.getResource("firestoreKeys.json").getPath(); +// GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); +// +// // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) +// // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); +// // +// +// FirestoreOptions firestoreOptions = +// FirestoreOptions +// .getDefaultInstance() +// .toBuilder() +// .setCredentials(credentials) +// .setProjectId(GCP_OTA.PROJECT_ID) +// .build(); +// db = firestoreOptions.getService(); +// +// +// +// +// } catch (FileNotFoundException e) { +// e.printStackTrace(); +// } catch (IOException e) { +// e.printStackTrace(); +// } +// } +// +// +// public static void addDocumentMapList(String deviceId, Map>> mapList) { +// try { +// DocumentReference docRef = db +// .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) +// .document(deviceId) +// .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) +// .document(deviceId); +// ApiFuture result = docRef.set(mapList, SetOptions.merge()); +// System.out.println("Update time : " + result.get().getUpdateTime()); +// } catch (InterruptedException e) { +// e.printStackTrace(); +// } catch (ExecutionException e) { +// e.printStackTrace(); +// } +// } +// +// public static void addDocument(String deviceId, Map> map) { +// try { +// DocumentReference docRef = db +// .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) +// .document(deviceId) +// .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) +// .document(deviceId); +// ApiFuture result = docRef.set(map); +// System.out.println("Update time : " + result.get().getUpdateTime()); +// } catch (InterruptedException e) { +// e.printStackTrace(); +// } catch (ExecutionException e) { +// e.printStackTrace(); +// } +// } +// +// +//} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index fdd5396..d5cf9e5 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -165,8 +165,8 @@ private static void sendAsyncFwUpgradeList(String deviceId, List Date: Thu, 28 Feb 2019 16:22:48 +0100 Subject: [PATCH 37/54] no firestore --- .../java/org/eclipse/hawkbit/simulator/SimulatorStartup.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 487e26e..08643b2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -11,7 +11,7 @@ import java.net.MalformedURLException; import java.net.URL; -import org.eclipse.hawkbit.google.gcp.GCP_FireStore; +//import org.eclipse.hawkbit.google.gcp.GCP_FireStore; import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; //import org.eclipse.hawkbit.google.gcp.BucketHandler; @@ -52,7 +52,7 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { LOGGER.debug("Init Firestore ... "); - GCP_FireStore.init(); + //GCP_FireStore.init(); LOGGER.debug("Init Subscriber ... "); GCP_Subscriber.init(); From 196f1a745fc71c0e87e42ffaff6fa53a704a5adb Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 16:57:59 +0100 Subject: [PATCH 38/54] updated google-api-client to version 1.28.0 --- hawkbit-device-simulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 221896d..24215c7 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -152,7 +152,7 @@ com.google.api-client google-api-client - 1.23.0 + 1.28.0 com.google.auth From 8be5daa9b9e36057bae8e9fc1bba61d2c0aa7f3c Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 17:05:24 +0100 Subject: [PATCH 39/54] merge and update --- .../hawkbit/google/gcp/GCP_FireStore.java | 176 +++++++++--------- .../hawkbit/google/gcp/GCP_Subscriber.java | 6 +- .../hawkbit/simulator/SimulatorStartup.java | 5 +- 3 files changed, 93 insertions(+), 94 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 473e7b0..592a4c9 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -1,88 +1,88 @@ -//package org.eclipse.hawkbit.google.gcp; -// -//import java.io.FileNotFoundException; -//import java.io.IOException; -//import java.util.List; -//import java.util.Map; -//import java.util.concurrent.ExecutionException; -// -//import com.google.api.core.ApiFuture; -//import com.google.auth.oauth2.GoogleCredentials; -//import com.google.cloud.firestore.DocumentReference; -//import com.google.cloud.firestore.Firestore; -//import com.google.cloud.firestore.FirestoreOptions; -//import com.google.cloud.firestore.SetOptions; -//import com.google.cloud.firestore.WriteResult; -//import com.google.firebase.FirebaseApp; -//import com.google.firebase.FirebaseOptions; -//import com.google.firebase.cloud.FirestoreClient; -// -//public class GCP_FireStore { -// -// private static Firestore db; -// -// public static void init() { -// -// try { -// // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); -// // String path = classLoader.getResource("firestoreKeys.json").getPath(); -// GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); -// -// // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) -// // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); -// // -// -// FirestoreOptions firestoreOptions = -// FirestoreOptions -// .getDefaultInstance() -// .toBuilder() -// .setCredentials(credentials) -// .setProjectId(GCP_OTA.PROJECT_ID) -// .build(); -// db = firestoreOptions.getService(); -// -// -// -// -// } catch (FileNotFoundException e) { -// e.printStackTrace(); -// } catch (IOException e) { -// e.printStackTrace(); -// } -// } -// -// -// public static void addDocumentMapList(String deviceId, Map>> mapList) { -// try { -// DocumentReference docRef = db -// .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) -// .document(deviceId) -// .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) -// .document(deviceId); -// ApiFuture result = docRef.set(mapList, SetOptions.merge()); -// System.out.println("Update time : " + result.get().getUpdateTime()); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } catch (ExecutionException e) { -// e.printStackTrace(); -// } -// } -// -// public static void addDocument(String deviceId, Map> map) { -// try { -// DocumentReference docRef = db -// .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) -// .document(deviceId) -// .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) -// .document(deviceId); -// ApiFuture result = docRef.set(map); -// System.out.println("Update time : " + result.get().getUpdateTime()); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } catch (ExecutionException e) { -// e.printStackTrace(); -// } -// } -// -// -//} +package org.eclipse.hawkbit.google.gcp; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import com.google.api.core.ApiFuture; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.FirestoreOptions; +import com.google.cloud.firestore.SetOptions; +import com.google.cloud.firestore.WriteResult; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.cloud.FirestoreClient; + +public class GCP_FireStore { + + private static Firestore db; + + public static void init() { + + try { + // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + // String path = classLoader.getResource("firestoreKeys.json").getPath(); + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + + // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) + // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + // + + FirestoreOptions firestoreOptions = + FirestoreOptions + .getDefaultInstance() + .toBuilder() + .setCredentials(credentials) + .setProjectId(GCP_OTA.PROJECT_ID) + .build(); + db = firestoreOptions.getService(); + + + + + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + + public static void addDocumentMapList(String deviceId, Map>> mapList) { + try { + DocumentReference docRef = db + .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(mapList, SetOptions.merge()); + System.out.println("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + public static void addDocument(String deviceId, Map> map) { + try { + DocumentReference docRef = db + .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(map); + System.out.println("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index d5cf9e5..fdd5396 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -165,8 +165,8 @@ private static void sendAsyncFwUpgradeList(String deviceId, List Date: Thu, 28 Feb 2019 17:10:41 +0100 Subject: [PATCH 40/54] firestore update --- .../hawkbit/google/gcp/GCP_FireStore.java | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 592a4c9..5664078 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -1,12 +1,15 @@ package org.eclipse.hawkbit.google.gcp; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import com.google.api.core.ApiFuture; +import com.google.api.services.iam.v1.IamScopes; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; @@ -24,26 +27,24 @@ public class GCP_FireStore { public static void init() { try { - // ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); - // String path = classLoader.getResource("firestoreKeys.json").getPath(); - GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); - - // GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) - // .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - // - + ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + + GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + FirestoreOptions firestoreOptions = - FirestoreOptions - .getDefaultInstance() - .toBuilder() - .setCredentials(credentials) - .setProjectId(GCP_OTA.PROJECT_ID) - .build(); - db = firestoreOptions.getService(); - - - - + FirestoreOptions + .getDefaultInstance() + .toBuilder() + .setCredentials(credentials) + .setProjectId(GCP_OTA.PROJECT_ID) + .build(); + db = firestoreOptions.getService(); + + + + } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { From cb69c485f742d27c39c520d8f1ccba480c229945 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 17:18:11 +0100 Subject: [PATCH 41/54] FireBASe again --- hawkbit-device-simulator/runSpring.sh | 1 - .../org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100755 hawkbit-device-simulator/runSpring.sh diff --git a/hawkbit-device-simulator/runSpring.sh b/hawkbit-device-simulator/runSpring.sh deleted file mode 100755 index 9c2972f..0000000 --- a/hawkbit-device-simulator/runSpring.sh +++ /dev/null @@ -1 +0,0 @@ -mvn spring-boot:run diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 5664078..877f50c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -27,12 +27,14 @@ public class GCP_FireStore { public static void init() { try { - ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); +// ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); +// String path = classLoader.getResource("firestorekeys.json").getPath(); - GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); +// GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) +// .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + FirestoreOptions firestoreOptions = FirestoreOptions .getDefaultInstance() From f217e2c1cfc9a10ced3cbfa3bab721a6fe7750dd Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 17:19:13 +0100 Subject: [PATCH 42/54] add scripts --- hawkbit-device-simulator/mvn.sh | 0 hawkbit-device-simulator/runSpring.sh | 2 ++ 2 files changed, 2 insertions(+) mode change 100644 => 100755 hawkbit-device-simulator/mvn.sh create mode 100755 hawkbit-device-simulator/runSpring.sh diff --git a/hawkbit-device-simulator/mvn.sh b/hawkbit-device-simulator/mvn.sh old mode 100644 new mode 100755 diff --git a/hawkbit-device-simulator/runSpring.sh b/hawkbit-device-simulator/runSpring.sh new file mode 100755 index 0000000..58540b1 --- /dev/null +++ b/hawkbit-device-simulator/runSpring.sh @@ -0,0 +1,2 @@ +mvn clean install +mvn spring-boot:run From d6e837ba2b4c78dc8108b0f0689617f408309307 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 17:20:04 +0100 Subject: [PATCH 43/54] script modifications --- hawkbit-device-simulator/pullCompileRun.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hawkbit-device-simulator/pullCompileRun.sh b/hawkbit-device-simulator/pullCompileRun.sh index 786f15f..e853d13 100644 --- a/hawkbit-device-simulator/pullCompileRun.sh +++ b/hawkbit-device-simulator/pullCompileRun.sh @@ -1,3 +1,2 @@ git pull -mvn clean install -./runSpring.sh \ No newline at end of file +./runSpring.sh From 05313fee74d6206782a62c6477fef1fde63cdc18 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 17:29:55 +0100 Subject: [PATCH 44/54] trying again with keys for firestore --- .../org/eclipse/hawkbit/google/gcp/GCP_FireStore.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java index 877f50c..b01bff6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java @@ -27,13 +27,13 @@ public class GCP_FireStore { public static void init() { try { -// ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); -// String path = classLoader.getResource("firestorekeys.json").getPath(); + ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); -// GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) -// .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + // GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); FirestoreOptions firestoreOptions = FirestoreOptions From 121b407eea0b407328ff7071c1ef183b0c1eeb11 Mon Sep 17 00:00:00 2001 From: charbull Date: Thu, 28 Feb 2019 23:36:09 +0100 Subject: [PATCH 45/54] update extracting deviceId from pubsub message --- .../eclipse/hawkbit/google/gcp/GCP_OTA.java | 10 ++++- .../hawkbit/google/gcp/GCP_Subscriber.java | 43 +++++++++---------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java index 259ccc6..ae1eab4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java @@ -1,5 +1,8 @@ package org.eclipse.hawkbit.google.gcp; +import org.eclipse.hawkbit.simulator.UpdateStatus; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; + public class GCP_OTA { //TODO: Configurations to take outside of here @@ -14,9 +17,14 @@ public class GCP_OTA { // public final static String REGISTRY_NAME = "tradfri"; // public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; //TODO: + public final static String FW_MSG_RECEIVED = "msg-received"; + public final static String FW_INSTALLING = "installing"; + public final static String FW_DOWNLOADING = "downloading"; + public final static String FW_INSTALLED = "installed"; + public final static String SUBSCRIPTION_STATE_ID = "state"; public final static String SUBSCRIPTION_FW_STATE = "fw-state"; - public final static String SUBSCRIPTION_FW_DEVICE_ID = "deviceId"; + public final static String DEVICE_ID = "deviceId"; public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java index fdd5396..539bfd2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java @@ -60,9 +60,6 @@ public static void init() { // Continue to listen to messages while (true) { PubsubMessage message = messages.take(); - System.out.println("Message Id: " + message.getMessageId()); - //{"deviceId":"CharbelDevice","fw-state":"installed"} - System.out.println("Data: " + message.getData().toStringUtf8()); if(!GCP_OTA.FW_VIA_COMMAND) { updateHawkbitStatus(message); } @@ -82,27 +79,27 @@ public static void updateHawkbitStatus(PubsubMessage message){ if(message.getData().toStringUtf8().contains(GCP_OTA.SUBSCRIPTION_FW_STATE)) { JsonObject payloadJson = gson.fromJson(message.getData() .toStringUtf8(), JsonObject.class); - if(payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_STATE) && payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_DEVICE_ID)) { - String deviceId = payloadJson.get(GCP_OTA.SUBSCRIPTION_FW_DEVICE_ID).getAsString(); + if(payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_STATE)) { + String deviceId = message.getAttributesMap().get(GCP_OTA.DEVICE_ID); String fw_state = payloadJson.get(GCP_OTA.SUBSCRIPTION_FW_STATE).getAsString(); if(deviceId != null && fw_state != null) { UpdateStatus updateStatus = null; System.out.println("====> New state received "+fw_state+ " from device "+deviceId); switch (fw_state) { - case "msg-received" : + case GCP_OTA.FW_MSG_RECEIVED : updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); sendUpate(deviceId, updateStatus); break; - case "installing" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + case GCP_OTA.FW_DOWNLOADING: + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); sendUpate(deviceId, updateStatus); break; - case "downloading" : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + case GCP_OTA.FW_INSTALLING : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); sendUpate(deviceId, updateStatus); break; - case "installed": + case GCP_OTA.FW_INSTALLED: updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); sendUpate(deviceId, updateStatus); @@ -158,11 +155,11 @@ private static void sendAsyncFwUpgradeList(String deviceId, List>> data = GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); if(data != null) { - long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME); - LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); - GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, + GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); LOGGER.info("Writing to Firestore "); GCP_FireStore.addDocumentMapList(deviceId @@ -177,11 +174,11 @@ private static void sendAsyncFwUpgradeList(String deviceId, List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) // .map(art -> art.getFilename()) // .collect(Collectors.toList()); // fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); - //End of comment + //[End of comment] mapCallbacks.put(device.getId(), callback); mapDevices.put(device.getId(), device); From f9b96ddb36fab3c387f6de71cf7a1746994ba74b Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 11:41:30 -0400 Subject: [PATCH 46/54] update for Docker --- hawkbit-device-simulator/README.md | 3 + .../docker/0.3.0-SNAPSHOT/Dockerfile | 16 +- .../docker/0.3.0-SNAPSHOT/Dockerfile.original | 18 + hawkbit-device-simulator/docker/Dockerfile | 8 + hawkbit-device-simulator/dockerBuild.sh | 3 + hawkbit-device-simulator/pom.xml | 44 +++ ...cketHandler.java => GcpBucketHandler.java} | 70 ++-- .../{GCP_FireStore.java => GcpFireStore.java} | 14 +- ...GCP_IoTHandler.java => GcpIoTHandler.java} | 320 ++++++------------ .../google/gcp/{GCP_OTA.java => GcpOTA.java} | 10 +- ...GCP_Subscriber.java => GcpSubscriber.java} | 62 ++-- ...java => HawkBitSoftwareModuleHandler.java} | 4 +- .../simulator/DeviceSimulatorUpdater.java | 16 +- .../simulator/SimulatedDeviceFactory.java | 1 - .../simulator/SimulationController.java | 7 +- .../hawkbit/simulator/SimulatorStartup.java | 8 +- .../simulator/amqp/DmfReceiverService.java | 5 +- .../simulator/amqp/DmfSenderService.java | 28 +- 18 files changed, 300 insertions(+), 337 deletions(-) create mode 100644 hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original create mode 100644 hawkbit-device-simulator/docker/Dockerfile create mode 100755 hawkbit-device-simulator/dockerBuild.sh rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{GCPBucketHandler.java => GcpBucketHandler.java} (80%) rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{GCP_FireStore.java => GcpFireStore.java} (87%) rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{GCP_IoTHandler.java => GcpIoTHandler.java} (68%) rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{GCP_OTA.java => GcpOTA.java} (82%) rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{GCP_Subscriber.java => GcpSubscriber.java} (77%) rename hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/{HawkbitSoftwareModuleHandler.java => HawkBitSoftwareModuleHandler.java} (96%) diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index ca09ec0..708a95f 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -41,6 +41,9 @@ Please read the following if you want to know more about how to install it [here - Add it to `src/main/resources` +## Device Registry + +For now, this handler supports only one registry ## GCP Config diff --git a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile index e8f3693..18cc6b5 100644 --- a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile +++ b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile @@ -1,16 +1,10 @@ -FROM openjdk:8u171-jre-alpine +FROM openjdk:8-jre +FROM jetty -ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ - HAWKBIT_SIM_HOME=/opt/hawkbit-simulator +MAINTAINER Charbel Kaed -# Http port EXPOSE 8083 -RUN set -x \ - && mkdir -p $HAWKBIT_SIM_HOME \ - && cd $HAWKBIT_SIM_HOME \ - && apk add --no-cache libressl wget \ - && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" +ADD target/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar /opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar -WORKDIR $HAWKBIT_SIM_HOME -ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file +ENTRYPOINT ["java","-jar","/opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original new file mode 100644 index 0000000..5c4ebd3 --- /dev/null +++ b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Kai Zimmermann + +ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator + +# Http port +EXPOSE 8083 + +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" + +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/docker/Dockerfile b/hawkbit-device-simulator/docker/Dockerfile new file mode 100644 index 0000000..2fec7d4 --- /dev/null +++ b/hawkbit-device-simulator/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Charbel Kaed + +# Http port +EXPOSE 8083 + +ENTRYPOINT ["java","-jar","hawkbit-gcp-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/dockerBuild.sh b/hawkbit-device-simulator/dockerBuild.sh new file mode 100755 index 0000000..6b2fb2f --- /dev/null +++ b/hawkbit-device-simulator/dockerBuild.sh @@ -0,0 +1,3 @@ +mvn clean install +docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbull/ota . +docker run -d --name=jetty -p 8083:8083 charbull/ota:latest diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index cc6d9e3..7e88894 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -14,8 +14,30 @@ hawkBit :: GCP :: Manager Device Management Federation API with GCP + + charbel + + + com.spotify + dockerfile-maven-plugin + 1.4.10 + + HawkBit-GCP + java + ${project.build.directory}/docker + ["java", "-jar", "/${project.build.finalName}.jar"] + ${docker.image.prefix}/${project.artifactId} + + + / + ${project.build.directory} + ${project.build.finalName}.jar + + + + org.springframework.boot spring-boot-maven-plugin @@ -40,6 +62,28 @@ 1 + + org.apache.maven.plugins + maven-dependency-plugin + + + unpack + package + + unpack + + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + + + + + + diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java similarity index 80% rename from hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java rename to hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java index 5452cec..d267f2a 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCPBucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java @@ -7,6 +7,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Collections; @@ -37,9 +41,9 @@ -public class GCPBucketHandler { +public class GcpBucketHandler { - private static final Logger LOGGER = LoggerFactory.getLogger(GCPBucketHandler.class); + private static final Logger LOGGER = LoggerFactory.getLogger(GcpBucketHandler.class); private static Storage storage = null; static Gson gson = new Gson(); @@ -53,7 +57,7 @@ private static Storage getStorage() { try { if(storage == null) { - ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); String path = classLoader.getResource("keys.json").getPath(); GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); @@ -72,19 +76,35 @@ private static Storage getStorage() { } public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { - Storage gcs = getStorage(); - String data = HawkbitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); + String data = null; + String decodedURL = URLDecoder.decode(fileUrl, "UTF-8"); + URL url = new URL(decodedURL); + URI uri; + try { + uri = new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef()); + String decodedURLAsString = uri.toASCIIString(); + data = HawkBitSoftwareModuleHandler.downloadFileData(decodedURLAsString, targetToken); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + if(!checkIfExists(artifactName)) { - LOGGER.info("Uploading to GCS artifact: "+artifactName); - uploadSimple(gcs, GCP_OTA.BUCKET_NAME, artifactName, data); + if(data != null) { + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); + } else { + LOGGER.error("Unable to download the artifact: "+artifactName+" from HawkBit Server"); + } + } else { + LOGGER.debug("Artifact already exists in the bucket"); } } public static String getFirmwareInfoBucket(String artifactName) { - StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); if(storageObject != null) { JsonObject jsonObject = new JsonObject(); @@ -103,16 +123,16 @@ public static String getFirmwareInfoBucket(String artifactName) public static Map> getFirmwareInfoBucket_Map(String artifactName) { - StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); if(storageObject != null) { Map> fw_update = new HashMap<>(1); Map mapContent = new HashMap<>(3); LOGGER.debug(artifactName+" exists!"); - mapContent.put(GCP_OTA.OBJECT_NAME, storageObject.getName()); - mapContent.put(GCP_OTA.URL, storageObject.getMediaLink()); - mapContent.put(GCP_OTA.MD5HASH, storageObject.getMd5Hash()); - fw_update.put(GCP_OTA.FW_UPDATE, mapContent); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); + fw_update.put(GcpOTA.FW_UPDATE, mapContent); return fw_update; } return null; @@ -124,32 +144,32 @@ public static Map>> getFirmwareInfoBucket_MapList Map>> fw_update_Map = new HashMap>>(1); - + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) .map(art -> art.getFilename()) .collect(Collectors.toList()); List> list_fw_update = new ArrayList<>(fwNameList.size()); - + fwNameList.forEach(artifactName -> { - StorageObject storageObject = GCPBucketHandler.getStorageObjectInfo(artifactName); + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); if(storageObject != null) { Map mapContent = new HashMap<>(3); LOGGER.debug(artifactName+" exists!"); - mapContent.put(GCP_OTA.OBJECT_NAME, storageObject.getName()); - mapContent.put(GCP_OTA.URL, storageObject.getMediaLink()); - mapContent.put(GCP_OTA.MD5HASH, storageObject.getMd5Hash()); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); list_fw_update.add(mapContent); } }); - fw_update_Map.put(GCP_OTA.FW_UPDATE, list_fw_update); + fw_update_Map.put(GcpOTA.FW_UPDATE, list_fw_update); return fw_update_Map; } private static boolean checkIfExists(String artifactName) throws IOException { - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); Objects objects; do { objects = objectsList.execute(); @@ -170,7 +190,7 @@ private static boolean checkIfExists(String artifactName) throws IOException { public static StorageObject getStorageObjectInfo(String artifactName) { try { - Storage.Objects.List objectsList = getStorage().objects().list(GCP_OTA.BUCKET_NAME); + Storage.Objects.List objectsList = getStorage().objects().list(GcpOTA.BUCKET_NAME); Objects objects; do { objects = objectsList.execute(); @@ -194,7 +214,7 @@ public static StorageObject getStorageObjectInfo(String artifactName) { private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { - ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); String path = classLoader.getResource("keys.json").getPath(); GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); @@ -208,7 +228,7 @@ private static void listBuckets() throws FileNotFoundException, IOException, Gen credential ).build(); - Storage.Buckets.List bucketsList = storage.buckets().list(GCP_OTA.PROJECT_ID); + Storage.Buckets.List bucketsList = storage.buckets().list(GcpOTA.PROJECT_ID); Buckets buckets; do { buckets = bucketsList.execute(); @@ -221,7 +241,7 @@ private static void listBuckets() throws FileNotFoundException, IOException, Gen bucketsList.setPageToken(buckets.getNextPageToken()); } while (buckets.getNextPageToken() != null); - Storage.Objects.List objectsList = storage.objects().list(GCP_OTA.BUCKET_NAME); + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); Objects objects; do { objects = objectsList.execute(); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java similarity index 87% rename from hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java rename to hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java index b01bff6..100824f 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_FireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java @@ -20,14 +20,14 @@ import com.google.firebase.FirebaseOptions; import com.google.firebase.cloud.FirestoreClient; -public class GCP_FireStore { +public class GcpFireStore { private static Firestore db; public static void init() { try { - ClassLoader classLoader = GCPBucketHandler.class.getClassLoader(); + ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); String path = classLoader.getResource("keys.json").getPath(); GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) @@ -40,7 +40,7 @@ public static void init() { .getDefaultInstance() .toBuilder() .setCredentials(credentials) - .setProjectId(GCP_OTA.PROJECT_ID) + .setProjectId(GcpOTA.PROJECT_ID) .build(); db = firestoreOptions.getService(); @@ -58,9 +58,9 @@ public static void init() { public static void addDocumentMapList(String deviceId, Map>> mapList) { try { DocumentReference docRef = db - .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) .document(deviceId) - .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) .document(deviceId); ApiFuture result = docRef.set(mapList, SetOptions.merge()); System.out.println("Update time : " + result.get().getUpdateTime()); @@ -74,9 +74,9 @@ public static void addDocumentMapList(String deviceId, Map> map) { try { DocumentReference docRef = db - .collection(GCP_OTA.FIRESTORE_DEVICES_COLLECTION) + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) .document(deviceId) - .collection(GCP_OTA.FIRESTORE_CONFIG_COLLECTION) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) .document(deviceId); ApiFuture result = docRef.set(map); System.out.println("Update time : " + result.get().getUpdateTime()); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java similarity index 68% rename from hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java rename to hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java index 4cb8fb3..ea2601c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_IoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java @@ -8,22 +8,19 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.Map; -import org.apache.log4j.spi.LoggerFactory; -import org.eclipse.paho.client.mqttv3.MqttClient; -import org.eclipse.paho.client.mqttv3.MqttException; -import org.eclipse.paho.client.mqttv3.MqttMessage; -import org.eclipse.paho.client.mqttv3.logging.Logger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; import com.google.api.client.http.HttpRequestInitializer; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.jackson2.JacksonFactory; import com.google.api.services.cloudiot.v1.CloudIot; import com.google.api.services.cloudiot.v1.CloudIotScopes; -import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayRequest; -import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayResponse; import com.google.api.services.cloudiot.v1.model.Device; import com.google.api.services.cloudiot.v1.model.DeviceConfig; import com.google.api.services.cloudiot.v1.model.DeviceCredential; @@ -35,19 +32,21 @@ import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; -import com.google.api.services.cloudiot.v1.model.UnbindDeviceFromGatewayRequest; -import com.google.api.services.cloudiot.v1.model.UnbindDeviceFromGatewayResponse; import com.google.common.base.Charsets; import com.google.common.io.Files; +//TODO: how to make this multi-registries? +//allowing getting config and setting config for all +//also firestore might break since devices id are unique per registry +public class GcpIoTHandler { -public class GCP_IoTHandler { - public static GoogleCredential getCredentialsFromFile() - { + private static final Logger LOGGER = LoggerFactory.getLogger(GcpIoTHandler.class); + + public static GoogleCredential getCredentialsFromFile(){ GoogleCredential credential = null; try { - ClassLoader classLoader = GCP_IoTHandler.class.getClassLoader(); + ClassLoader classLoader = GcpIoTHandler.class.getClassLoader(); String path = classLoader.getResource("keys.json").getPath(); credential = GoogleCredential.fromStream(new FileInputStream(path)) .createScoped(CloudIotScopes.all()); @@ -58,10 +57,9 @@ public static GoogleCredential getCredentialsFromFile() } - public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException - { + public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException{ List allDevices_per_project = new ArrayList(); - List gcp_registries = GCP_IoTHandler.listRegistries(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); for(DeviceRegistry gcp_registry : gcp_registries) { allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); @@ -69,80 +67,6 @@ public static List getAllDevices(String projectId, String cloudRegion) t return allDevices_per_project; } - - /** Create a registry for Cloud IoT. */ - public static DeviceRegistry createRegistry( - String cloudRegion, String projectId, String registryName, String pubsubTopicPath) - throws GeneralSecurityException, IOException { - GoogleCredential credential = - GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; - final String fullPubsubPath = "projects/" + projectId + "/topics/" + pubsubTopicPath; - - DeviceRegistry registry = new DeviceRegistry(); - EventNotificationConfig notificationConfig = new EventNotificationConfig(); - notificationConfig.setPubsubTopicName(fullPubsubPath); - List notificationConfigs = new ArrayList(); - notificationConfigs.add(notificationConfig); - registry.setEventNotificationConfigs(notificationConfigs); - registry.setId(registryName); - - DeviceRegistry reg = - service.projects().locations().registries().create(projectPath, registry).execute(); - System.out.println("Created registry: " + reg.getName()); - - return reg; - } - - - public static Device createDeviceWithRs256( - String deviceId, - String certificateFilePath, - String projectId, - String cloudRegion, - String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); - String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); - publicKeyCredential.setKey(key); - publicKeyCredential.setFormat("RSA_X509_PEM"); - - DeviceCredential devCredential = new DeviceCredential(); - devCredential.setPublicKey(publicKeyCredential); - - System.out.println("Creating device with id: " + deviceId); - Device device = new Device(); - device.setId(deviceId); - device.setCredentials(Arrays.asList(devCredential)); - Device createdDevice = - service - .projects() - .locations() - .registries() - .devices() - .create(registryPath, device) - .execute(); - - System.out.println("Created device: " + createdDevice.toPrettyString()); - return createdDevice; - } - public static List listDevices(String projectId, String cloudRegion, String registryName) throws GeneralSecurityException, IOException { JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); @@ -184,9 +108,9 @@ public static List listDevices(String projectId, String cloudRegion, Str public static boolean atLeastOnceConnected(String deviceId) { try { return atLeastOnceConnected(deviceId, - GCP_OTA.PROJECT_ID, - GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME); + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); } catch (GeneralSecurityException | IOException e) { e.printStackTrace(); } @@ -194,6 +118,45 @@ public static boolean atLeastOnceConnected(String deviceId) { } + /** + * Retrieves Device Metadata + * @return Map of metadata + * */ + public static Map getDeviceMetadata(String deviceId) { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service; + try { + service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, + deviceId); + + return service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute().getMetadata(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } catch(GoogleJsonResponseException e) { + e.printStackTrace(); + LOGGER.error("Couldn't find the device: "+deviceId+" in the registry"); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) throws GeneralSecurityException, IOException { JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); @@ -232,134 +195,6 @@ private static boolean atLeastOnceConnected(String deviceId, String projectId, S - - - /** Create a device to bind to a gateway. */ - public static void createDevice( - String projectId, String cloudRegion, String registryName, String deviceId) - throws GeneralSecurityException, IOException { - // [START create_device] - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - List devices = - service - .projects() - .locations() - .registries() - .devices() - .list(registryPath) - .setFieldMask("config,gatewayConfig") - .execute() - .getDevices(); - - if (devices != null) { - System.out.println("Found " + devices.size() + " devices"); - for (Device d : devices) { - if ((d.getId() != null && d.getId().equals(deviceId)) - || (d.getName() != null && d.getName().equals(deviceId))) { - System.out.println("Device exists, skipping."); - return; - } - } - } - } - - - public static void bindDeviceToGateway( - String projectId, String cloudRegion, String registryName, String deviceId, String gatewayId) - throws GeneralSecurityException, IOException { - // [START bind_device_to_gateway] - createDevice(projectId, cloudRegion, registryName, deviceId); - - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - BindDeviceToGatewayRequest request = new BindDeviceToGatewayRequest(); - request.setDeviceId(deviceId); - request.setGatewayId(gatewayId); - - BindDeviceToGatewayResponse response = - service - .projects() - .locations() - .registries() - .bindDeviceToGateway(registryPath, request) - .execute(); - - System.out.println(String.format("Device bound: %s", response.toPrettyString())); - // [END bind_device_to_gateway] - } - - public static void unbindDeviceFromGateway( - String projectId, String cloudRegion, String registryName, String deviceId, String gatewayId) - throws GeneralSecurityException, IOException { - // [START unbind_device_from_gateway] - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - UnbindDeviceFromGatewayRequest request = new UnbindDeviceFromGatewayRequest(); - request.setDeviceId(deviceId); - request.setGatewayId(gatewayId); - - UnbindDeviceFromGatewayResponse response = - service - .projects() - .locations() - .registries() - .unbindDeviceFromGateway(registryPath, request) - .execute(); - - System.out.println(String.format("Device unbound: %s", response.toPrettyString())); - // [END unbind_device_from_gateway] - } - - public static void attachDeviceToGateway(MqttClient client, String deviceId) - throws MqttException { - // [START attach_device] - final String attachTopic = String.format("/devices/%s/attach", deviceId); - System.out.println(String.format("Attaching: %s", attachTopic)); - String attachPayload = "{}"; - MqttMessage message = new MqttMessage(attachPayload.getBytes()); - message.setQos(1); - client.publish(attachTopic, message); - // [END attach_device] - } - - /** Detaches a bound device from the Gateway. */ - public static void detachDeviceFromGateway(MqttClient client, String deviceId) - throws MqttException { - // [START detach_device] - final String detachTopic = String.format("/devices/%s/detach", deviceId); - System.out.println(String.format("Detaching: %s", detachTopic)); - String attachPayload = "{}"; - MqttMessage message = new MqttMessage(attachPayload.getBytes()); - message.setQos(1); - client.publish(detachTopic, message); - // [END detach_device] - } - /** Lists all of the registries associated with the given project. */ public static List listRegistries(String projectId, String cloudRegion) throws GeneralSecurityException, IOException { @@ -600,4 +435,47 @@ public static DeviceRegistry getRegistry( return service.projects().locations().registries().get(registryPath).execute(); } + + public static Device createDeviceWithRs256( + String deviceId, + String certificateFilePath, + String projectId, + String cloudRegion, + String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); + String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); + publicKeyCredential.setKey(key); + publicKeyCredential.setFormat("RSA_X509_PEM"); + + DeviceCredential devCredential = new DeviceCredential(); + devCredential.setPublicKey(publicKeyCredential); + + System.out.println("Creating device with id: " + deviceId); + Device device = new Device(); + device.setId(deviceId); + device.setCredentials(Arrays.asList(devCredential)); + Device createdDevice = + service + .projects() + .locations() + .registries() + .devices() + .create(registryPath, device) + .execute(); + + System.out.println("Created device: " + createdDevice.toPrettyString()); + return createdDevice; + } + } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java similarity index 82% rename from hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java rename to hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java index ae1eab4..9e5b506 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_OTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -1,21 +1,17 @@ package org.eclipse.hawkbit.google.gcp; -import org.eclipse.hawkbit.simulator.UpdateStatus; -import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +public class GcpOTA { -public class GCP_OTA { - - //TODO: Configurations to take outside of here + //TODO: Configurations to take outside of here same for keys.json public final static String PROJECT_ID = "ota-iot-231619"; public final static String CLOUD_REGION = "us-central1"; public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; -// // public final static String PROJECT_ID = "ikea-homesmart-workshop"; // public final static String CLOUD_REGION = "europe-west1"; // public final static String REGISTRY_NAME = "tradfri"; -// public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; //TODO: +// public final static String BUCKET_NAME = "ikea-homesmart-workshop.appspot.com"; //TODO: public final static String FW_MSG_RECEIVED = "msg-received"; public final static String FW_INSTALLING = "installing"; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java similarity index 77% rename from hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java rename to hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java index 539bfd2..4535ed0 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GCP_Subscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java @@ -27,7 +27,7 @@ import com.google.pubsub.v1.ProjectSubscriptionName; import com.google.pubsub.v1.PubsubMessage; -public class GCP_Subscriber { +public class GcpSubscriber { private static final BlockingQueue messages = new LinkedBlockingDeque<>(); private static Map mapCallbacks = new HashMap(); @@ -36,7 +36,7 @@ public class GCP_Subscriber { private static Gson gson = new Gson(); - private static final Logger LOGGER = LoggerFactory.getLogger(GCP_Subscriber.class); + private static final Logger LOGGER = LoggerFactory.getLogger(GcpSubscriber.class); static class StateMessageReceiver implements MessageReceiver { @@ -50,7 +50,7 @@ public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { /** Receive messages over a subscription. */ public static void init() { ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of( - GCP_OTA.PROJECT_ID, GCP_OTA.SUBSCRIPTION_STATE_ID); + GcpOTA.PROJECT_ID, GcpOTA.SUBSCRIPTION_STATE_ID); Subscriber subscriber = null; try { // create a subscriber bound to the asynchronous message receiver @@ -60,7 +60,7 @@ public static void init() { // Continue to listen to messages while (true) { PubsubMessage message = messages.take(); - if(!GCP_OTA.FW_VIA_COMMAND) { + if(!GcpOTA.FW_VIA_COMMAND) { updateHawkbitStatus(message); } } @@ -76,30 +76,30 @@ public static void init() { public static void updateHawkbitStatus(PubsubMessage message){ - if(message.getData().toStringUtf8().contains(GCP_OTA.SUBSCRIPTION_FW_STATE)) { + if(message.getData().toStringUtf8().contains(GcpOTA.SUBSCRIPTION_FW_STATE)) { JsonObject payloadJson = gson.fromJson(message.getData() .toStringUtf8(), JsonObject.class); - if(payloadJson.has(GCP_OTA.SUBSCRIPTION_FW_STATE)) { - String deviceId = message.getAttributesMap().get(GCP_OTA.DEVICE_ID); - String fw_state = payloadJson.get(GCP_OTA.SUBSCRIPTION_FW_STATE).getAsString(); + if(payloadJson.has(GcpOTA.SUBSCRIPTION_FW_STATE)) { + String deviceId = message.getAttributesMap().get(GcpOTA.DEVICE_ID); + String fw_state = payloadJson.get(GcpOTA.SUBSCRIPTION_FW_STATE).getAsString(); if(deviceId != null && fw_state != null) { UpdateStatus updateStatus = null; System.out.println("====> New state received "+fw_state+ " from device "+deviceId); switch (fw_state) { - case GCP_OTA.FW_MSG_RECEIVED : + case GcpOTA.FW_MSG_RECEIVED : updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); sendUpate(deviceId, updateStatus); break; - case GCP_OTA.FW_DOWNLOADING: + case GcpOTA.FW_DOWNLOADING: updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); sendUpate(deviceId, updateStatus); break; - case GCP_OTA.FW_INSTALLING : + case GcpOTA.FW_INSTALLING : updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); sendUpate(deviceId, updateStatus); break; - case GCP_OTA.FW_INSTALLED: + case GcpOTA.FW_INSTALLED: updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); sendUpate(deviceId, updateStatus); @@ -119,7 +119,7 @@ public static void updateHawkbitStatus(PubsubMessage message){ LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); //Device never connected - if(!GCP_IoTHandler.atLeastOnceConnected(deviceId)) { + if(!GcpIoTHandler.atLeastOnceConnected(deviceId)) { LOGGER.error(deviceId+" : device was never connected"); sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); } @@ -138,14 +138,14 @@ private static String getStringFromListMap(Map> JsonObject fw_update = new JsonObject(); JsonArray fw_update_list = new JsonArray(); - listMap.get(GCP_OTA.FW_UPDATE).forEach(map -> { + listMap.get(GcpOTA.FW_UPDATE).forEach(map -> { JsonObject mapJsonObject = new JsonObject(); - mapJsonObject.addProperty(GCP_OTA.OBJECT_NAME, map.get(GCP_OTA.OBJECT_NAME)); - mapJsonObject.addProperty(GCP_OTA.URL, map.get(GCP_OTA.URL)); - mapJsonObject.addProperty(GCP_OTA.MD5HASH, map.get(GCP_OTA.MD5HASH)); + mapJsonObject.addProperty(GcpOTA.OBJECT_NAME, map.get(GcpOTA.OBJECT_NAME)); + mapJsonObject.addProperty(GcpOTA.URL, map.get(GcpOTA.URL)); + mapJsonObject.addProperty(GcpOTA.MD5HASH, map.get(GcpOTA.MD5HASH)); fw_update_list.add(mapJsonObject); }); - fw_update.add(GCP_OTA.FW_UPDATE,fw_update_list); + fw_update.add(GcpOTA.FW_UPDATE,fw_update_list); return gson.toJson(fw_update); } @@ -153,17 +153,17 @@ private static String getStringFromListMap(Map> private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { Map>> data = - GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); + GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); if(data != null) { - long configVersion = GCP_IoTHandler.getLatestConfig(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME); + long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); - GCP_IoTHandler.setDeviceConfiguration(deviceId, GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); LOGGER.info("Writing to Firestore "); - GCP_FireStore.addDocumentMapList(deviceId - , GCPBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); + GcpFireStore.addDocumentMapList(deviceId + , GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); } else LOGGER.error("Artifacts is empty for device "+deviceId); } @@ -172,16 +172,16 @@ private static void sendAsyncFwUpgradeList(String deviceId, List Attempting download to the device \n"+data); - GCP_IoTHandler.sendCommand(device.getId(), GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION, - GCP_OTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); + GcpIoTHandler.sendCommand(device.getId(), GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); } private UpdateStatus simulateDownloads() { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index e5a1bc8..efe8f45 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -76,7 +76,6 @@ private AbstractSimulatedDevice createSimulatedDevice(final String id, final Str final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { switch (protocol) { case DMF_AMQP: - System.out.println("Creating DMF device "+id); return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); case DDI_HTTP: return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index 8cc8529..0e8f3a1 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -14,8 +14,8 @@ import java.security.GeneralSecurityException; import java.util.List; -import org.eclipse.hawkbit.google.gcp.GCP_IoTHandler; -import org.eclipse.hawkbit.google.gcp.GCP_OTA; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.google.gcp.GcpOTA; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.springframework.beans.factory.annotation.Autowired; @@ -105,7 +105,8 @@ ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulat try { - List allDevices_gcp = GCP_IoTHandler.getAllDevices(GCP_OTA.PROJECT_ID, GCP_OTA.CLOUD_REGION); + List allDevices_gcp = GcpIoTHandler.listDevices(GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME); for(Device gcp_device : allDevices_gcp) { System.out.println("[GCP Device] "+gcp_device.getId()); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 911073d..ae10282 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -11,8 +11,8 @@ import java.net.MalformedURLException; import java.net.URL; -import org.eclipse.hawkbit.google.gcp.GCP_FireStore; -import org.eclipse.hawkbit.google.gcp.GCP_Subscriber; +import org.eclipse.hawkbit.google.gcp.GcpFireStore; +import org.eclipse.hawkbit.google.gcp.GcpSubscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,9 +51,9 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { LOGGER.debug("Init Firestore ... "); - GCP_FireStore.init(); + GcpFireStore.init(); LOGGER.debug("Init Subscriber ... "); - GCP_Subscriber.init(); + GcpSubscriber.init(); //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket simulationProperties.getAutostarts().forEach(autostart -> { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index b5d69be..be1d8cd 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -23,7 +23,7 @@ import org.eclipse.hawkbit.dmf.amqp.api.MessageType; import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; -import org.eclipse.hawkbit.google.gcp.GCPBucketHandler; +import org.eclipse.hawkbit.google.gcp.GcpBucketHandler; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; @@ -268,8 +268,7 @@ private void handleUpdateProcess(final Message message, final String thingId, fi artifact -> { try { - System.out.println("Handling artifact : "+artifact.getFilename()); - GCPBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); + GcpBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index d35ac3b..eb910e2 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -12,7 +12,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.stream.Collectors; import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; @@ -22,6 +21,7 @@ import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; import org.eclipse.hawkbit.simulator.SimulationProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,10 +84,10 @@ public void ping(final String tenant, final String correlationId) { * installation due to maintenance window. */ public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { - System.out.println("[DmfSenderService] Update Process"); - final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, - updateResultMessages); - sendMessage(spExchange, updateResultMessage); + System.out.println("[DmfSenderService] Update Process"); + final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, + updateResultMessages); + sendMessage(spExchange, updateResultMessage); } /** @@ -114,7 +114,7 @@ public void finishUpdateProcessWithError(final SimulatedUpdate update, final Lis * the amqp message which will be send if its not null */ public void sendMessage(final String address, final Message message) { - + if (message == null) { System.out.println("[DmfSenderService] received a null message"); @@ -171,7 +171,7 @@ public Message convertMessage(final Object object, final MessageProperties messa * the ID of the action for the error message */ public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { - + final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, actionId); @@ -243,11 +243,12 @@ public void createOrUpdateThing(final String tenant, final String targetId) { */ public void updateAttributesOfThing(final String tenant, final String targetId) { System.out.printf("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); - sendMessage(spExchange, updateAttributes(tenant, targetId, DmfUpdateMode.MERGE, - simulationProperties.getAttributes().stream().collect(Collectors - .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); - - LOGGER.info("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); + Map metadata = GcpIoTHandler.getDeviceMetadata(targetId); + sendMessage(spExchange, + updateAttributes(tenant, + targetId, + DmfUpdateMode.MERGE, + metadata)); } /** @@ -301,7 +302,6 @@ private MessageProperties createAttributeUpdateMessage(final String tenant, fina private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, final Map attributes) { System.out.println("[DmfSenderService] AttributeUpdateMessage"); - final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); attributeUpdate.setMode(mode); @@ -312,7 +312,7 @@ private Message updateAttributes(final String tenant, final String targetId, fin return m; } - + /** * Send a created message to SP. From 043af06fd5930234a73a218be1d428c52e1781af Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:44:09 -0400 Subject: [PATCH 47/54] update --- hawkbit-device-simulator/dockerBuild.sh | 5 +- hawkbit-device-simulator/pom.xml | 60 +----- .../hawkbit/google/gcp/GcpBucketHandler.java | 46 ++-- .../hawkbit/google/gcp/GcpCredentials.java | 83 ++++++++ .../hawkbit/google/gcp/GcpFireStore.java | 45 ++-- .../hawkbit/google/gcp/GcpIoTHandler.java | 199 +++++++----------- .../eclipse/hawkbit/google/gcp/GcpOTA.java | 18 +- .../hawkbit/google/gcp/GcpProperties.java | 62 ++++++ .../hawkbit/google/gcp/GcpSubscriber.java | 25 +-- .../hawkbit/simulator/DDISimulatedDevice.java | 3 +- .../hawkbit/simulator/DMFSimulatedDevice.java | 9 +- .../hawkbit/simulator/DeviceSimulator.java | 2 + .../simulator/DeviceSimulatorUpdater.java | 14 +- .../simulator/SimulationController.java | 7 +- .../hawkbit/simulator/SimulatorStartup.java | 9 +- .../simulator/amqp/DmfReceiverService.java | 27 +-- 16 files changed, 307 insertions(+), 307 deletions(-) create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java diff --git a/hawkbit-device-simulator/dockerBuild.sh b/hawkbit-device-simulator/dockerBuild.sh index 6b2fb2f..86ce52f 100755 --- a/hawkbit-device-simulator/dockerBuild.sh +++ b/hawkbit-device-simulator/dockerBuild.sh @@ -1,3 +1,4 @@ mvn clean install -docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbull/ota . -docker run -d --name=jetty -p 8083:8083 charbull/ota:latest +mvn package +docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbel/ota . +docker run --network="docker_default" -v /Users/charbelk/dev/OSS/HawkBit-GCP/hawkbit-gcp-integrator/src/main/resources/:/opt/resources -d --name=HawkBit-GCP -p 8083:8083 charbel/ota:latest --PROJECT_ID=ota-iot-231619 --CLOUD_REGION=us-central1 --REGISTRY_NAME=OTA-DeviceRegistry --BUCKET_NAME=ota-iot-231619.appspot.com --KEYS=/opt/resources/keys.json \ No newline at end of file diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 7e88894..7e87b0b 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -9,35 +9,13 @@ hawkbit-examples-parent - hawkbit-gcp-integration - hawkbit-gcp-manager + hawkbit-gcp-iot-integration + hawkbit-gcp-iot-manager hawkBit :: GCP :: Manager Device Management Federation API with GCP - - charbel - - - com.spotify - dockerfile-maven-plugin - 1.4.10 - - HawkBit-GCP - java - ${project.build.directory}/docker - ["java", "-jar", "/${project.build.finalName}.jar"] - ${docker.image.prefix}/${project.artifactId} - - - / - ${project.build.directory} - ${project.build.finalName}.jar - - - - org.springframework.boot spring-boot-maven-plugin @@ -48,42 +26,12 @@ ${baseDir} - org.eclipse.hawkbit.simulator.DeviceSimulator + org.eclipse.hawkbit.handler.GcpModuleIntegrator JAR - - com.google.cloud.tools - appengine-maven-plugin - 1.3.2 - - 1 - - - - org.apache.maven.plugins - maven-dependency-plugin - - - unpack - package - - unpack - - - - - ${project.groupId} - ${project.artifactId} - ${project.version} - - - - - - @@ -94,7 +42,7 @@ true ${project.build.directory} - manifest.yml + keys.jsons diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java index d267f2a..e55dbbc 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java @@ -7,10 +7,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.net.URLDecoder; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Collections; @@ -57,9 +53,7 @@ private static Storage getStorage() { try { if(storage == null) { - ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + GoogleCredential credential = GcpCredentials.getCredential() .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); httpTransport = GoogleNetHttpTransport.newTrustedTransport(); @@ -76,29 +70,13 @@ private static Storage getStorage() { } public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { - Storage gcs = getStorage(); - String data = null; - String decodedURL = URLDecoder.decode(fileUrl, "UTF-8"); - URL url = new URL(decodedURL); - URI uri; - try { - uri = new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef()); - String decodedURLAsString = uri.toASCIIString(); - data = HawkBitSoftwareModuleHandler.downloadFileData(decodedURLAsString, targetToken); - } catch (URISyntaxException e) { - e.printStackTrace(); - } + Storage gcs = getStorage(); + String data = HawkBitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); if(!checkIfExists(artifactName)) { - if(data != null) { - LOGGER.info("Uploading to GCS artifact: "+artifactName); - uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); - } else { - LOGGER.error("Unable to download the artifact: "+artifactName+" from HawkBit Server"); - } - } else { - LOGGER.debug("Artifact already exists in the bucket"); + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); } } @@ -108,7 +86,7 @@ public static String getFirmwareInfoBucket(String artifactName) if(storageObject != null) { JsonObject jsonObject = new JsonObject(); - LOGGER.debug(artifactName+" exists!"); + LOGGER.info(artifactName+" exists!"); jsonObject.addProperty("ObjectName", storageObject.getName()); jsonObject.addProperty("Url", storageObject.getMediaLink()); jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); @@ -128,7 +106,7 @@ public static Map> getFirmwareInfoBucket_Map(String ar { Map> fw_update = new HashMap<>(1); Map mapContent = new HashMap<>(3); - LOGGER.debug(artifactName+" exists!"); + LOGGER.info(artifactName+" exists!"); mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); @@ -144,19 +122,19 @@ public static Map>> getFirmwareInfoBucket_MapList Map>> fw_update_Map = new HashMap>>(1); - + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) .map(art -> art.getFilename()) .collect(Collectors.toList()); List> list_fw_update = new ArrayList<>(fwNameList.size()); - + fwNameList.forEach(artifactName -> { StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); if(storageObject != null) { Map mapContent = new HashMap<>(3); - LOGGER.debug(artifactName+" exists!"); + LOGGER.info(artifactName+" exists!"); mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); @@ -178,7 +156,7 @@ private static boolean checkIfExists(String artifactName) throws IOException { for (StorageObject object : items) { if(object.getName().equalsIgnoreCase(artifactName)) { - LOGGER.debug(artifactName+" already exists!"); + LOGGER.info(artifactName+" already exists!"); return true; } } @@ -199,7 +177,7 @@ public static StorageObject getStorageObjectInfo(String artifactName) { for (StorageObject object : items) { if(object.getName().equalsIgnoreCase(artifactName)) { - LOGGER.debug(artifactName+" exists!"); + LOGGER.info(artifactName+" exists!"); return object; } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java new file mode 100644 index 0000000..f9aa889 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java @@ -0,0 +1,83 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; + +public class GcpCredentials { + + + private static Path keysFile = null; + private static GoogleCredentials googleCredentials; + private static GoogleCredential googleCredential; + private static CredentialsProvider credentialsProvider; + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpCredentials.class); + + protected static GoogleCredential getCredential() { + if(googleCredential == null) { + try { + googleCredential = GoogleCredential.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + System.out.println("Please make sure to put your keys.json in the project"); + LOGGER.error("Please make sure to put your keys.json in the project"); + } + } + return googleCredential; + } + + + protected static CredentialsProvider getCredentialProvider() { + if(credentialsProvider == null) { + try { + credentialsProvider = FixedCredentialsProvider.create( + ServiceAccountCredentials.fromStream(Files.newInputStream(keysFile))); + } catch (IOException e) { + e.printStackTrace(); + } + } + return credentialsProvider; + } + + protected static GoogleCredentials getCredentials() { + if(googleCredentials == null) { + try { + googleCredentials = GoogleCredentials.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + } + } + return googleCredentials; + } + + public static void setKeysFilePath(String keysPath) { + LOGGER.info("==========> Setting keys path to "+keysPath); + keysFile = Paths.get(keysPath); + LOGGER.info("==========> Setting keys path to "+keysFile.toString()); + int n; + try (InputStream in = Files.newInputStream(keysFile)) { + while ((n = in.read()) != -1) { + System.out.print((char) n); + } + } catch (IOException x) { + System.err.format("IOException: %s%n", x); + } + LOGGER.info("============================================================ "); + + getCredentials(); + getCredential(); + } + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java index 100824f..6b3f216 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java @@ -1,19 +1,18 @@ package org.eclipse.hawkbit.google.gcp; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.google.api.core.ApiFuture; import com.google.api.services.iam.v1.IamScopes; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.Firestore; -import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.WriteResult; import com.google.firebase.FirebaseApp; @@ -22,36 +21,22 @@ public class GcpFireStore { + private static final Logger LOGGER = LoggerFactory.getLogger(GcpFireStore.class); + private static Firestore db; public static void init() { - try { - ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - - GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - // GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); - - FirestoreOptions firestoreOptions = - FirestoreOptions - .getDefaultInstance() - .toBuilder() - .setCredentials(credentials) - .setProjectId(GcpOTA.PROJECT_ID) - .build(); - db = firestoreOptions.getService(); + GoogleCredentials credentials = GcpCredentials.getCredentials() + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(credentials) + .setProjectId(GcpOTA.PROJECT_ID) + .build(); + FirebaseApp.initializeApp(options); - - - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } + db = FirestoreClient.getFirestore(); } @@ -63,7 +48,7 @@ public static void addDocumentMapList(String deviceId, Map result = docRef.set(mapList, SetOptions.merge()); - System.out.println("Update time : " + result.get().getUpdateTime()); + LOGGER.info("Update time : " + result.get().getUpdateTime()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { @@ -79,7 +64,7 @@ public static void addDocument(String deviceId, Map> .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) .document(deviceId); ApiFuture result = docRef.set(map); - System.out.println("Update time : " + result.get().getUpdateTime()); + LOGGER.info("Update time : " + result.get().getUpdateTime()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java index ea2601c..7b11c8c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java @@ -1,11 +1,7 @@ package org.eclipse.hawkbit.google.gcp; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.Map; @@ -23,49 +19,37 @@ import com.google.api.services.cloudiot.v1.CloudIotScopes; import com.google.api.services.cloudiot.v1.model.Device; import com.google.api.services.cloudiot.v1.model.DeviceConfig; -import com.google.api.services.cloudiot.v1.model.DeviceCredential; import com.google.api.services.cloudiot.v1.model.DeviceRegistry; import com.google.api.services.cloudiot.v1.model.DeviceState; -import com.google.api.services.cloudiot.v1.model.EventNotificationConfig; import com.google.api.services.cloudiot.v1.model.ListDeviceStatesResponse; import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest; -import com.google.api.services.cloudiot.v1.model.PublicKeyCredential; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; -import com.google.common.base.Charsets; -import com.google.common.io.Files; -//TODO: how to make this multi-registries? -//allowing getting config and setting config for all -//also firestore might break since devices id are unique per registry -public class GcpIoTHandler { +public class GcpIoTHandler { private static final Logger LOGGER = LoggerFactory.getLogger(GcpIoTHandler.class); - public static GoogleCredential getCredentialsFromFile(){ + public static GoogleCredential getCredentialsFromFile() + { GoogleCredential credential = null; - try { - ClassLoader classLoader = GcpIoTHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(CloudIotScopes.all()); - } catch (IOException e) { - System.out.println("Please make sure to put your keys.json in the project"); - } + credential = GcpCredentials.getCredential() + .createScoped(CloudIotScopes.all()); return credential; } - public static List getAllDevices(String projectId, String cloudRegion) throws GeneralSecurityException, IOException{ - List allDevices_per_project = new ArrayList(); - List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); - for(DeviceRegistry gcp_registry : gcp_registries) - { - allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); - } - return allDevices_per_project; - } +// public static List getAllDevices(String projectId, String cloudRegion, String registryId) throws GeneralSecurityException, IOException +// { +// List allDevices_per_project = new ArrayList(); +// List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); +// for(DeviceRegistry gcp_registry : gcp_registries) +// { +// allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); +// } +// return allDevices_per_project; +// } public static List listDevices(String projectId, String cloudRegion, String registryName) throws GeneralSecurityException, IOException { @@ -100,6 +84,7 @@ public static List listDevices(String projectId, String cloudRegion, Str System.out.println(); } } else { + LOGGER.warn("Registry has no devices."); System.out.println("Registry has no devices."); } return devices; @@ -118,6 +103,44 @@ public static boolean atLeastOnceConnected(String deviceId) { } + private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + String lastTimeEvent = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastEventTime(); + + String lastHRbeat = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastHeartbeatTime(); + System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); + return (lastTimeEvent !=null || lastHRbeat!=null); + } + + + /** * Retrieves Device Metadata * @return Map of metadata @@ -156,44 +179,10 @@ public static Map getDeviceMetadata(String deviceId) { return null; } + - private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String deviceUniqueId = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - String lastTimeEvent = - service - .projects() - .locations() - .registries() - .devices() - .get(deviceUniqueId) - .execute() - .getLastEventTime(); - - String lastHRbeat = - service - .projects() - .locations() - .registries() - .devices() - .get(deviceUniqueId) - .execute() - .getLastHeartbeatTime(); - System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); - return (lastTimeEvent !=null || lastHRbeat!=null); - } - - + + /** Lists all of the registries associated with the given project. */ public static List listRegistries(String projectId, String cloudRegion) @@ -216,17 +205,17 @@ public static List listRegistries(String projectId, String cloud .getDeviceRegistries(); if (registries != null) { - System.out.println("Found " + registries.size() + " registries"); + LOGGER.info("Found " + registries.size() + " registries"); for (DeviceRegistry r: registries) { - System.out.println("Id: " + r.getId()); - System.out.println("Name: " + r.getName()); + LOGGER.info("Id: " + r.getId()); + LOGGER.info("Name: " + r.getName()); if (r.getMqttConfig() != null) { - System.out.println("Config: " + r.getMqttConfig().toPrettyString()); + LOGGER.info("Config: " + r.getMqttConfig().toPrettyString()); } System.out.println(); } } else { - System.out.println("Project has no registries."); + LOGGER.warn("Project has no registries."); } return registries; @@ -245,7 +234,7 @@ public static void listDeviceConfigs( final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", projectId, cloudRegion, registryName, deviceId); - System.out.println("Listing device configs for " + devicePath); + LOGGER.info("Listing device configs for " + devicePath); List deviceConfigs = service .projects() @@ -258,9 +247,8 @@ public static void listDeviceConfigs( .getDeviceConfigs(); for (DeviceConfig config : deviceConfigs) { - System.out.println("Config version: " + config.getVersion()); - System.out.println("Contents: " + config.getBinaryData()); - System.out.println(); + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); } } catch (Exception e) { @@ -283,7 +271,7 @@ public static long getLatestConfig( final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", projectId, cloudRegion, registryName, deviceId); - System.out.println("Listing device configs for " + devicePath); + LOGGER.info("Listing device configs for " + devicePath); List deviceConfigs = service .projects() @@ -297,8 +285,8 @@ public static long getLatestConfig( for (DeviceConfig config : deviceConfigs) { - System.out.println("Config version: " + config.getVersion()); - System.out.println("Contents: " + config.getBinaryData()); + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); if(configVersion < config.getVersion()) { configVersion = config.getVersion(); @@ -342,7 +330,7 @@ public static void setDeviceConfiguration( .devices() .modifyCloudToDeviceConfig(devicePath, req).execute(); - System.out.println("Updated: " + config.getVersion()); + LOGGER.info("Updated: " + config.getVersion()); } catch (GeneralSecurityException | IOException e) { e.printStackTrace(); } @@ -364,7 +352,7 @@ public static List getDeviceStates( final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", projectId, cloudRegion, registryName, deviceId); - System.out.println("Retrieving device states " + devicePath); + LOGGER.info("Retrieving device states " + devicePath); ListDeviceStatesResponse resp = service .projects() @@ -402,7 +390,7 @@ public static void sendCommand( Base64.Encoder encoder = Base64.getEncoder(); String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); req.setBinaryData(encPayload); - System.out.printf("Sending command to %s\n", devicePath); + LOGGER.info("Sending command to %s\n", devicePath); SendCommandToDeviceResponse res = service .projects() @@ -412,7 +400,7 @@ public static void sendCommand( .sendCommandToDevice(devicePath, req) .execute(); - System.out.println("Command response: " + res.toString()); + LOGGER.info("Command response: " + res.toString()); } catch (Exception e) { e.printStackTrace(); @@ -435,47 +423,4 @@ public static DeviceRegistry getRegistry( return service.projects().locations().registries().get(registryPath).execute(); } - - public static Device createDeviceWithRs256( - String deviceId, - String certificateFilePath, - String projectId, - String cloudRegion, - String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - PublicKeyCredential publicKeyCredential = new PublicKeyCredential(); - String key = Files.asCharSource(new File(certificateFilePath), Charsets.UTF_8).read(); - publicKeyCredential.setKey(key); - publicKeyCredential.setFormat("RSA_X509_PEM"); - - DeviceCredential devCredential = new DeviceCredential(); - devCredential.setPublicKey(publicKeyCredential); - - System.out.println("Creating device with id: " + deviceId); - Device device = new Device(); - device.setId(deviceId); - device.setCredentials(Arrays.asList(devCredential)); - Device createdDevice = - service - .projects() - .locations() - .registries() - .devices() - .create(registryPath, device) - .execute(); - - System.out.println("Created device: " + createdDevice.toPrettyString()); - return createdDevice; - } - } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java index 9e5b506..b3b6c83 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -2,16 +2,16 @@ public class GcpOTA { - //TODO: Configurations to take outside of here same for keys.json - public final static String PROJECT_ID = "ota-iot-231619"; - public final static String CLOUD_REGION = "us-central1"; - public final static String REGISTRY_NAME = "OTA-DeviceRegistry"; - public final static String BUCKET_NAME = "ota-iot-231619.appspot.com"; +// public static String PROJECT_ID = "ota-iot-231619"; +// public static String CLOUD_REGION = "us-central1"; +// public static String REGISTRY_NAME = "OTA-DeviceRegistry"; +// public static String BUCKET_NAME = "ota-iot-231619.appspot.com"; + + public static String PROJECT_ID = ""; + public static String CLOUD_REGION = ""; + public static String REGISTRY_NAME = ""; + public static String BUCKET_NAME = ""; -// public final static String PROJECT_ID = "ikea-homesmart-workshop"; -// public final static String CLOUD_REGION = "europe-west1"; -// public final static String REGISTRY_NAME = "tradfri"; -// public final static String BUCKET_NAME = "ikea-homesmart-workshop.appspot.com"; //TODO: public final static String FW_MSG_RECEIVED = "msg-received"; public final static String FW_INSTALLING = "installing"; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java new file mode 100644 index 0000000..d64b8c7 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java @@ -0,0 +1,62 @@ +package org.eclipse.hawkbit.google.gcp; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; + +public class GcpProperties { + + public static void parseCLI(String[] args) { + // create Options object + Options options = new Options(); + Option keys = Option.builder().argName("KEYS").hasArg().required() + .longOpt("KEYS").desc("Keys file path from a GCP project Service Account").build(); + Option gcpProjectID = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("PROJECT_ID").desc("GCP PROJECT_ID").build(); + Option gcpCloudRegion = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("CLOUD_REGION").desc("GCP CLOUD_REGION").build(); + Option gcpRegistryName = Option.builder().argName("REGISTRY_NAME").hasArg().required() + .longOpt("REGISTRY_NAME").desc("GCP REGISTRY_NAME").build(); + Option gcpBucketName = Option.builder().argName("BUCKET_NAME").hasArg().required() + .longOpt("BUCKET_NAME").desc("GCP BUCKET_NAME").build(); + + options.addOption(keys); + options.addOption(gcpProjectID); + options.addOption(gcpCloudRegion); + options.addOption(gcpRegistryName); + options.addOption(gcpBucketName); + + DefaultParser defaultParser = new DefaultParser(); + HelpFormatter formatter = new HelpFormatter(); + + CommandLine line; + try { + line = defaultParser.parse(options, args); + + if (line.hasOption("PROJECT_ID")) { + GcpOTA.PROJECT_ID = line.getOptionValue("PROJECT_ID"); + } + if (line.hasOption("CLOUD_REGION")) { + GcpOTA.CLOUD_REGION = line.getOptionValue("CLOUD_REGION"); + } + if (line.hasOption("REGISTRY_NAME")) { + GcpOTA.REGISTRY_NAME = line.getOptionValue("REGISTRY_NAME"); + } + if (line.hasOption("BUCKET_NAME")) { + GcpOTA.BUCKET_NAME = line.getOptionValue("BUCKET_NAME"); + } + if (line.hasOption("KEYS")) { + GcpCredentials.setKeysFilePath(line.getOptionValue("KEYS")); + } + + } catch (IllegalArgumentException | ParseException e) { + System.out.println(e.getMessage()); + formatter.printHelp("help", options); + System.exit(0); + } + } + +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java index 4535ed0..3b3f94b 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java @@ -1,8 +1,5 @@ package org.eclipse.hawkbit.google.gcp; - - - import java.util.HashMap; import java.util.List; import java.util.Map; @@ -11,8 +8,8 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.UpdateStatus; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; @@ -53,9 +50,10 @@ public static void init() { GcpOTA.PROJECT_ID, GcpOTA.SUBSCRIPTION_STATE_ID); Subscriber subscriber = null; try { + // create a subscriber bound to the asynchronous message receiver subscriber = - Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).build(); + Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).setCredentialsProvider(GcpCredentials.getCredentialProvider()).build(); subscriber.startAsync().awaitRunning(); // Continue to listen to messages while (true) { @@ -85,7 +83,7 @@ public static void updateHawkbitStatus(PubsubMessage message){ if(deviceId != null && fw_state != null) { UpdateStatus updateStatus = null; - System.out.println("====> New state received "+fw_state+ " from device "+deviceId); + LOGGER.info("====> New state received "+fw_state+ " from device "+deviceId); switch (fw_state) { case GcpOTA.FW_MSG_RECEIVED : updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); @@ -125,10 +123,10 @@ public static void updateHawkbitStatus(PubsubMessage message){ } } } else { - LOGGER.debug("Ignoring message"); + LOGGER.info("Ignoring message"); } } else { - LOGGER.debug("Ignoring message"); + LOGGER.info("Ignoring message"); } } @@ -171,6 +169,7 @@ private static void sendAsyncFwUpgradeList(String deviceId, List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) - // .map(art -> art.getFilename()) - // .collect(Collectors.toList()); - // fwNameList.forEach(fw -> sendAsyncFwUpgrade(device.getId(), fw)); - //[End of comment] - mapCallbacks.put(device.getId(), callback); mapDevices.put(device.getId(), device); } else { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java index a6a1347..2beee8c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java @@ -75,8 +75,7 @@ public DDISimulatedDevice(final String id, final String tenant, final int pollDe this.controllerResource = controllerResource; this.deviceUpdater = deviceUpdater; this.gatewayToken = gatewayToken; - System.out.printf("[DDISimulatedDevice] Id: %s, tenant: %s \n", id, tenant); - + LOGGER.info("Id: %s, tenant: %s \n", id, tenant); } @Override diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java index bbd9772..7092ae7 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -9,6 +9,8 @@ package org.eclipse.hawkbit.simulator; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; /** @@ -17,6 +19,9 @@ public class DMFSimulatedDevice extends AbstractSimulatedDevice { private final DmfSenderService spSenderService; + private static final Logger LOGGER = LoggerFactory.getLogger(DMFSimulatedDevice.class); + + /** * @param id * the ID of the device @@ -27,12 +32,12 @@ public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderS final int pollDelaySec) { super(id, tenant, Protocol.DMF_AMQP, pollDelaySec); this.spSenderService = spSenderService; - System.out.printf("[DMFSimulatedDevice] Id: %s, tenant: %s \n", id, tenant); + LOGGER.info(" Id: {}, tenant: {} \n", id, tenant); } @Override public void poll() { - System.out.println("[DMFSimulatedDevice] handling event "+super.getTenant()); + LOGGER.info("handling event of tenant "+super.getTenant()); spSenderService.createOrUpdateThing(super.getTenant(), super.getId()); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java index 1f2148c..16e6a11 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java @@ -11,6 +11,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import org.eclipse.hawkbit.google.gcp.GcpProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -52,6 +53,7 @@ TaskScheduler taskScheduler() { // Exception squid:S2095 - Spring boot standard behavior @SuppressWarnings({ "squid:S2095" }) public static void main(final String[] args) { + GcpProperties.parseCLI(args); SpringApplication.run(DeviceSimulator.class, args); } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index 3c24eb0..3c2110e 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -156,7 +156,7 @@ public void run() { } if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { - System.out.println("[DeviceSimulator] Download & Install"); + LOGGER.info("Download & Install"); device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); callback.sendFeedback(device); device.clean(); @@ -168,7 +168,7 @@ public void run() { private void syncDownloadGCP(String deviceId, String data) { - System.out.println("==========> Attempting download to the device \n"+data); + LOGGER.info("Attempting download to the device \n"+data); GcpIoTHandler.sendCommand(device.getId(), GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); } @@ -184,8 +184,7 @@ private UpdateStatus simulateDownloads() { final List status = new ArrayList<>(); - LOGGER.info("Simulate downloads for {}", device.getId()); - System.out.printf("Simulate downloads for {}", device.getId()); + LOGGER.info("Simulate downloads for "+device.getId()); modules.forEach(module -> { @@ -220,7 +219,7 @@ private static boolean isErrorResponse(final UpdateStatus status) { private static void handleArtifact(final String targetToken, final String gatewayToken, final List status, final DmfArtifact artifact) { - System.out.println("[DeviceSimulator] handleArtifact "+artifact.getSize()); + LOGGER.info(" handleArtifact "+artifact.getSize()); if (artifact.getUrls().containsKey("HTTPS")) { status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, artifact.getHashes().getSha1(), artifact.getSize())); @@ -232,7 +231,7 @@ private static void handleArtifact(final String targetToken, final String gatewa private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, final String sha1Hash, final long size) { - System.out.println("[DeviceSimulator] downloadingUrl "+url); + LOGGER.info(" downloadingUrl "+url); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, hideTokenDetails(targetToken), sha1Hash, size); @@ -295,7 +294,6 @@ private static UpdateStatus readAndCheckDownloadUrl(final String url, final Stri // } final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; - System.out.println("[DeviceSimulator] "+message); LOGGER.debug(message); return new UpdateStatus(ResponseStatus.SUCCESSFUL, message); } @@ -322,7 +320,7 @@ private static String getPayload(final CloseableHttpResponse response) try { InputStream is = response.getEntity().getContent(); payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); - System.out.println("Payload ==========> "+payload); + LOGGER.info("Payload: "+payload); } catch (Exception e) { e.printStackTrace(); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index 0e8f3a1..e1c9ab3 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -7,7 +7,8 @@ * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.hawkbit.simulator; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; @@ -40,6 +41,8 @@ public class SimulationController { private final AmqpProperties amqpProperties; private final SimulationProperties simulationProperties; + + private static final Logger LOGGER = LoggerFactory.getLogger(SimulationController.class); Gson gson = new Gson(); @@ -109,7 +112,7 @@ ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulat GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME); for(Device gcp_device : allDevices_gcp) { - System.out.println("[GCP Device] "+gcp_device.getId()); + LOGGER.info("GCP Device: "+gcp_device.getId()); repository.add(deviceFactory. createSimulatedDevice(gcp_device.getId(), simulationProperties.getDefaultTenant(), diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index ae10282..478cd3f 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -49,15 +49,16 @@ public void onApplicationEvent(final ApplicationReadyEvent event) { LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); - - LOGGER.debug("Init Firestore ... "); + amqpProperties.setEnabled(true); + + LOGGER.info("Init Firestore ... "); GcpFireStore.init(); - LOGGER.debug("Init Subscriber ... "); + LOGGER.info("Init Subscriber ... "); GcpSubscriber.init(); //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket simulationProperties.getAutostarts().forEach(autostart -> { - LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); + LOGGER.info("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); for (int i = 0; i < autostart.getAmount(); i++) { final String deviceId = autostart.getName() + i; try { diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index be1d8cd..64859ff 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -80,7 +80,7 @@ public class DmfReceiverService extends MessageService { this.spSenderService = spSenderService; this.deviceUpdater = deviceUpdater; this.repository = repository; - System.out.println("[DmfReceiverService] Init"); + LOGGER.info("Init"); } /** @@ -90,7 +90,7 @@ public class DmfReceiverService extends MessageService { * the message to get validated */ private void checkContentTypeJson(final Message message) { - System.out.println("[DmfReceiverService] checkJson "+message.getBody()); + LOGGER.info(" checkJson "+message.getBody()); if (message.getBody().length == 0) { return; } @@ -128,7 +128,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP final MessageType messageType = MessageType.valueOf(type); - System.out.println("[DmfReceiverService] Message received :\n"+message.toString()); + LOGGER.info(" Message received :\n"+message.toString()); if (MessageType.EVENT.equals(messageType)) { checkContentTypeJson(message); @@ -149,7 +149,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP } if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, + LOGGER.info("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); } @@ -165,7 +165,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) void checkDmfHealth() { - System.out.println("[DmfReceiverService] Message CheckDmfHealth "); + LOGGER.info("Message CheckDmfHealth "); if (!amqpProperties.isCheckDmfHealth()) { return; @@ -187,7 +187,7 @@ void checkDmfHealth() { private void handleEventMessage(final Message message, final String thingId) { - System.out.println("[DmfReceiverService] handling event "+thingId); + LOGGER.info("handling event "+thingId); final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); if (eventHeader == null) { @@ -197,20 +197,20 @@ private void handleEventMessage(final Message message, final String thingId) { // Exception squid:S2259 - Checked before @SuppressWarnings({ "squid:S2259" }) final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); - System.out.println("[DmfReceiverService] EventTopic "+eventTopic); + LOGGER.info("EventTopic "+eventTopic); switch (eventTopic) { case DOWNLOAD_AND_INSTALL: case DOWNLOAD: - System.out.println("[DmfReceiverService] Download with message:\n"+message.toString()); + LOGGER.info("Download with message:\n"+message.toString()); handleUpdateProcess(message, thingId, eventTopic); break; case CANCEL_DOWNLOAD: - System.out.println("[DmfReceiverService] Cancel Download with message:\n"+message.toString()); + LOGGER.info("Cancel Download with message:\n"+message.toString()); handleCancelDownloadAction(message, thingId); break; case REQUEST_ATTRIBUTES_UPDATE: - System.out.println("[DmfReceiverService] Attributes update with message:\n"+message.toString()); + LOGGER.info("Attributes update with message:\n"+message.toString()); handleAttributeUpdateRequest(message, thingId); break; default: @@ -223,7 +223,7 @@ private void handleAttributeUpdateRequest(final Message message, final String th final MessageProperties messageProperties = message.getMessageProperties(); final Map headers = messageProperties.getHeaders(); final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - System.out.println("[DmfReceiverService] handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); + LOGGER.info("handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); spSenderService.updateAttributesOfThing(tenant, thingId); } @@ -246,7 +246,7 @@ public void handleCancelDownloadAction(final Message message, final String thing } private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { - System.out.println("[DmfReceiverService] handling update "+thingId); + LOGGER.info(" handling update "+thingId); final MessageProperties messageProperties = message.getMessageProperties(); final Map headers = messageProperties.getHeaders(); @@ -268,6 +268,7 @@ private void handleUpdateProcess(final Message message, final String thingId, fi artifact -> { try { + LOGGER.info("Handling artifact : "+artifact.getFilename()); GcpBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); } catch (FileNotFoundException e) { e.printStackTrace(); @@ -285,7 +286,7 @@ private void handleUpdateProcess(final Message message, final String thingId, fi } private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { - System.out.println("[DmfReceiverService] sendFeedback event "+device.getId()); + LOGGER.info(" sendFeedback event "+device.getId()); switch (device.getUpdateStatus().getResponseStatus()) { case SUCCESSFUL: From 0f41faf6c2fd1a5218f90217d315ff33c3afc5d7 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:45:38 -0400 Subject: [PATCH 48/54] renaming --- hawkbit-gcp-iot-core/.gitignore | 5 + hawkbit-gcp-iot-core/README.md | 150 ++++++ hawkbit-gcp-iot-core/appEngineDeploy.sh | 1 + .../docker/0.2.0-SNAPSHOT/Dockerfile | 18 + .../docker/0.3.0-SNAPSHOT/Dockerfile | 10 + .../docker/0.3.0-SNAPSHOT/Dockerfile.original | 18 + hawkbit-gcp-iot-core/docker/Dockerfile | 8 + hawkbit-gcp-iot-core/dockerBuild.sh | 4 + hawkbit-gcp-iot-core/images/rolloutConfig.png | Bin 0 -> 171370 bytes hawkbit-gcp-iot-core/logAppEngine.sh | 1 + hawkbit-gcp-iot-core/mvn.sh | 1 + hawkbit-gcp-iot-core/pom.xml | 207 +++++++++ hawkbit-gcp-iot-core/pullCompileRun.sh | 2 + hawkbit-gcp-iot-core/runSpring.sh | 2 + .../src/main/appengine/app.yaml | 9 + .../hawkbit/google/gcp/GcpBucketHandler.java | 270 +++++++++++ .../hawkbit/google/gcp/GcpCredentials.java | 83 ++++ .../hawkbit/google/gcp/GcpFireStore.java | 76 ++++ .../hawkbit/google/gcp/GcpIoTHandler.java | 426 ++++++++++++++++++ .../eclipse/hawkbit/google/gcp/GcpOTA.java | 36 ++ .../hawkbit/google/gcp/GcpProperties.java | 62 +++ .../hawkbit/google/gcp/GcpSubscriber.java | 231 ++++++++++ .../gcp/HawkBitSoftwareModuleHandler.java | 74 +++ .../gcp/RetryHttpInitializerWrapper.java | 104 +++++ .../simulator/AbstractSimulatedDevice.java | 132 ++++++ .../hawkbit/simulator/DDISimulatedDevice.java | 228 ++++++++++ .../hawkbit/simulator/DMFSimulatedDevice.java | 68 +++ .../hawkbit/simulator/DeviceSimulator.java | 59 +++ .../simulator/DeviceSimulatorRepository.java | 141 ++++++ .../simulator/DeviceSimulatorUpdater.java | 403 +++++++++++++++++ .../simulator/NextPollTimeController.java | 64 +++ .../simulator/SimulatedDeviceFactory.java | 142 ++++++ .../simulator/SimulationController.java | 277 ++++++++++++ .../simulator/SimulationProperties.java | 201 +++++++++ .../hawkbit/simulator/SimulatorStartup.java | 78 ++++ .../hawkbit/simulator/UpdateStatus.java | 106 +++++ .../simulator/amqp/AmqpConfiguration.java | 129 ++++++ .../simulator/amqp/AmqpProperties.java | 93 ++++ .../simulator/amqp/DmfReceiverService.java | 317 +++++++++++++ .../simulator/amqp/DmfSenderService.java | 395 ++++++++++++++++ .../simulator/amqp/MessageService.java | 75 +++ .../simulator/amqp/SimulatedUpdate.java | 49 ++ .../http/GatewayTokenInterceptor.java | 34 ++ .../src/main/resources/.gitignore | 4 + .../src/main/resources/application.properties | 42 ++ .../src/main/resources/logback-spring.xml | 19 + .../src/main/webapp/WEB-INF/appengine-web.xml | 6 + hawkbit-gcp-iot-core/vmInstallDependencies.sh | 41 ++ 48 files changed, 4901 insertions(+) create mode 100644 hawkbit-gcp-iot-core/.gitignore create mode 100644 hawkbit-gcp-iot-core/README.md create mode 100755 hawkbit-gcp-iot-core/appEngineDeploy.sh create mode 100644 hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile create mode 100644 hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile create mode 100644 hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original create mode 100644 hawkbit-gcp-iot-core/docker/Dockerfile create mode 100755 hawkbit-gcp-iot-core/dockerBuild.sh create mode 100644 hawkbit-gcp-iot-core/images/rolloutConfig.png create mode 100755 hawkbit-gcp-iot-core/logAppEngine.sh create mode 100755 hawkbit-gcp-iot-core/mvn.sh create mode 100644 hawkbit-gcp-iot-core/pom.xml create mode 100644 hawkbit-gcp-iot-core/pullCompileRun.sh create mode 100755 hawkbit-gcp-iot-core/runSpring.sh create mode 100644 hawkbit-gcp-iot-core/src/main/appengine/app.yaml create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java create mode 100644 hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java create mode 100644 hawkbit-gcp-iot-core/src/main/resources/.gitignore create mode 100644 hawkbit-gcp-iot-core/src/main/resources/application.properties create mode 100644 hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml create mode 100644 hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml create mode 100644 hawkbit-gcp-iot-core/vmInstallDependencies.sh diff --git a/hawkbit-gcp-iot-core/.gitignore b/hawkbit-gcp-iot-core/.gitignore new file mode 100644 index 0000000..cfb6698 --- /dev/null +++ b/hawkbit-gcp-iot-core/.gitignore @@ -0,0 +1,5 @@ +/bin/ +/target/ +/pom2.xml +/pom3.xml +/pom4.xml diff --git a/hawkbit-gcp-iot-core/README.md b/hawkbit-gcp-iot-core/README.md new file mode 100644 index 0000000..708a95f --- /dev/null +++ b/hawkbit-gcp-iot-core/README.md @@ -0,0 +1,150 @@ +# hawkBit GCP Device Simulator + + +## Spin a VM + +Use the installation script: [vmInstallDependencies.sh](./vmInstallDependencies.sh) + +or +install the following: +### git: +`sudo apt-get install git` + +### java 8 + +- `sudo apt-get install openjdk-8-jdk` + +- `sudo update-alternatives --config java` + +### docker + +Please read the following if you want to know more about how to install it [here](https://docs.docker.com/install/linux/docker-ce/debian/) + +- `sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common` +- `curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -` +- `sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"` +- `sudo apt-get update` +- `sudo apt-get install docker-ce docker-ce-cli containerd.io` + +### maven + +`sudo apt-get install maven` + +### create a service account +### add the service account to the VM in the configuration + +## First Credentials for GCP +- use the same service account +- Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) + +- Rename the downloaded file to `keys.json` + +- Add it to `src/main/resources` + +## Device Registry + +For now, this handler supports only one registry + +## GCP Config + +- Set the projectId and the cloud region in the GCP_OTA.java +- Create a `state` subscription on the state topic +- Create a bucket: gsutil mb gs:/firmware-ota/ +- enable the Token Service API: `cloud iot token` + + +# hawkBit Device Simulator + +The device simulator handles software update commands from the update server. It is designed to be used very conveniently, +for example, from within a browser. Hence, all the endpoints use the GET verb. +-Dhawkbit.device.simulator.amqp.enabled=true + +# Open Ports + +- 8080/tcp —> hawkbit +- 8083/tcp —> gcp manager +- 3306/tcp, 33060/tcp —> Mysql +- 4369/tcp, 5671-5672/tcp, 15671-15672/tcp, 25672/tcp —> rabbitMQ + +# docker on debian + +if you had any difficulty installing docker compose follow the following + +1- Open `docker-compose-stack.yml` and remove the hawkBit simulator part, since we want to run the GCP Manager on the same port +``` + + image: "hawkbit/hawkbit-device-simulator:latest" + networks: + - hawknet + ports: + - "8083:8083" + deploy: + restart_policy: + condition: on-failure + environment: + - 'HAWKBIT_DEVICE_SIMULATOR_AUTOSTARTS_[0]_TENANT=DEFAULT' + - 'SPRING_RABBITMQ_VIRTUALHOST=/' + - 'SPRING_RABBITMQ_HOST=rabbitmq' + - 'SPRING_RABBITMQ_PORT=5672' + - 'SPRING_RABBITMQ_USERNAME=guest' + - 'SPRING_RABBITMQ_PASSWORD=guest' +``` + +2 - Run the following to start it +``` +sudo docker swarm init +sudo docker stack deploy -c docker-compose-stack.yml hawkbit +``` + + +## Firebase config +Follow these steps to configurate firebase with the java sdk [steps](https://firebase.google.com/docs/admin/setup) +Generate the file and place it in `src/main/resources` and name it `firebasekeys.json` + +## MySQL Info + MYSQL_DATABASE: "hawkbit" + MYSQL_USER: "root" + port : 3306 + +## Run on your own workstation +``` +mvn spring-boot:run +``` +or use the the [runSpring.sh](./runSpring.sh) + +## Create Software Distribution + +## Tag your Devices + +## Create a Target Filter + +## Configure the Rollout + +- Error threshold 0 +- Trigger threshold 100 + + + +Follow the same config as in the +![image](./images/rolloutConfig.png) + + + +## Notes + +The simulator has user authentication enabled in **cloud profile**. Default credentials: +* username : admin +* passwd : admin + +This can be configured/disabled by spring boot properties + +## hawkBit APIs + +In case there is no AMQP message broker (like rabbitMQ) running, you can disable the AMQP support for the device simulator, so the simulator is not trying to connect to an amqp message broker. + +Configuration property `hawkbit.device.simulator.amqp.enabled=false` + +## Populate GCP devices +``` +http://localhost:8083/gcp +``` \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/appEngineDeploy.sh b/hawkbit-gcp-iot-core/appEngineDeploy.sh new file mode 100755 index 0000000..fdbb0eb --- /dev/null +++ b/hawkbit-gcp-iot-core/appEngineDeploy.sh @@ -0,0 +1 @@ +mvn appengine:deploy diff --git a/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile b/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile new file mode 100644 index 0000000..08f5a30 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Kai Zimmermann + +ENV HAWKBIT_SIM_VERSION=0.2.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator + +# Http port +EXPOSE 8083 + +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=0.2.0-SNAPSHOT" + +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] diff --git a/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile new file mode 100644 index 0000000..18cc6b5 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile @@ -0,0 +1,10 @@ +FROM openjdk:8-jre +FROM jetty + +MAINTAINER Charbel Kaed + +EXPOSE 8083 + +ADD target/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar /opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar + +ENTRYPOINT ["java","-jar","/opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original new file mode 100644 index 0000000..5c4ebd3 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Kai Zimmermann + +ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator + +# Http port +EXPOSE 8083 + +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" + +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/docker/Dockerfile b/hawkbit-gcp-iot-core/docker/Dockerfile new file mode 100644 index 0000000..2fec7d4 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Charbel Kaed + +# Http port +EXPOSE 8083 + +ENTRYPOINT ["java","-jar","hawkbit-gcp-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/dockerBuild.sh b/hawkbit-gcp-iot-core/dockerBuild.sh new file mode 100755 index 0000000..86ce52f --- /dev/null +++ b/hawkbit-gcp-iot-core/dockerBuild.sh @@ -0,0 +1,4 @@ +mvn clean install +mvn package +docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbel/ota . +docker run --network="docker_default" -v /Users/charbelk/dev/OSS/HawkBit-GCP/hawkbit-gcp-integrator/src/main/resources/:/opt/resources -d --name=HawkBit-GCP -p 8083:8083 charbel/ota:latest --PROJECT_ID=ota-iot-231619 --CLOUD_REGION=us-central1 --REGISTRY_NAME=OTA-DeviceRegistry --BUCKET_NAME=ota-iot-231619.appspot.com --KEYS=/opt/resources/keys.json \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/images/rolloutConfig.png b/hawkbit-gcp-iot-core/images/rolloutConfig.png new file mode 100644 index 0000000000000000000000000000000000000000..e69428718589b84232fb8b8a4ea8920adebd272b GIT binary patch literal 171370 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 z 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℘-n0 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 z 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 literal 0 HcmV?d00001 diff --git a/hawkbit-gcp-iot-core/logAppEngine.sh b/hawkbit-gcp-iot-core/logAppEngine.sh new file mode 100755 index 0000000..27c3c7e --- /dev/null +++ b/hawkbit-gcp-iot-core/logAppEngine.sh @@ -0,0 +1 @@ + gcloud app logs tail -s default diff --git a/hawkbit-gcp-iot-core/mvn.sh b/hawkbit-gcp-iot-core/mvn.sh new file mode 100755 index 0000000..5cac501 --- /dev/null +++ b/hawkbit-gcp-iot-core/mvn.sh @@ -0,0 +1 @@ +mvn clean install \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/pom.xml b/hawkbit-gcp-iot-core/pom.xml new file mode 100644 index 0000000..7e87b0b --- /dev/null +++ b/hawkbit-gcp-iot-core/pom.xml @@ -0,0 +1,207 @@ + + 4.0.0 + + org.eclipse.hawkbit + 0.3.0-SNAPSHOT + hawkbit-examples-parent + + + hawkbit-gcp-iot-integration + hawkbit-gcp-iot-manager + hawkBit :: GCP :: Manager + Device Management Federation API with GCP + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + ${baseDir} + org.eclipse.hawkbit.handler.GcpModuleIntegrator + JAR + + + + + + + + src/main/resources + + + cf + true + ${project.build.directory} + + keys.jsons + + + + + + + + org.eclipse.hawkbit + hawkbit-dmf-api + + + org.eclipse.hawkbit + hawkbit-example-ddi-feign-client + ${project.version} + + + + org.springframework.amqp + spring-rabbit + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-commons + + + org.springframework.cloud + spring-cloud-context + + + org.apache.httpcomponents + httpclient + + + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.0 + + + org.json + json + 20090211 + + + io.jsonwebtoken + jjwt + 0.7.0 + + + joda-time + joda-time + 2.1 + + + com.google.apis + google-api-services-cloudiot + v1-rev20181120-1.27.0 + + + com.google.oauth-client + google-oauth-client + 1.23.0 + + + com.google.api-client + google-api-client + 1.28.0 + + + com.google.auth + google-auth-library-appengine + 0.12.0 + + + + com.google.apis + google-api-services-iam + v1-rev267-1.25.0 + + + commons-cli + commons-cli + 1.4 + + + com.google.cloud + google-cloud-pubsub + 1.63.0 + + + + com.google.apis + google-api-services-storage + v1-rev149-1.25.0 + + + com.google.cloud + google-cloud-firestore + 0.81.0-beta + + + com.google.firebase + firebase-admin + 6.7.0 + + + + com.google.guava + guava + 23.6-jre + + + + + junit + junit + 4.12 + test + + + com.google.truth + truth + 0.34 + test + + + \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/pullCompileRun.sh b/hawkbit-gcp-iot-core/pullCompileRun.sh new file mode 100644 index 0000000..e853d13 --- /dev/null +++ b/hawkbit-gcp-iot-core/pullCompileRun.sh @@ -0,0 +1,2 @@ +git pull +./runSpring.sh diff --git a/hawkbit-gcp-iot-core/runSpring.sh b/hawkbit-gcp-iot-core/runSpring.sh new file mode 100755 index 0000000..58540b1 --- /dev/null +++ b/hawkbit-gcp-iot-core/runSpring.sh @@ -0,0 +1,2 @@ +mvn clean install +mvn spring-boot:run diff --git a/hawkbit-gcp-iot-core/src/main/appengine/app.yaml b/hawkbit-gcp-iot-core/src/main/appengine/app.yaml new file mode 100644 index 0000000..0e6bd11 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/appengine/app.yaml @@ -0,0 +1,9 @@ +service: default +runtime: java +runtime_config: + jdk: openjdk8 +env: flex + +handlers: +- url: /.* + script: this field is required, but ignored \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java new file mode 100644 index 0000000..e55dbbc --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java @@ -0,0 +1,270 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.InputStreamContent; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.iam.v1.IamScopes; +import com.google.api.services.storage.Storage; +import com.google.api.services.storage.model.Bucket; +import com.google.api.services.storage.model.Buckets; +import com.google.api.services.storage.model.Objects; +import com.google.api.services.storage.model.StorageObject; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + + + + +public class GcpBucketHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpBucketHandler.class); + + private static Storage storage = null; + static Gson gson = new Gson(); + + private static HttpTransport httpTransport; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + + + private static Storage getStorage() { + try { + if(storage == null) + { + GoogleCredential credential = GcpCredentials.getCredential() + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + } + } catch (Exception e) { + e.printStackTrace(); + } + return storage; + } + + public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { + + Storage gcs = getStorage(); + String data = HawkBitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); + if(!checkIfExists(artifactName)) + { + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); + } + } + + public static String getFirmwareInfoBucket(String artifactName) + { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + JsonObject jsonObject = new JsonObject(); + LOGGER.info(artifactName+" exists!"); + jsonObject.addProperty("ObjectName", storageObject.getName()); + jsonObject.addProperty("Url", storageObject.getMediaLink()); + jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); + + JsonObject jsonConfig = new JsonObject(); + jsonConfig.add("firmware-update", jsonObject); + return gson.toJson(jsonConfig); + } + return null; + } + + + public static Map> getFirmwareInfoBucket_Map(String artifactName) + { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map> fw_update = new HashMap<>(1); + Map mapContent = new HashMap<>(3); + LOGGER.info(artifactName+" exists!"); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); + fw_update.put(GcpOTA.FW_UPDATE, mapContent); + return fw_update; + } + return null; + } + + + public static Map>> getFirmwareInfoBucket_MapList(List modules) + { + Map>> fw_update_Map = + new HashMap>>(1); + + + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> art.getFilename()) + .collect(Collectors.toList()); + + List> list_fw_update = new ArrayList<>(fwNameList.size()); + + fwNameList.forEach(artifactName -> { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map mapContent = new HashMap<>(3); + LOGGER.info(artifactName+" exists!"); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); + list_fw_update.add(mapContent); + } + }); + fw_update_Map.put(GcpOTA.FW_UPDATE, list_fw_update); + return fw_update_Map; + } + + private static boolean checkIfExists(String artifactName) throws IOException { + + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.info(artifactName+" already exists!"); + return true; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + return false; + } + + public static StorageObject getStorageObjectInfo(String artifactName) { + try { + Storage.Objects.List objectsList = getStorage().objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.info(artifactName+" exists!"); + return object; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + LOGGER.warn(artifactName+" not found"); + } catch (Exception e) { + } + return null; + } + + + private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { + ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GcpOTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println("[BucketHandler] BucketName : "+bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println("[BucketHandler] ObjectName: "+object.getName()); + System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); + System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + String data) throws UnsupportedEncodingException, IOException { + return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( + data.getBytes("UTF-8")), "text/plain"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + File data) throws FileNotFoundException, IOException { + return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), + "application/octet-stream"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + InputStream data, String contentType) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(contentType, data); + Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) + .setName(objectName); + // The media uploader gzips content by default, and alters the Content-Encoding accordingly. + // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, + // so the service stores exactly what is in the InputStream, without transformation. + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, + InputStream data) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); + Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, + mediaContent); + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java new file mode 100644 index 0000000..f9aa889 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java @@ -0,0 +1,83 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; + +public class GcpCredentials { + + + private static Path keysFile = null; + private static GoogleCredentials googleCredentials; + private static GoogleCredential googleCredential; + private static CredentialsProvider credentialsProvider; + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpCredentials.class); + + protected static GoogleCredential getCredential() { + if(googleCredential == null) { + try { + googleCredential = GoogleCredential.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + System.out.println("Please make sure to put your keys.json in the project"); + LOGGER.error("Please make sure to put your keys.json in the project"); + } + } + return googleCredential; + } + + + protected static CredentialsProvider getCredentialProvider() { + if(credentialsProvider == null) { + try { + credentialsProvider = FixedCredentialsProvider.create( + ServiceAccountCredentials.fromStream(Files.newInputStream(keysFile))); + } catch (IOException e) { + e.printStackTrace(); + } + } + return credentialsProvider; + } + + protected static GoogleCredentials getCredentials() { + if(googleCredentials == null) { + try { + googleCredentials = GoogleCredentials.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + } + } + return googleCredentials; + } + + public static void setKeysFilePath(String keysPath) { + LOGGER.info("==========> Setting keys path to "+keysPath); + keysFile = Paths.get(keysPath); + LOGGER.info("==========> Setting keys path to "+keysFile.toString()); + int n; + try (InputStream in = Files.newInputStream(keysFile)) { + while ((n = in.read()) != -1) { + System.out.print((char) n); + } + } catch (IOException x) { + System.err.format("IOException: %s%n", x); + } + LOGGER.info("============================================================ "); + + getCredentials(); + getCredential(); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java new file mode 100644 index 0000000..6b3f216 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java @@ -0,0 +1,76 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.core.ApiFuture; +import com.google.api.services.iam.v1.IamScopes; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.SetOptions; +import com.google.cloud.firestore.WriteResult; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.cloud.FirestoreClient; + +public class GcpFireStore { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpFireStore.class); + + private static Firestore db; + + public static void init() { + + GoogleCredentials credentials = GcpCredentials.getCredentials() + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(credentials) + .setProjectId(GcpOTA.PROJECT_ID) + .build(); + FirebaseApp.initializeApp(options); + + db = FirestoreClient.getFirestore(); + } + + + public static void addDocumentMapList(String deviceId, Map>> mapList) { + try { + DocumentReference docRef = db + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(mapList, SetOptions.merge()); + LOGGER.info("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + public static void addDocument(String deviceId, Map> map) { + try { + DocumentReference docRef = db + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(map); + LOGGER.info("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java new file mode 100644 index 0000000..7b11c8c --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java @@ -0,0 +1,426 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.cloudiot.v1.CloudIot; +import com.google.api.services.cloudiot.v1.CloudIotScopes; +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceConfig; +import com.google.api.services.cloudiot.v1.model.DeviceRegistry; +import com.google.api.services.cloudiot.v1.model.DeviceState; +import com.google.api.services.cloudiot.v1.model.ListDeviceStatesResponse; +import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; + + +public class GcpIoTHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpIoTHandler.class); + + public static GoogleCredential getCredentialsFromFile() + { + GoogleCredential credential = null; + credential = GcpCredentials.getCredential() + .createScoped(CloudIotScopes.all()); + return credential; + } + + +// public static List getAllDevices(String projectId, String cloudRegion, String registryId) throws GeneralSecurityException, IOException +// { +// List allDevices_per_project = new ArrayList(); +// List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); +// for(DeviceRegistry gcp_registry : gcp_registries) +// { +// allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); +// } +// return allDevices_per_project; +// } + + public static List listDevices(String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + List devices = + service + .projects() + .locations() + .registries() + .devices() + .list(registryPath) + .execute() + .getDevices(); + + if (devices != null) { + System.out.println("Found " + devices.size() + " devices"); + for (Device d : devices) { + System.out.println("Id: " + d.getId()); + if (d.getConfig() != null) { + // Note that this will show the device config in Base64 encoded format. + System.out.println("Config: " + d.getConfig().toPrettyString()); + } + System.out.println(); + } + } else { + LOGGER.warn("Registry has no devices."); + System.out.println("Registry has no devices."); + } + return devices; + } + + public static boolean atLeastOnceConnected(String deviceId) { + try { + return atLeastOnceConnected(deviceId, + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + return false; + } + + + private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + String lastTimeEvent = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastEventTime(); + + String lastHRbeat = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastHeartbeatTime(); + System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); + return (lastTimeEvent !=null || lastHRbeat!=null); + } + + + + /** + * Retrieves Device Metadata + * @return Map of metadata + * */ + public static Map getDeviceMetadata(String deviceId) { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service; + try { + service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, + deviceId); + + return service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute().getMetadata(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } catch(GoogleJsonResponseException e) { + e.printStackTrace(); + LOGGER.error("Couldn't find the device: "+deviceId+" in the registry"); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + + + + + + /** Lists all of the registries associated with the given project. */ + public static List listRegistries(String projectId, String cloudRegion) + throws GeneralSecurityException, IOException { + + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + + List registries = + service + .projects() + .locations() + .registries() + .list(projectPath) + .execute() + .getDeviceRegistries(); + + if (registries != null) { + LOGGER.info("Found " + registries.size() + " registries"); + for (DeviceRegistry r: registries) { + LOGGER.info("Id: " + r.getId()); + LOGGER.info("Name: " + r.getName()); + if (r.getMqttConfig() != null) { + LOGGER.info("Config: " + r.getMqttConfig().toPrettyString()); + } + System.out.println(); + } + } else { + LOGGER.warn("Project has no registries."); + } + return registries; + + } + + /** List all of the configs for the given device. */ + public static void listDeviceConfigs( + String deviceId, String projectId, String cloudRegion, String registryName) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + for (DeviceConfig config : deviceConfigs) { + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + + /** List all of the configs for the given device. */ + public static long getLatestConfig( + String deviceId, String projectId, String cloudRegion, String registryName) + { + long configVersion = 0; + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + + for (DeviceConfig config : deviceConfigs) { + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); + if(configVersion < config.getVersion()) + { + configVersion = config.getVersion(); + } + } + + } catch (Exception e) { + e.printStackTrace(); + } + + return configVersion; + } + + + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ + public static void setDeviceConfiguration( + String deviceId, String projectId, String cloudRegion, String registryName, + String data, long version) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); + req.setVersionToUpdate(version); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + + DeviceConfig config = + service + .projects() + .locations() + .registries() + .devices() + .modifyCloudToDeviceConfig(devicePath, req).execute(); + + LOGGER.info("Updated: " + config.getVersion()); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + + + } + + /** Retrieves device metadata from a registry. **/ + public static List getDeviceStates( + String deviceId, String projectId, String cloudRegion, String registryName) + { + + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Retrieving device states " + devicePath); + + ListDeviceStatesResponse resp = service + .projects() + .locations() + .registries() + .devices() + .states() + .list(devicePath).execute(); + + return resp.getDeviceStates(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public static void sendCommand( + String deviceId, String projectId, String cloudRegion, String registryName, String data) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String devicePath = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + LOGGER.info("Sending command to %s\n", devicePath); + SendCommandToDeviceResponse res = + service + .projects() + .locations() + .registries() + .devices() + .sendCommandToDevice(devicePath, req) + .execute(); + + LOGGER.info("Command response: " + res.toString()); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** Retrieves registry metadata from a project. **/ + public static DeviceRegistry getRegistry( + String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String registryPath = String.format("projects/%s/locations/%s/registries/%s", + projectId, cloudRegion, registryName); + + return service.projects().locations().registries().get(registryPath).execute(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java new file mode 100644 index 0000000..b3b6c83 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -0,0 +1,36 @@ +package org.eclipse.hawkbit.google.gcp; + +public class GcpOTA { + +// public static String PROJECT_ID = "ota-iot-231619"; +// public static String CLOUD_REGION = "us-central1"; +// public static String REGISTRY_NAME = "OTA-DeviceRegistry"; +// public static String BUCKET_NAME = "ota-iot-231619.appspot.com"; + + public static String PROJECT_ID = ""; + public static String CLOUD_REGION = ""; + public static String REGISTRY_NAME = ""; + public static String BUCKET_NAME = ""; + + + public final static String FW_MSG_RECEIVED = "msg-received"; + public final static String FW_INSTALLING = "installing"; + public final static String FW_DOWNLOADING = "downloading"; + public final static String FW_INSTALLED = "installed"; + + public final static String SUBSCRIPTION_STATE_ID = "state"; + public final static String SUBSCRIPTION_FW_STATE = "fw-state"; + public final static String DEVICE_ID = "deviceId"; + + + public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; + public final static String FIRESTORE_CONFIG_COLLECTION = "config"; + public final static String FW_UPDATE = "firmware-update"; + + public final static String OBJECT_NAME = "ObjectName"; + public final static String URL = "Url"; + public final static String MD5HASH = "Md5Hash"; + + public final static boolean FW_VIA_COMMAND = false; +} + diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java new file mode 100644 index 0000000..d64b8c7 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java @@ -0,0 +1,62 @@ +package org.eclipse.hawkbit.google.gcp; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; + +public class GcpProperties { + + public static void parseCLI(String[] args) { + // create Options object + Options options = new Options(); + Option keys = Option.builder().argName("KEYS").hasArg().required() + .longOpt("KEYS").desc("Keys file path from a GCP project Service Account").build(); + Option gcpProjectID = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("PROJECT_ID").desc("GCP PROJECT_ID").build(); + Option gcpCloudRegion = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("CLOUD_REGION").desc("GCP CLOUD_REGION").build(); + Option gcpRegistryName = Option.builder().argName("REGISTRY_NAME").hasArg().required() + .longOpt("REGISTRY_NAME").desc("GCP REGISTRY_NAME").build(); + Option gcpBucketName = Option.builder().argName("BUCKET_NAME").hasArg().required() + .longOpt("BUCKET_NAME").desc("GCP BUCKET_NAME").build(); + + options.addOption(keys); + options.addOption(gcpProjectID); + options.addOption(gcpCloudRegion); + options.addOption(gcpRegistryName); + options.addOption(gcpBucketName); + + DefaultParser defaultParser = new DefaultParser(); + HelpFormatter formatter = new HelpFormatter(); + + CommandLine line; + try { + line = defaultParser.parse(options, args); + + if (line.hasOption("PROJECT_ID")) { + GcpOTA.PROJECT_ID = line.getOptionValue("PROJECT_ID"); + } + if (line.hasOption("CLOUD_REGION")) { + GcpOTA.CLOUD_REGION = line.getOptionValue("CLOUD_REGION"); + } + if (line.hasOption("REGISTRY_NAME")) { + GcpOTA.REGISTRY_NAME = line.getOptionValue("REGISTRY_NAME"); + } + if (line.hasOption("BUCKET_NAME")) { + GcpOTA.BUCKET_NAME = line.getOptionValue("BUCKET_NAME"); + } + if (line.hasOption("KEYS")) { + GcpCredentials.setKeysFilePath(line.getOptionValue("KEYS")); + } + + } catch (IllegalArgumentException | ParseException e) { + System.out.println(e.getMessage()); + formatter.printHelp("help", options); + System.exit(0); + } + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java new file mode 100644 index 0000000..3b3f94b --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java @@ -0,0 +1,231 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.UpdateStatus; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsub.v1.Subscriber; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; + +public class GcpSubscriber { + + private static final BlockingQueue messages = new LinkedBlockingDeque<>(); + private static Map mapCallbacks = new HashMap(); + + private static Map mapDevices = new HashMap(); + + private static Gson gson = new Gson(); + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpSubscriber.class); + + static class StateMessageReceiver implements MessageReceiver { + + @Override + public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { + messages.offer(message); + consumer.ack(); + } + } + + /** Receive messages over a subscription. */ + public static void init() { + ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of( + GcpOTA.PROJECT_ID, GcpOTA.SUBSCRIPTION_STATE_ID); + Subscriber subscriber = null; + try { + + // create a subscriber bound to the asynchronous message receiver + subscriber = + Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).setCredentialsProvider(GcpCredentials.getCredentialProvider()).build(); + subscriber.startAsync().awaitRunning(); + // Continue to listen to messages + while (true) { + PubsubMessage message = messages.take(); + if(!GcpOTA.FW_VIA_COMMAND) { + updateHawkbitStatus(message); + } + } + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + if (subscriber != null) { + subscriber.stopAsync(); + } + } + } + + + + public static void updateHawkbitStatus(PubsubMessage message){ + if(message.getData().toStringUtf8().contains(GcpOTA.SUBSCRIPTION_FW_STATE)) { + JsonObject payloadJson = gson.fromJson(message.getData() + .toStringUtf8(), JsonObject.class); + if(payloadJson.has(GcpOTA.SUBSCRIPTION_FW_STATE)) { + String deviceId = message.getAttributesMap().get(GcpOTA.DEVICE_ID); + String fw_state = payloadJson.get(GcpOTA.SUBSCRIPTION_FW_STATE).getAsString(); + + if(deviceId != null && fw_state != null) { + UpdateStatus updateStatus = null; + LOGGER.info("====> New state received "+fw_state+ " from device "+deviceId); + switch (fw_state) { + case GcpOTA.FW_MSG_RECEIVED : + updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_DOWNLOADING: + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_INSTALLING : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_INSTALLED: + updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + sendUpate(deviceId, updateStatus); + + //remove device and callback + mapCallbacks.remove(deviceId); + mapDevices.remove(deviceId); + + break; + default: + LOGGER.error("Unknown fw-state: "+fw_state); + updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); + sendUpate(deviceId, updateStatus); + break; + } + + } else { + LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); + + //Device never connected + if(!GcpIoTHandler.atLeastOnceConnected(deviceId)) { + LOGGER.error(deviceId+" : device was never connected"); + sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); + } + } + } else { + LOGGER.info("Ignoring message"); + } + } else { + LOGGER.info("Ignoring message"); + } + + } + + + private static String getStringFromListMap(Map>> listMap) { + JsonObject fw_update = new JsonObject(); + JsonArray fw_update_list = new JsonArray(); + + listMap.get(GcpOTA.FW_UPDATE).forEach(map -> { + JsonObject mapJsonObject = new JsonObject(); + mapJsonObject.addProperty(GcpOTA.OBJECT_NAME, map.get(GcpOTA.OBJECT_NAME)); + mapJsonObject.addProperty(GcpOTA.URL, map.get(GcpOTA.URL)); + mapJsonObject.addProperty(GcpOTA.MD5HASH, map.get(GcpOTA.MD5HASH)); + fw_update_list.add(mapJsonObject); + }); + fw_update.add(GcpOTA.FW_UPDATE,fw_update_list); + return gson.toJson(fw_update); + } + + + + private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { + Map>> data = + GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); + if(data != null) { + long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + + LOGGER.info("Writing to Firestore "); + GcpFireStore.addDocumentMapList(deviceId + , GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); + } + else LOGGER.error("Artifacts is empty for device "+deviceId); + } + + + + + @SuppressWarnings("unused") + private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { + String data = GcpBucketHandler.getFirmwareInfoBucket(artifactName); + if(data != null) { + long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, data, configVersion); + + LOGGER.info("Writing to Firestore "); + GcpFireStore.addDocument(deviceId, GcpBucketHandler.getFirmwareInfoBucket_Map(artifactName)); + } + else LOGGER.error(artifactName+" not found in bucket for device "+deviceId); + } + + + private static void sendUpate(String deviceId, UpdateStatus updateStatus) { + AbstractSimulatedDevice device = mapDevices.get(deviceId); + UpdaterCallback callback = mapCallbacks.get(deviceId); + if(device != null && callback != null) { + device.setUpdateStatus(updateStatus); + callback.sendFeedback(device); + } else { + if(device == null) { + LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); + } + if(callback == null) { + LOGGER.error("Map didnt find callback on "+ updateStatus.getResponseStatus().toString()); + } + } + } + + public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback callback, + List modules, EventTopic actionType) { + + LOGGER.info("Update device with eventTopic: "+actionType); + + //if the device is still updating, wait until it is finished + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL + || actionType == EventTopic.DOWNLOAD) { + if(!mapDevices.containsKey(device.getId())) { + LOGGER.info("ActionType "+actionType); + + sendAsyncFwUpgradeList(device.getId(), modules); + + mapCallbacks.put(device.getId(), callback); + mapDevices.put(device.getId(), device); + } else { + LOGGER.error("Device ID already exist on actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.RUNNING, "Payload Reached")); + } + } + else { + LOGGER.error("Unsupported actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.ERROR, "Unsupported Action")); + + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java new file mode 100644 index 0000000..c47598d --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java @@ -0,0 +1,74 @@ +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +//TODO: +/** + * Use the Hawkbit Management Client to download + * software modules and put them on the bucket + * */ +public class HawkBitSoftwareModuleHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(HawkBitSoftwareModuleHandler.class); + + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + + + + protected static String downloadFileData(final String url,final String targetToken) + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { + + final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts(); + final HttpGet request = new HttpGet(url); + + if (!StringUtils.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } + + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + String message = "download "+url+" failed (" + response.getStatusLine().getStatusCode()+ ")"; + LOGGER.error(message); + return null; + } + String payload = null; + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + System.out.println("Payload ==========> "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + } + + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java new file mode 100644 index 0000000..c98c0c2 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java @@ -0,0 +1,104 @@ +/* + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.eclipse.hawkbit.google.gcp; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.util.ExponentialBackOff; +import com.google.api.client.util.Sleeper; +import com.google.common.base.Preconditions; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * RetryHttpInitializerWrapper will automatically retry upon RPC failures, preserving the + * auto-refresh behavior of the Google Credentials. + */ +public class RetryHttpInitializerWrapper implements HttpRequestInitializer { + + /** A private logger. */ + private static final Logger LOG = Logger.getLogger(RetryHttpInitializerWrapper.class.getName()); + + /** One minutes in milliseconds. */ + private static final int ONE_MINUTE_MILLIS = 60 * 1000; + + /** + * Intercepts the request for filling in the "Authorization" header field, as well as recovering + * from certain unsuccessful error codes wherein the Credential must refresh its token for a + * retry. + */ + private final Credential wrappedCredential; + + /** A sleeper; you can replace it with a mock in your test. */ + private final Sleeper sleeper; + + /** + * A constructor. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + */ + public RetryHttpInitializerWrapper(final Credential wrappedCredential) { + this(wrappedCredential, Sleeper.DEFAULT); + } + + /** + * A protected constructor only for testing. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + * @param sleeper Sleeper for easy testing. + */ + RetryHttpInitializerWrapper(final Credential wrappedCredential, final Sleeper sleeper) { + this.wrappedCredential = Preconditions.checkNotNull(wrappedCredential); + this.sleeper = sleeper; + } + + /** Initializes the given request. */ + @Override + public final void initialize(final HttpRequest request) { + request.setReadTimeout(2 * ONE_MINUTE_MILLIS); // 2 minutes read timeout + final HttpUnsuccessfulResponseHandler backoffHandler = + new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff()).setSleeper(sleeper); + request.setInterceptor(wrappedCredential); + request.setUnsuccessfulResponseHandler( + new HttpUnsuccessfulResponseHandler() { + @Override + public boolean handleResponse( + final HttpRequest request, final HttpResponse response, final boolean supportsRetry) + throws IOException { + if (wrappedCredential.handleResponse(request, response, supportsRetry)) { + // If credential decides it can handle it, the return code or message indicated + // something specific to authentication, and no backoff is desired. + return true; + } else if (backoffHandler.handleResponse(request, response, supportsRetry)) { + // Otherwise, we defer to the judgment of our internal backoff handler. + LOG.info("Retrying " + request.getUrl().toString()); + return true; + } else { + return false; + } + } + }); + request.setIOExceptionHandler( + new HttpBackOffIOExceptionHandler(new ExponentialBackOff()).setSleeper(sleeper)); + } +} \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java new file mode 100644 index 0000000..9ce11e9 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +/** + * The bean of a simulated device which can be stored in the + * {@link DeviceSimulatorRepository} or shown in the UI. + * + */ +public abstract class AbstractSimulatedDevice { + + private String id; + private String tenant; + private UpdateStatus updateStatus; + private Protocol protocol = Protocol.DMF_AMQP; + private String targetSecurityToken; + private int pollDelaySec; + private int nextPollCounterSec; + + /** + * Enum definition of the protocol to be used for the simulated device. + * + */ + public enum Protocol { + /** + * Device Management Federation API via AMQP, push mechanism. + */ + DMF_AMQP, + /** + * Direct Device Interface via HTTP, poll mechanism. + */ + DDI_HTTP + } + + /** + * empty constructor. + */ + AbstractSimulatedDevice() { + + } + + /** + * Creates a new simulated device. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param pollDelaySec + */ + AbstractSimulatedDevice(final String id, final String tenant, final Protocol protocol, final int pollDelaySec) { + this.id = id; + this.tenant = tenant; + this.protocol = protocol; + this.pollDelaySec = pollDelaySec; + } + + /** + * Can be called by a scheduler to trigger a device polling, like in real + * scenarios devices are frequently asking for updates etc. + */ + public abstract void poll(); + + public int getPollDelaySec() { + return pollDelaySec; + } + + public void setPollDelaySec(final int pollDelaySec) { + this.pollDelaySec = pollDelaySec; + } + + public abstract void updateAttribute(final String mode, final String key, final String value); + + /** + * Method to clean-up resource e.g. when the simulated device has been + * removed from the repository. + */ + public void clean() { + this.updateStatus = null; + } + + public String getId() { + return id; + } + + public String getTenant() { + return tenant; + } + + public void setId(final String id) { + this.id = id; + } + + public void setTenant(final String tenant) { + this.tenant = tenant; + } + + public UpdateStatus getUpdateStatus() { + return updateStatus; + } + + public void setUpdateStatus(final UpdateStatus updateStatus) { + this.updateStatus = updateStatus; + } + + public Protocol getProtocol() { + return protocol; + } + + public int getNextPollCounterSec() { + return nextPollCounterSec; + } + + public void setNextPollCounterSec(final int nextPollDelayInSec) { + this.nextPollCounterSec = nextPollDelayInSec; + } + + public String getTargetSecurityToken() { + return targetSecurityToken; + } + + public void setTargetSecurityToken(final String targetSecurityToken) { + this.targetSecurityToken = targetSecurityToken; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java new file mode 100644 index 0000000..2beee8c --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +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.DdiControllerBase; +import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType; +import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase; +import org.eclipse.hawkbit.ddi.json.model.DdiResult; +import org.eclipse.hawkbit.ddi.json.model.DdiResult.FinalResult; +import org.eclipse.hawkbit.ddi.json.model.DdiStatus; +import org.eclipse.hawkbit.ddi.json.model.DdiStatus.ExecutionStatus; +import org.eclipse.hawkbit.ddi.json.model.DdiUpdateMode; +import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifactHash; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * A simulated device using the DDI API of the hawkBit update server. + * + */ +public class DDISimulatedDevice extends AbstractSimulatedDevice { + + private static final Logger LOGGER = LoggerFactory.getLogger(DDISimulatedDevice.class); + + private final DdiRootControllerRestApi controllerResource; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final String gatewayToken; + + private volatile boolean removed; + private volatile Long currentActionId; + + /** + * @param id + * the ID of the device + * @param tenant + * the tenant of the simulated device + * @param pollDelaySec + * the delay of the poll interval in sec + * @param controllerResource + * the http controller resource + * @param deviceUpdater + * the service to update devices + * @param gatewayToken + * to authenticate at DDI and for download as well + */ + public DDISimulatedDevice(final String id, final String tenant, final int pollDelaySec, + final DdiRootControllerRestApi controllerResource, final DeviceSimulatorUpdater deviceUpdater, + final String gatewayToken) { + super(id, tenant, Protocol.DDI_HTTP, pollDelaySec); + this.controllerResource = controllerResource; + this.deviceUpdater = deviceUpdater; + this.gatewayToken = gatewayToken; + LOGGER.info("Id: %s, tenant: %s \n", id, tenant); + } + + @Override + public void clean() { + super.clean(); + removed = true; + } + + /** + * Polls the base URL for the DDI API interface. + */ + @Override + public void poll() { + if (!removed) { + ResponseEntity poll = null; + try { + poll = controllerResource.getControllerBase(getTenant(), getId()); + } catch (final RuntimeException ex) { + LOGGER.error("Failed base poll", ex); + return; + } + + if (!HttpStatus.OK.equals(poll.getStatusCode())) { + return; + } + + final String href = poll.getBody().getLink("deploymentBase").getHref(); + if (href == null) { + return; + } + + final long actionId = Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?'))); + if (currentActionId == null || currentActionId == actionId) { + final ResponseEntity action = controllerResource + .getControllerBasedeploymentAction(getTenant(), getId(), actionId, -1, null); + + if (!HttpStatus.OK.equals(action.getStatusCode())) { + return; + } + + final HandlingType updateType = action.getBody().getDeployment().getUpdate(); + final List modules = action.getBody().getDeployment().getChunks(); + + currentActionId = actionId; + startDdiUpdate(actionId, updateType, modules); + } + } + } + + @Override + public void updateAttribute(final String mode, final String key, final String value) { + + final DdiUpdateMode updateMode; + switch (mode.toLowerCase()) { + case "replace": + updateMode = DdiUpdateMode.REPLACE; + break; + case "remove": + updateMode = DdiUpdateMode.REMOVE; + break; + case "merge": + default: + updateMode = DdiUpdateMode.MERGE; + break; + } + + final DdiStatus status = new DdiStatus(ExecutionStatus.CLOSED, new DdiResult(FinalResult.SUCCESS, null), null); + + final DdiConfigData configData = new DdiConfigData(null, null, status, Collections.singletonMap(key, value), + updateMode); + + controllerResource.putConfigData(configData, super.getTenant(), super.getId()); + } + + private static DmfSoftwareModule convertChunk(final DdiChunk ddi) { + final DmfSoftwareModule converted = new DmfSoftwareModule(); + converted.setModuleVersion(ddi.getVersion()); + converted.setArtifacts( + ddi.getArtifacts().stream().map(DDISimulatedDevice::convertArtifact).collect(Collectors.toList())); + + return converted; + } + + private static DmfArtifact convertArtifact(final DdiArtifact ddi) { + final DmfArtifact converted = new DmfArtifact(); + converted.setSize(ddi.getSize()); + converted.setFilename(ddi.getFilename()); + converted.setHashes(new DmfArtifactHash(ddi.getHashes().getSha1(), (ddi.getHashes().getMd5()))); + final Map urls = new HashMap<>(); + + if (ddi.getLink("download") != null) { + urls.put("HTTPS", ddi.getLink("download").getHref()); + } + + if (ddi.getLink("download-http") != null) { + urls.put("HTTP", ddi.getLink("download-http").getHref()); + } + + converted.setUrls(urls); + + return converted; + } + + private void startDdiUpdate(final long actionId, final HandlingType updateType, final List modules) { + + deviceUpdater.startUpdate(getTenant(), getId(), + modules.stream().map(DDISimulatedDevice::convertChunk).collect(Collectors.toList()), null, gatewayToken, + sendFeedback(actionId), + HandlingType.SKIP.equals(updateType) ? EventTopic.DOWNLOAD : EventTopic.DOWNLOAD_AND_INSTALL); + } + + private UpdaterCallback sendFeedback(final long actionId) { + return device -> { + final DdiActionFeedback feedback = calculateFeedback(actionId, device); + controllerResource.postBasedeploymentActionFeedback(feedback, getTenant(), getId(), actionId); + currentActionId = null; + }; + } + + private DdiActionFeedback calculateFeedback(final long actionId, final AbstractSimulatedDevice device) { + DdiActionFeedback feedback; + + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.CLOSED, + new DdiResult(FinalResult.SUCCESS, null), device.getUpdateStatus().getStatusMessages())); + break; + case ERROR: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.CLOSED, + new DdiResult(FinalResult.FAILURE, null), device.getUpdateStatus().getStatusMessages())); + break; + case DOWNLOADING: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.DOWNLOAD, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + case DOWNLOADED: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.DOWNLOADED, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + case RUNNING: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.PROCEEDING, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + default: + throw new IllegalStateException("simulated device has an unknown response status + " + + device.getUpdateStatus().getResponseStatus()); + } + return feedback; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java new file mode 100644 index 0000000..7092ae7 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; + +/** + * A simulated device using the DMF API of the hawkBit update server. + */ +public class DMFSimulatedDevice extends AbstractSimulatedDevice { + private final DmfSenderService spSenderService; + + private static final Logger LOGGER = LoggerFactory.getLogger(DMFSimulatedDevice.class); + + + /** + * @param id + * the ID of the device + * @param tenant + * the tenant of the simulated device + */ + public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderService spSenderService, + final int pollDelaySec) { + super(id, tenant, Protocol.DMF_AMQP, pollDelaySec); + this.spSenderService = spSenderService; + LOGGER.info(" Id: {}, tenant: {} \n", id, tenant); + } + + @Override + public void poll() { + LOGGER.info("handling event of tenant "+super.getTenant()); + + spSenderService.createOrUpdateThing(super.getTenant(), super.getId()); + } + + @Override + public void updateAttribute(final String mode, final String key, final String value) { + System.out.println("[DMFSimulatedDevice] handling updateAttribute"); + + final DmfUpdateMode updateMode; + + switch (mode.toLowerCase()) { + + case "replace" : + updateMode = DmfUpdateMode.REPLACE; + break; + case "remove" : + updateMode = DmfUpdateMode.REMOVE; + break; + case "merge" : + default : + updateMode = DmfUpdateMode.MERGE; + break; + } + + spSenderService.updateAttributesOfThing(super.getTenant(), super.getId(), updateMode, key, value); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java new file mode 100644 index 0000000..16e6a11 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.eclipse.hawkbit.google.gcp.GcpProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler; + +/** + * The main-method to start the Spring-Boot application. + * + */ +@SpringBootApplication +@EnableScheduling +public class DeviceSimulator { + + public DeviceSimulator() { + // utility class + } + + /** + * @return central ScheduledExecutorService + */ + @Bean + ScheduledExecutorService threadPool() { + return Executors.newScheduledThreadPool(4); + } + + @Bean + TaskScheduler taskScheduler() { + return new ConcurrentTaskScheduler(threadPool()); + } + + /** + * Start the Spring Boot Application. + * + * @param args + * the args + */ + // Exception squid:S2095 - Spring boot standard behavior + @SuppressWarnings({ "squid:S2095" }) + public static void main(final String[] args) { + GcpProperties.parseCLI(args); + SpringApplication.run(DeviceSimulator.class, args); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java new file mode 100644 index 0000000..2d97738 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +/** + * An in-memory simulated device repository to hold the simulated device in + * memory and be able to retrieve them again. + * + */ +@Service +public class DeviceSimulatorRepository { + + private final Map devices = new ConcurrentHashMap<>(); + + private final Set tenants = new HashSet<>(); + + /** + * Adds a simulated device to the repository. + * + * @param simulatedDevice + * the device to add + * @return the device which has been added to the repository + */ + public AbstractSimulatedDevice add(final AbstractSimulatedDevice simulatedDevice) { + devices.put(new DeviceKey(simulatedDevice.getTenant().toLowerCase(), simulatedDevice.getId()), simulatedDevice); + tenants.add(simulatedDevice.getTenant().toLowerCase()); + return simulatedDevice; + } + + /** + * @return all simulated devices + */ + public Collection getAll() { + return devices.values(); + } + + /** + * Retrieves a single simulated devices or {@code null} if device does not + * exists. + * + * @param tenant + * the tenant of the simulated device + * @param id + * the ID of the device + * @return a simulated device from the repository or {@code null} if device + * does not exixts. + */ + public AbstractSimulatedDevice get(final String tenant, final String id) { + return devices.get(new DeviceKey(tenant.toLowerCase(), id)); + } + + /** + * Removes a device from the simulation. + * + * @param tenant + * the tenant of the simulated device + * @param id + * the ID of the device + * @return the simulated device or null if it was not in the + * repository + */ + public AbstractSimulatedDevice remove(final String tenant, final String id) { + return devices.remove(new DeviceKey(tenant.toLowerCase(), id)); + } + + public Set getTenants() { + return tenants; + } + + /** + * Clears all stored devices. + */ + public void clear() { + devices.values().forEach(AbstractSimulatedDevice::clean); + devices.clear(); + tenants.clear(); + } + + private static final class DeviceKey { + private final String tenant; + private final String id; + + private DeviceKey(final String tenant, final String id) { + this.tenant = tenant; + this.id = id; + } + + @Override + public int hashCode() {// NOSONAR - as this is generated + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((tenant == null) ? 0 : tenant.hashCode()); + return result; + } + + @Override + public boolean equals(final Object obj) {// NOSONAR - as this is + // generated + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final DeviceKey other = (DeviceKey) obj; + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + if (tenant == null) { + if (other.tenant != null) { + return false; + } + } else if (!tenant.equals(other.tenant)) { + return false; + } + return true; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java new file mode 100644 index 0000000..3c2110e --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -0,0 +1,403 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +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.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.google.gcp.GcpOTA; +import org.eclipse.hawkbit.google.gcp.GcpSubscriber; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +/** + * Update simulation handler. + */ +@Service +public class DeviceSimulatorUpdater { + + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); + + @Autowired + private ScheduledExecutorService threadPool; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Starting an simulated update process of an simulated device. + * + * @param tenant + * the tenant of the device + * @param id + * the ID of the simulated device + * @param modules + * the software module version from the hawkbit update server + * @param targetSecurityToken + * the target security token for download authentication + * @param gatewayToken + * as alternative to target token the gateway token for download + * authentication + * @param callback + * the callback which gets called when the simulated update + * process has been finished + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void startUpdate(final String tenant, final String id, final List modules, + final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, + final EventTopic actionType) { + + AbstractSimulatedDevice device = repository.get(tenant, id); + + // plug and play - non existing device will be auto created + if (device == null) { + device = repository + .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); + } + + device.setTargetSecurityToken(targetSecurityToken); + + if(GcpOTA.FW_VIA_COMMAND) { + threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, + TimeUnit.MILLISECONDS); + } + else //use the subscription on state + { + GcpSubscriber.updateDevice(device, callback, modules, actionType); + } + } + + private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; + + private final AbstractSimulatedDevice device; + private final UpdaterCallback callback; + private final List modules; + private final String gatewayToken; + private static String payload = ""; + + private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, + final List modules, final EventTopic actionType, final String gatewayToken) { + this.device = device; + this.callback = callback; + this.modules = modules; + this.actionType = actionType; + this.gatewayToken = gatewayToken; + } + + @Override + public void run() { + + + if(GcpOTA.FW_VIA_COMMAND) + { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); + callback.sendFeedback(device); + + if (!CollectionUtils.isEmpty(modules)) { + device.setUpdateStatus(simulateDownloads()); + callback.sendFeedback(device); + if (isErrorResponse(device.getUpdateStatus())) { + device.clean(); + return; + } + } + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + LOGGER.info("Download & Install"); + device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); + callback.sendFeedback(device); + device.clean(); + } + } + } + + + + private void syncDownloadGCP(String deviceId, String data) + { + LOGGER.info("Attempting download to the device \n"+data); + GcpIoTHandler.sendCommand(device.getId(), GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); + } + + private UpdateStatus simulateDownloads() { + + device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); + callback.sendFeedback(device); + + final List status = new ArrayList<>(); + + LOGGER.info("Simulate downloads for "+device.getId()); + + + modules.forEach(module -> { + module.getArtifacts().forEach( + artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact)); + }); + + syncDownloadGCP(device.getId(), payload); + + final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); + result.getStatusMessages().add("Simulator: Download complete!"); + status.forEach(download -> { + result.getStatusMessages().addAll(download.getStatusMessages()); + if (isErrorResponse(download)) { + result.setResponseStatus(ResponseStatus.ERROR); + } + }); + + LOGGER.info("Download simulations complete for {}", device.getId()); + + return result; + } + + private static boolean isErrorResponse(final UpdateStatus status) { + if (status == null) { + return false; + } + + return ResponseStatus.ERROR.equals(status.getResponseStatus()); + } + + private static void handleArtifact(final String targetToken, final String gatewayToken, + final List status, final DmfArtifact artifact) { + + LOGGER.info(" handleArtifact "+artifact.getSize()); + if (artifact.getUrls().containsKey("HTTPS")) { + status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } else if (artifact.getUrls().containsKey("HTTP")) { + status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } + } + + private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, + final String sha1Hash, final long size) { + LOGGER.info(" downloadingUrl "+url); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, + hideTokenDetails(targetToken), sha1Hash, size); + } + + try { + return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); + } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + LOGGER.error("Failed to download " + url, e); + return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); + } + + } + + private static 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.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } else if (!StringUtils.isEmpty(gatewayToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); + } + + final String sha1HashResult; + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + final String message = wrongStatusCode(url, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + if (response.getEntity().getContentLength() != size) { + final String message = wrongContentLength(url, size, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + // Exception squid:S2070 - not used for hashing sensitive + // data + @SuppressWarnings("squid:S2070") + final MessageDigest md = MessageDigest.getInstance("SHA-1"); + + //overallread = getOverallRead(response, md); + payload = getPayload(response); + + // if (overallread != size) { + // final String message = incompleteRead(url, size, overallread); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } + // + // sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); + } + + // if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { + // final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } + + final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; + LOGGER.debug(message); + return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 getPayload(final CloseableHttpResponse response) + throws IOException { + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + LOGGER.info("Payload: "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + + 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, + final String sha1HashResult) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " + + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; + LOGGER.error(message); + return message; + } + + private static String incompleteRead(final String url, final long size, final long overallread) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size + + BUT_GOT_LOG_MESSAGE + overallread + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongContentLength(final String url, final long size, + final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size + + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() + + ")"; + LOGGER.error(message); + return message; + } + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + } + + /** + * Callback interface which is called when the simulated update process has + * been finished and the caller of starting the simulated update process can + * send the result back to the hawkBit update server. + */ + @FunctionalInterface + public interface UpdaterCallback { + /** + * Callback method to indicate that the simulated update process has + * been finished. + * + * @param device + * the device which has been updated + */ + void sendFeedback(AbstractSimulatedDevice device); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java new file mode 100644 index 0000000..7eb90ac --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collection; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Poll time trigger which executes the {@link DDISimulatedDevice#poll()} every + * second. + */ +@Component +public class NextPollTimeController { + + private static final Logger LOGGER = LoggerFactory.getLogger(NextPollTimeController.class); + + private static final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); + private static final ExecutorService pollService = Executors.newFixedThreadPool(1); + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Constructor which schedules the poll trigger runnable every second. + */ + public NextPollTimeController() { + executorService.scheduleWithFixedDelay(new NextPollUpdaterRunnable(), 1, 1, TimeUnit.SECONDS); + } + + private class NextPollUpdaterRunnable implements Runnable { + @Override + public void run() { + final Collection devices = repository.getAll(); + + devices.forEach(device -> { + int nextCounter = device.getNextPollCounterSec() - 1; + if (nextCounter < 0) { + try { + pollService.submit(() -> device.poll()); + } catch (final IllegalStateException e) { + LOGGER.trace("Device could not be polled", e); + } + nextCounter = device.getPollDelaySec(); + } + + device.setNextPollCounterSec(nextCounter); + }); + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java new file mode 100644 index 0000000..efe8f45 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + + +import java.net.URL; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.hawkbit.ddi.client.resource.RootControllerResourceClient; +import org.eclipse.hawkbit.feign.core.client.IgnoreMultipleConsumersProducersSpringMvcContract; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; +import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import feign.Feign; +import feign.Logger.Level; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.slf4j.Slf4jLogger; + +/** + * The simulated device factory to create either {@link DMFSimulatedDevice} or + * {@link DDISimulatedDevice}. + */ +@Service +public class SimulatedDeviceFactory { + @Autowired + private DeviceSimulatorUpdater deviceUpdater; + + @Autowired(required = false) + private DmfSenderService spSenderService; + + @Autowired + private ScheduledExecutorService threadPool; + + /** + * Creating a simulated device. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param protocol + * the protocol which should be used be the simulated device + * @param pollDelaySec + * the poll delay time in seconds which should be used for + * {@link DDISimulatedDevice}s and {@link DMFSimulatedDevice} + * @param baseEndpoint + * the http base endpoint which should be used for + * {@link DDISimulatedDevice}s + * @param gatewayToken + * the gatewayToken to be used to authenticate + * {@link DDISimulatedDevice}s at the endpoint + * @return the created simulated device + */ + public AbstractSimulatedDevice createSimulatedDevice(final String id, final String tenant, final Protocol protocol, + final int pollDelaySec, final URL baseEndpoint, final String gatewayToken) { + return createSimulatedDevice(id, tenant, protocol, pollDelaySec, baseEndpoint, gatewayToken, false); + } + + private AbstractSimulatedDevice createSimulatedDevice(final String id, final String tenant, final Protocol protocol, + final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { + switch (protocol) { + case DMF_AMQP: + return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); + case DDI_HTTP: + return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); + default: + throw new IllegalArgumentException("Protocol " + protocol + " unknown"); + } + } + + private AbstractSimulatedDevice createDdiDevice(final String id, final String tenant, final int pollDelaySec, + final URL baseEndpoint, final String gatewayToken) { + + final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(new Jackson2HalModule()); + + final RootControllerResourceClient controllerResource = Feign.builder() + .requestInterceptor(new GatewayTokenInterceptor(gatewayToken)) + .contract(new IgnoreMultipleConsumersProducersSpringMvcContract()).logLevel(Level.HEADERS) + .decoder(new ResponseEntityDecoder(new JacksonDecoder(mapper))).encoder(new JacksonEncoder()) + .logger(new Slf4jLogger()).decode404() + .target(RootControllerResourceClient.class, baseEndpoint.toString()); + + return new DDISimulatedDevice(id, tenant, pollDelaySec, controllerResource, deviceUpdater, gatewayToken); + } + + private AbstractSimulatedDevice createDmfDevice(final String id, final String tenant, final int pollDelaySec, + final boolean pollImmediatly) { + final AbstractSimulatedDevice device = new DMFSimulatedDevice(id, tenant, spSenderService, pollDelaySec); + device.setNextPollCounterSec(pollDelaySec); + if (pollImmediatly) { + spSenderService.createOrUpdateThing(tenant, id); + } + + threadPool.schedule(() -> spSenderService.updateAttributesOfThing(tenant, id), 2_000, TimeUnit.MILLISECONDS); + + return device; + } + + /** + * Creating a simulated device and send an immediate DMF poll to update + * server. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param protocol + * the protocol which should be used be the simulated device + * @param pollDelaySec + * the poll delay time in seconds which should be used for + * {@link DDISimulatedDevice}s and {@link DMFSimulatedDevice} + * @param baseEndpoint + * the http base endpoint which should be used for + * {@link DDISimulatedDevice}s + * @param gatewayToken + * the gatewayToken to be used to authenticate + * {@link DDISimulatedDevice}s at the endpoint + * @return the created simulated device + */ + public AbstractSimulatedDevice createSimulatedDeviceWithImmediatePoll(final String id, final String tenant, + final Protocol protocol, final int pollDelaySec, final URL baseEndpoint, final String gatewayToken) { + return createSimulatedDevice(id, tenant, protocol, pollDelaySec, baseEndpoint, gatewayToken, true); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java new file mode 100644 index 0000000..e1c9ab3 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -0,0 +1,277 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.google.gcp.GcpOTA; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.gson.Gson; + +/** + * REST endpoint for controlling the device simulator. + */ +@RestController +public class SimulationController { + + private final DeviceSimulatorRepository repository; + + private final SimulatedDeviceFactory deviceFactory; + + private final AmqpProperties amqpProperties; + + private final SimulationProperties simulationProperties; + + private static final Logger LOGGER = LoggerFactory.getLogger(SimulationController.class); + + Gson gson = new Gson(); + + @Autowired + public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { + this.repository = repository; + this.deviceFactory = deviceFactory; + this.amqpProperties = amqpProperties; + this.simulationProperties = simulationProperties; + } + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/gcp") + ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "1") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + + try { + List allDevices_gcp = GcpIoTHandler.listDevices(GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME); + for(Device gcp_device : allDevices_gcp) + { + LOGGER.info("GCP Device: "+gcp_device.getId()); + repository.add(deviceFactory. + createSimulatedDevice(gcp_device.getId(), + simulationProperties.getDefaultTenant(), + protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + + } + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/start") + ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "20") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + for (int i = 0; i < amount; i++) { + final String deviceId = name + i; + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + /** + * Update an attribute of a device. + * + * NOTE: This represents not the expected client behaviour for DDI, since a + * DDI client shall only update its attributes if requested by hawkBit. + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be updated. + * @param mode + * Update mode ('merge', 'replace', or 'remove') + * @param key + * Key of the attribute to be updated + * @param value + * Value of the attribute + * @return HTTP OK (200) if the update has been triggered. + */ + @GetMapping("/attributes") + ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId, + @RequestParam(value = "mode", defaultValue = "merge") final String mode, + @RequestParam(value = "key") final String key, + @RequestParam(value = "value", required = false) final String value) { + + final AbstractSimulatedDevice simulatedDevice = repository + .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (simulatedDevice == null) { + return ResponseEntity.notFound().build(); + } + + simulatedDevice.updateAttribute(mode, key, value); + + return ResponseEntity.ok("Update triggered"); + } + + /** + * Remove a simulated device + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be removed. + * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) + * if not found. + */ + @GetMapping("/remove") + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId) { + + final AbstractSimulatedDevice controller = repository + .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (controller == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok("Deleted"); + } + + + @GetMapping("/hi") + ResponseEntity hi() { + return ResponseEntity.ok("hi"); + } + + + /** + * Reset the device simulator by removing all simulated devices + * + * @return A response string that the simulator has been reset + */ + @GetMapping("/reset") + ResponseEntity reset() { + + repository.clear(); + + return ResponseEntity.ok("All simulated devices have been removed."); + } + + private boolean isDmfDisabled() { + return !amqpProperties.isEnabled(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java new file mode 100644 index 0000000..a30833e --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import javax.validation.constraints.NotEmpty; + +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.google.common.base.Splitter; + +/** + * General simulator service properties. + * + */ +@Component +@ConfigurationProperties("hawkbit.device.simulator") +// Exception for squid:S2245 : not security relevant random number generation +@SuppressWarnings("squid:S2245") +public class SimulationProperties { + private static final Splitter SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults(); + private static final Random RANDOM = new Random(); + + private String defaultTenant = "DEFAULT"; + + /** + * List of tenants where the simulator should auto start simulations after + * startup. + */ + private final List autostarts = new ArrayList<>(); + + private final List attributes = new ArrayList<>(); + + public String getDefaultTenant() { + return defaultTenant; + } + + public void setDefaultTenant(final String defaultTenant) { + this.defaultTenant = defaultTenant; + } + + public List getAttributes() { + return attributes; + } + + public List getAutostarts() { + return this.autostarts; + } + + /** + * Properties for target attributes set as part of simulation. + * + */ + public static class Attribute { + private String key; + private String value; + private String random; + + public String getKey() { + return key; + } + + public String getValue() { + return Optional.ofNullable(value).orElseGet(this::getRandomElement); + } + + public void setKey(final String key) { + this.key = key; + } + + public void setValue(final String value) { + this.value = value; + } + + public void setRandom(final String random) { + this.random = random; + } + + public String getRandom() { + return random; + } + + private String getRandomElement() { + final List options = SPLITTER.splitToList(random); + return options.get(RANDOM.nextInt(options.size())); + } + } + + /** + * Auto start configuration for simulation setups that the simulator begins + * after startup. + * + */ + public static class Autostart { + /** + * Name prefix of simulated devices, followed by counter, e.g. + * simulated0, simulated1, simulated2.... + */ + private String name = "simulated"; + + /** + * Amount of simulated devices. + */ + private int amount = 0; + + /** + * Tenant name for the simulation. + */ + @NotEmpty + private String tenant; + + /** + * API for simulation. + */ + private Protocol api = Protocol.DMF_AMQP; + + /** + * Endpoint in case of DDI API based simulation. + */ + private String endpoint = "http://localhost:8080"; + + /** + * Poll time in {@link TimeUnit#SECONDS} for simulated devices. + */ + private int pollDelay = (int) TimeUnit.MINUTES.toSeconds(30); + + /** + * Optional gateway token for DDI API based simulation. + */ + private String gatewayToken = ""; + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public int getAmount() { + return amount; + } + + public void setAmount(final int amount) { + this.amount = amount; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(final String tenant) { + this.tenant = tenant; + } + + public Protocol getApi() { + return api; + } + + public void setApi(final Protocol api) { + this.api = api; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(final String endpoint) { + this.endpoint = endpoint; + } + + public int getPollDelay() { + return pollDelay; + } + + public void setPollDelay(final int pollDelay) { + this.pollDelay = pollDelay; + } + + public String getGatewayToken() { + return gatewayToken; + } + + public void setGatewayToken(final String gatewayToken) { + this.gatewayToken = gatewayToken; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java new file mode 100644 index 0000000..478cd3f --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.eclipse.hawkbit.google.gcp.GcpFireStore; +import org.eclipse.hawkbit.google.gcp.GcpSubscriber; +import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * Execution of operations after startup. Set up of simulations. + * + */ +@Component +@ConditionalOnProperty(prefix = "hawkbit.device.simulator", name = "autostart", matchIfMissing = true) +public class SimulatorStartup implements ApplicationListener { + private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); + + @Autowired + private SimulationProperties simulationProperties; + + @Autowired + private DeviceSimulatorRepository repository; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private AmqpProperties amqpProperties; + + @Override + public void onApplicationEvent(final ApplicationReadyEvent event) { + System.out.println("AutoStarting application ..."); + LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); + + + amqpProperties.setEnabled(true); + + LOGGER.info("Init Firestore ... "); + GcpFireStore.init(); + LOGGER.info("Init Subscriber ... "); + GcpSubscriber.init(); + + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket + simulationProperties.getAutostarts().forEach(autostart -> { + LOGGER.info("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); + for (int i = 0; i < autostart.getAmount(); i++) { + final String deviceId = autostart.getName() + i; + try { + if (amqpProperties.isEnabled()) { + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), + new URL(autostart.getEndpoint()), autostart.getGatewayToken())); + } + + } catch (final MalformedURLException e) { + LOGGER.error("Creation of simulated device at startup failed.", e); + } + } + }); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java new file mode 100644 index 0000000..6088aa6 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.ArrayList; +import java.util.List; + +/** + * Update status of the simulated update. + * + */ +public class UpdateStatus { + private ResponseStatus responseStatus; + private List statusMessages; + + /** + * Constructor. + * + * @param responseStatus + * of the update + */ + public UpdateStatus(final ResponseStatus responseStatus) { + this.responseStatus = responseStatus; + } + + /** + * Constructor including status message. + * + * @param responseStatus + * of the update + * @param message + * of the update status + */ + public UpdateStatus(final ResponseStatus responseStatus, final String message) { + this(responseStatus); + statusMessages = new ArrayList<>(); + statusMessages.add(message); + } + + /** + * Constructor including status message. + * + * @param responseStatus + * of the update + * @param messages + * of the update status + */ + public UpdateStatus(final ResponseStatus responseStatus, final List statusMessages) { + this(responseStatus); + this.statusMessages = statusMessages; + } + + public ResponseStatus getResponseStatus() { + return responseStatus; + } + + public void setResponseStatus(final ResponseStatus responseStatus) { + this.responseStatus = responseStatus; + } + + public List getStatusMessages() { + if (statusMessages == null) { + statusMessages = new ArrayList<>(); + } + + return statusMessages; + } + + /** + * The status to response to the hawkBit update server if an simulated + * update process should be respond with successful or failure update. + */ + public enum ResponseStatus { + /** + * Update has been successful and response the successful update. + */ + SUCCESSFUL, + + /** + * Update has been not successful and response the error update. + */ + ERROR, + + /** + * Update is running (intermediate status). + */ + RUNNING, + + /** + * Device starts to download. + */ + DOWNLOADING, + + /** + * Device is finished with downloading. + */ + DOWNLOADED; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java new file mode 100644 index 0000000..d235af0 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.time.Duration; +import java.util.Map; + +import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; +import org.eclipse.hawkbit.simulator.SimulationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.FanoutExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +import com.google.common.collect.Maps; + +/** + * The spring AMQP configuration to use a AMQP for communication with SP update + * server. + */ +@Configuration +@EnableConfigurationProperties(AmqpProperties.class) +@ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "enabled") +public class AmqpConfiguration { + + private static final Logger LOGGER = LoggerFactory.getLogger(AmqpConfiguration.class); + + @Autowired + private AmqpProperties amqpProperties; + + @Bean + RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { + final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); + + final RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setBackOffPolicy(new ExponentialBackOffPolicy()); + rabbitTemplate.setRetryTemplate(retryTemplate); + + rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { + if (ack) { + LOGGER.debug("Message with correlation ID {} confirmed by broker.", correlationData.getId()); + } else { + LOGGER.error("Broker is unable to handle message with correlation ID {} : {}", correlationData.getId(), + cause); + } + + }); + + return rabbitTemplate; + } + + @Bean + DmfReceiverService dmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + return new DmfReceiverService(rabbitTemplate, amqpProperties, spSenderService, deviceUpdater, repository); + } + + @Bean + DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + return new DmfSenderService(rabbitTemplate, amqpProperties, simulationProperties); + } + + /** + * Creates the receiver queue from update server for receiving message from + * update server. + * + * @return the queue + */ + @Bean + Queue receiverConnectorQueueFromHawkBit() { + final Map arguments = getTTLMaxArgs(); + + return QueueBuilder.nonDurable(amqpProperties.getReceiverConnectorQueueFromSp()).autoDelete() + .withArguments(arguments).build(); + } + + /** + * Creates the receiver exchange for sending messages to update server. + * + * @return the exchange + */ + @Bean + FanoutExchange exchangeQueueToConnector() { + return new FanoutExchange(amqpProperties.getSenderForSpExchange(), false, true); + } + + /** + * Create the Binding + * {@link AmqpConfiguration#receiverConnectorQueueFromHawkBit()} to + * {@link AmqpConfiguration#exchangeQueueToConnector()}. + * + * @return the binding and create the queue and exchange + */ + @Bean + Binding bindReceiverQueueToSpExchange() { + return BindingBuilder.bind(receiverConnectorQueueFromHawkBit()).to(exchangeQueueToConnector()); + } + + private static Map getTTLMaxArgs() { + final Map args = Maps.newHashMapWithExpectedSize(2); + args.put("x-message-ttl", Duration.ofDays(1).toMillis()); + args.put("x-max-length", 100_000); + return args; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java new file mode 100644 index 0000000..9bafd4f --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Bean which holds the necessary properties for configuring the AMQP + * connection. + * + */ +@Component +@ConfigurationProperties("hawkbit.device.simulator.amqp") +public class AmqpProperties { + + /** + * The prefix for this configuration. + */ + public static final String CONFIGURATION_PREFIX = "hawkbit.device.simulator.amqp"; + + /** + * Indicates if the AMQP interface is enabled for the device simulator. + */ + private boolean enabled; + + /** + * Set to true for the simulator run DMF health check. + */ + private boolean checkDmfHealth = false; + + /** + * Queue for receiving DMF messages from update server. + */ + private String receiverConnectorQueueFromSp = "simulator_receiver"; + + /** + * Exchange for sending DMF messages to update server. + */ + private String senderForSpExchange = "simulator.replyTo"; + + /** + * Message time to live (ttl) for the deadletter queue. Default ttl is 1 + * hour. + */ + private int deadLetterTtl = 60_000; + + public boolean isCheckDmfHealth() { + return checkDmfHealth; + } + + public void setCheckDmfHealth(final boolean checkDmfHealth) { + this.checkDmfHealth = checkDmfHealth; + } + + public String getReceiverConnectorQueueFromSp() { + return receiverConnectorQueueFromSp; + } + + public void setReceiverConnectorQueueFromSp(final String receiverConnectorQueueFromSp) { + this.receiverConnectorQueueFromSp = receiverConnectorQueueFromSp; + } + + public String getSenderForSpExchange() { + return senderForSpExchange; + } + + public void setSenderForSpExchange(final String senderForSpExchange) { + this.senderForSpExchange = senderForSpExchange; + } + + public int getDeadLetterTtl() { + return deadLetterTtl; + } + + public void setDeadLetterTtl(final int deadLetterTtl) { + this.deadLetterTtl = deadLetterTtl; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java new file mode 100644 index 0000000..64859ff --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -0,0 +1,317 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; +import org.eclipse.hawkbit.dmf.amqp.api.MessageType; +import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; +import org.eclipse.hawkbit.google.gcp.GcpBucketHandler; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.scheduling.annotation.Scheduled; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + + +/** + * Handle all incoming Messages from hawkBit update server. + * + */ +public class DmfReceiverService extends MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); + + private final DmfSenderService spSenderService; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final DeviceSimulatorRepository repository; + + private final Set openPings = new HashSet(); + + private Gson gson = new Gson(); + + /** + * Constructor. + * + * @param rabbitTemplate + * for sending messages + * @param amqpProperties + * for amqp configuration + * @param spSenderService + * to send messages + * @param deviceUpdater + * simulator service for updates + * @param repository + * to manage simulated devices + */ + DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + super(rabbitTemplate, amqpProperties); + this.spSenderService = spSenderService; + this.deviceUpdater = deviceUpdater; + this.repository = repository; + LOGGER.info("Init"); + } + + /** + * Method to validate if content type is set in the message properties. + * + * @param message + * the message to get validated + */ + private void checkContentTypeJson(final Message message) { + LOGGER.info(" checkJson "+message.getBody()); + if (message.getBody().length == 0) { + return; + } + final MessageProperties messageProperties = message.getMessageProperties(); + final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); + if (null != headerContentType) { + messageProperties.setContentType(headerContentType); + } + final String contentType = messageProperties.getContentType(); + if (contentType != null && contentType.contains("json")) { + return; + } + throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); + } + + /** + * Handle the incoming Message from Queue with the property + * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). + * + * @param message + * the incoming message + * @param type + * the action type + * @param thingId + * the thing id in message header + * @param tenant + * the device belongs to + */ + @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") + public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, + @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, + @Header(MessageHeaderKey.TENANT) final String tenant) { + + try { + + final MessageType messageType = MessageType.valueOf(type); + + LOGGER.info(" Message received :\n"+message.toString()); + + if (MessageType.EVENT.equals(messageType)) { + checkContentTypeJson(message); + handleEventMessage(message, thingId); + return; + } + + if (MessageType.THING_DELETED.equals(messageType)) { + checkContentTypeJson(message); + repository.remove(tenant, thingId); + return; + } + + if (MessageType.PING_RESPONSE.equals(messageType)) { + final String correlationId = message.getMessageProperties().getCorrelationIdString(); + if (!openPings.remove(correlationId)) { + LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.info("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, + correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); + } + + return; + } + + LOGGER.info("No valid message type property."); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) + void checkDmfHealth() { + LOGGER.info("Message CheckDmfHealth "); + + if (!amqpProperties.isCheckDmfHealth()) { + return; + } + + if (openPings.size() > 5) { + LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); + } else { + LOGGER.debug("Currently {} open pings", openPings.size()); + } + + repository.getTenants().forEach(tenant -> { + final String correlationId = UUID.randomUUID().toString(); + spSenderService.ping(tenant, correlationId); + openPings.add(correlationId); + LOGGER.debug("Ping tenant {%s} with correlationId {%s}", tenant, correlationId); + }); + } + + private void handleEventMessage(final Message message, final String thingId) { + + LOGGER.info("handling event "+thingId); + + final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); + if (eventHeader == null) { + logAndThrowMessageError(message, "Event Topic is not set"); + } + + // Exception squid:S2259 - Checked before + @SuppressWarnings({ "squid:S2259" }) + final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); + LOGGER.info("EventTopic "+eventTopic); + + switch (eventTopic) { + case DOWNLOAD_AND_INSTALL: + case DOWNLOAD: + LOGGER.info("Download with message:\n"+message.toString()); + handleUpdateProcess(message, thingId, eventTopic); + break; + case CANCEL_DOWNLOAD: + LOGGER.info("Cancel Download with message:\n"+message.toString()); + handleCancelDownloadAction(message, thingId); + break; + case REQUEST_ATTRIBUTES_UPDATE: + LOGGER.info("Attributes update with message:\n"+message.toString()); + handleAttributeUpdateRequest(message, thingId); + break; + default: + LOGGER.info("No valid event property."); + break; + } + } + + private void handleAttributeUpdateRequest(final Message message, final String thingId) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + LOGGER.info("handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); + spSenderService.updateAttributesOfThing(tenant, thingId); + } + + public void handleCancelDownloadAction(final Message message, final String thingId) { + System.out.println("[DmfReceiverService] handling Cancel/Download Action "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + System.out.println(message.toString()); + //final Long actionId = convertMessage(message, Long.class); + JsonObject actionIdJson = gson.fromJson(message.getBody().toString(), JsonObject.class); + if(actionIdJson.has("actionId")) { + final long actionId = actionIdJson.get("actionId").getAsLong(); + final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); + spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + } else { + LOGGER.error("Action ID does not exist in message: "+message.toString()); + } + } + + private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { + LOGGER.info(" handling update "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + + final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, + DmfDownloadAndUpdateRequest.class); + final Long actionId = downloadAndUpdateRequest.getActionId(); + final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); + System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); + + + //TODO: This does not work if the there are two or more fws (os and app) + // Following options to consider: + //1- merge into one ZIP and upload to Bucket instead and point the device to it + //2- upload each file separately and upload it to a folder which is the device id, and point the device to the folder + downloadAndUpdateRequest.getSoftwareModules().forEach(module -> { + module.getArtifacts().forEach( + artifact -> + { + try { + LOGGER.info("Handling artifact : "+artifact.getFilename()); + GcpBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } + }); + }); + + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, + null, device -> sendFeedback(actionId, device), actionType); + } + + private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { + LOGGER.info(" sendFeedback event "+device.getId()); + + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case ERROR: + spSenderService.finishUpdateProcessWithError( + new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case DOWNLOADING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case DOWNLOADED: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case RUNNING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + default: + break; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java new file mode 100644 index 0000000..eb910e2 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -0,0 +1,395 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; +import org.eclipse.hawkbit.dmf.amqp.api.MessageType; +import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; +import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.simulator.SimulationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.support.CorrelationData; +import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; + +/** + * Sender service to send messages to update server. + */ +public class DmfSenderService extends MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); + + private final String spExchange; + + private final SimulationProperties simulationProperties; + + /** + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param simulationProperties + * for attributes update class + */ + DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + super(rabbitTemplate, amqpProperties); + spExchange = AmqpSettings.DMF_EXCHANGE; + this.simulationProperties = simulationProperties; + System.out.println("[DmfSenderService] init"); + } + + public void ping(final String tenant, final String correlationId) { + System.out.println("[DmfSenderService] ping with correlationId "+correlationId); + + final MessageProperties messageProperties = new MessageProperties(); + messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); + messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); + messageProperties.setCorrelationIdString(correlationId); + messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); + messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); + + sendMessage(spExchange, new Message(null, messageProperties)); + } + + /** + * Finish the update process. This will send a action status to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a description according the update process + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] Update Process"); + final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, + updateResultMessages); + sendMessage(spExchange, updateResultMessage); + } + + /** + * Finish update process with error and send error to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * list of messages for error + */ + public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] update error"); + sendErrorgMessage(update, updateResultMessages); + LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, + update.getThingId()); + } + + /** + * Send a message if the message is not null. + * + * @param address + * the exchange name + * @param message + * the amqp message which will be send if its not null + */ + public void sendMessage(final String address, final Message message) { + + + if (message == null) { + System.out.println("[DmfSenderService] received a null message"); + return; + } + System.out.println("[DmfSenderService] send message "+message.toString()); + message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); + + final String correlationId = UUID.randomUUID().toString(); + + if (isCorrelationIdEmpty(message)) { + message.getMessageProperties().setCorrelationIdString(correlationId); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); + } else { + LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); + } + + rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); + } + + private static boolean isCorrelationIdEmpty(final Message message) { + System.out.println("[DmfSenderService] coorelation"); + + return message.getMessageProperties().getCorrelationIdString() == null + || message.getMessageProperties().getCorrelationIdString().length() <= 0; + } + + /** + * Convert object and message properties to message. + * + * @param object + * to get converted + * @param messageProperties + * to get converted + * @return converted message + */ + public Message convertMessage(final Object object, final MessageProperties messageProperties) { + Message m = rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); + System.out.println("[DmfSenderService] Converted Message "+m.toString()); + return m; + } + + /** + * Send an error message to SP. + * + * @param tenant + * the tenant + * @param updateResultMessages + * the error message description to send + * @param actionId + * the ID of the action for the error message + */ + public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { + + + final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, + actionId); + System.out.println("[DmfSenderService] send error message "); + + sendMessage(spExchange, message); + } + + /** + * Send a warning message to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a warning description + */ + public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); + System.out.println("[DmfSenderService] warning message "); + + sendMessage(spExchange, message); + } + + /** + * Method to send a action status to SP. + * + * @param tenant + * the tenant + * @param actionStatus + * the action status + * @param updateResultMessages + * the message to get send + * @param actionId + * the cached value + */ + public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] send action message"); + + final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); + sendMessage(message); + + } + + /** + * Create new thing created message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void createOrUpdateThing(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] create/update "); + + sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); + + LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void updateAttributesOfThing(final String tenant, final String targetId) { + System.out.printf("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); + Map metadata = GcpIoTHandler.getDeviceMetadata(targetId); + sendMessage(spExchange, + updateAttributes(tenant, + targetId, + DmfUpdateMode.MERGE, + metadata)); + } + + /** + * Create new attribute update message for specific attribute and send to + * update server + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + * @param mode + * the update mode ('merge', 'replace', or 'remove') + * @param key + * the key of the attribute + * @param value + * the value of the attribute + */ + public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, + final String key, final String value) { + System.out.println("[DmfSenderService] updateAttributesOfThing"); + + sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); + } + + private Message thingCreatedMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] thingCreatedMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return new Message(null, messagePropertiesForSP); + } + + private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] createAttributeUpdateMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return messagePropertiesForSP; + } + + private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, + final Map attributes) { + System.out.println("[DmfSenderService] AttributeUpdateMessage"); + final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); + final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); + attributeUpdate.setMode(mode); + attributeUpdate.getAttributes().putAll(attributes); + + Message m = convertMessage(attributeUpdate, messagePropertiesForSP); + System.out.println("Converted Message "+m.toString()); + return m; + } + + + + /** + * Send a created message to SP. + * + * @param message + * the message to get send + */ + private void sendMessage(final Message message) { + LOGGER.info("[DmfSenderService] sending "+message.toString()); + + sendMessage(spExchange, message); + } + + /** + * Send error message to SP. + * + * @param context + * the current context + * @param updateResultMessages + * a list of descriptions according the update process + */ + private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] error"); + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); + sendMessage(spExchange, message); + } + + /** + * Create a action status message. + * + * @param actionStatus + * the ActionStatus + * @param actionMessage + * the message description + * @param actionId + * the action id + * @param cacheValue + * the cacheValue value + */ + private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, tenant); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, + final List updateResultMessages) { + System.out.println("[DmfSenderService] createUpdateResultMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), + actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, + final DmfActionStatus status) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java new file mode 100644 index 0000000..d47ab59 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; + +/** + * Abstract class for sender and receiver service. + * + * + * + */ +public class MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(MessageService.class); + + protected final RabbitTemplate rabbitTemplate; + + protected final AmqpProperties amqpProperties; + + /** + * Constructor. + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param messageConverter + * the message converter + */ + public MessageService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties) { + this.rabbitTemplate = rabbitTemplate; + this.amqpProperties = amqpProperties; + } + + /** + * Method to call when error emerges. + * + * @param message + * the message that triggered the error + * @param error + * the error + */ + public void logAndThrowMessageError(final Message message, final String error) { + LOGGER.error("Error \"{}\" reported by message {}", error, message.getMessageProperties().getMessageId()); + throw new IllegalArgumentException(error); + } + + /** + * Convert a message body to a given class and set the message header + * AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME for Jackson converter. + * + * @param message + * which body will converted + * @param clazz + * the body class + * @return the converted body + */ + @SuppressWarnings("unchecked") + public T convertMessage(final Message message, final Class clazz) { + message.getMessageProperties().getHeaders().put(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME, + clazz.getTypeName()); + return (T) rabbitTemplate.getMessageConverter().fromMessage(message); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java new file mode 100644 index 0000000..3c46e89 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Object for holding attributes for a simulated update for the device + * simulator. + */ +public class SimulatedUpdate implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String tenant; + private final String thingId; + private final Long actionId; + private transient LocalDateTime startCacheTime; + + SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { + this.tenant = tenant; + this.thingId = thingId; + this.actionId = actionId; + this.startCacheTime = LocalDateTime.now(); + } + + public String getTenant() { + return tenant; + } + + public String getThingId() { + return thingId; + } + + public Long getActionId() { + return actionId; + } + + public LocalDateTime getStartCacheTime() { + return startCacheTime; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java new file mode 100644 index 0000000..8cd1f86 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.http; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +/** + * A feign interceptor to apply the gateway-token header to each http-request. + * + */ +public class GatewayTokenInterceptor implements RequestInterceptor { + + private final String gatewayToken; + + /** + * @param gatewayToken + * the gatwway token to be used in the http-header + */ + public GatewayTokenInterceptor(final String gatewayToken) { + this.gatewayToken = gatewayToken; + } + + @Override + public void apply(final RequestTemplate template) { + template.header("Authorization", "GatewayToken " + gatewayToken); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/resources/.gitignore b/hawkbit-gcp-iot-core/src/main/resources/.gitignore new file mode 100644 index 0000000..81dd324 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/.gitignore @@ -0,0 +1,4 @@ +/keys.json +/rsa_cert.pem +/rsa_private.pem +/firestorekeys.json diff --git a/hawkbit-gcp-iot-core/src/main/resources/application.properties b/hawkbit-gcp-iot-core/src/main/resources/application.properties new file mode 100644 index 0000000..b7a0588 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/application.properties @@ -0,0 +1,42 @@ +# +# Copyright (c) 2015 Bosch Software Innovations GmbH and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# + +## Configuration for DMF communication + + +hawkbit.device.simulator.amqp.enabled=true +hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp=simulator_receiver +hawkbit.device.simulator.amqp.deadLetterQueue=simulator_deadletter +hawkbit.device.simulator.amqp.deadLetterExchange=simulator.deadletter +hawkbit.device.simulator.amqp.senderForSpExchange=simulator.replyTo + +hawkbit.device.simulator.default-tenant=DEFAULT + +## Configuration for simulations +hawkbit.device.simulator.autostarts.[0].tenant=${hawkbit.device.simulator.default-tenant} + +hawkbit.device.simulator.attributes[0].key=isoCode +hawkbit.device.simulator.attributes[0].random=DE,US,AU,FR,DK,CA +hawkbit.device.simulator.attributes[1].key=hwRevision +hawkbit.device.simulator.attributes[1].value=1.1 +hawkbit.device.simulator.attributes[2].key=serial +hawkbit.device.simulator.attributes[2].value=${random.value} + +endpoints.health.enabled=true + +## Configuration for local RabbitMQ integration +spring.rabbitmq.username=guest +spring.rabbitmq.password=guest +spring.rabbitmq.virtualHost=/ +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.dynamic=true + +security.basic.enabled=false +server.port=8083 diff --git a/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml b/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..4a77556 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml b/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 0000000..8beb8e5 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,6 @@ + + true + java8 + 1 + \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/vmInstallDependencies.sh b/hawkbit-gcp-iot-core/vmInstallDependencies.sh new file mode 100644 index 0000000..055a75a --- /dev/null +++ b/hawkbit-gcp-iot-core/vmInstallDependencies.sh @@ -0,0 +1,41 @@ +echo 'Installing git' +sudo apt-get install git + +echo 'Installing jdk 8' +sudo apt-get install openjdk-8-jdk + +echo 'Installing maven' +sudo apt-get install maven +sudo update-alternatives --config java + +echo 'Installing docker' +sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common +curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - +sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" +sudo apt-get update +sudo apt-get install docker-ce docker-ce-cli containerd.io + +echo 'cloning hawbkit and installing it' +mkdir hawkbit +git clone https://github.com/eclipse/hawkbit.git +cd hawkbit +mvn clean install + +echo 'cloning gcp hawkbit module' +git clone https://github.com/charbull/hawkbit-examples.git +cd hawkbit-examples +mvn clean install +cd hawkbit-device-simulator/ +chmod 777 runSpring.sh +#./runSpring.sh + +echo 'Creating topic state and subscription state' +gcloud pubsub topics create state +gcloud pubsub subscriptions create --topic state state + +echo 'things you should do manually' +echo '- Creating a service account hawkbit-poc' +echo '- Get the keys and put it in: hawkbit-examples/hawkbit-device-simulator/src/main/resources' + +#sudo docker swarm init +#sudo docker stack deploy -c docker-compose-stack.yml hawkbit \ No newline at end of file From 4a3e9162ae0380bea0a572005ce4220103061afc Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:50:09 -0400 Subject: [PATCH 49/54] update from master --- hawkbit-device-simulator/README.md | 159 +---- .../docker/0.3.0-SNAPSHOT/Dockerfile | 16 +- .../docker/0.3.0-SNAPSHOT/Dockerfile.original | 18 - hawkbit-device-simulator/docker/Dockerfile | 8 - hawkbit-device-simulator/pom.xml | 321 ++++----- .../src/main/appengine/app.yaml | 9 - .../hawkbit/google/gcp/GcpBucketHandler.java | 270 ------- .../hawkbit/google/gcp/GcpCredentials.java | 83 --- .../hawkbit/google/gcp/GcpFireStore.java | 76 -- .../hawkbit/google/gcp/GcpIoTHandler.java | 426 ----------- .../eclipse/hawkbit/google/gcp/GcpOTA.java | 36 - .../hawkbit/google/gcp/GcpProperties.java | 62 -- .../hawkbit/google/gcp/GcpSubscriber.java | 231 ------ .../gcp/HawkBitSoftwareModuleHandler.java | 74 -- .../gcp/RetryHttpInitializerWrapper.java | 104 --- .../simulator/AllowAllWebSecurityConfig.java | 25 + .../hawkbit/simulator/DDISimulatedDevice.java | 1 - .../hawkbit/simulator/DMFSimulatedDevice.java | 9 - .../hawkbit/simulator/DeviceSimulator.java | 2 - .../simulator/DeviceSimulatorUpdater.java | 650 ++++++++--------- .../simulator/SimulatedDeviceFactory.java | 3 +- .../simulator/SimulationController.java | 448 ++++++------ .../simulator/SimulationProperties.java | 2 +- .../hawkbit/simulator/SimulatorStartup.java | 68 +- .../hawkbit/simulator/UpdateStatus.java | 6 +- .../simulator/amqp/AmqpConfiguration.java | 82 +-- .../simulator/amqp/AmqpProperties.java | 10 + .../simulator/amqp/DmfReceiverService.java | 483 ++++++------- .../simulator/amqp/DmfSenderService.java | 664 ++++++++---------- .../simulator/amqp/MessageService.java | 2 - .../simulator/amqp/SimulatedUpdate.java | 10 +- .../src/main/resources/.gitignore | 4 - .../src/main/resources/application.properties | 4 +- .../src/main/resources/logback-spring.xml | 2 +- .../src/main/webapp/WEB-INF/appengine-web.xml | 6 - 35 files changed, 1293 insertions(+), 3081 deletions(-) delete mode 100644 hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original delete mode 100644 hawkbit-device-simulator/docker/Dockerfile delete mode 100644 hawkbit-device-simulator/src/main/appengine/app.yaml delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java delete mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java create mode 100644 hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java delete mode 100644 hawkbit-device-simulator/src/main/resources/.gitignore delete mode 100644 hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index 708a95f..dbe9362 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -1,150 +1,51 @@ -# hawkBit GCP Device Simulator - - -## Spin a VM - -Use the installation script: [vmInstallDependencies.sh](./vmInstallDependencies.sh) - -or -install the following: -### git: -`sudo apt-get install git` - -### java 8 - -- `sudo apt-get install openjdk-8-jdk` - -- `sudo update-alternatives --config java` - -### docker - -Please read the following if you want to know more about how to install it [here](https://docs.docker.com/install/linux/docker-ce/debian/) - -- `sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common` -- `curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -` -- `sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"` -- `sudo apt-get update` -- `sudo apt-get install docker-ce docker-ce-cli containerd.io` - -### maven - -`sudo apt-get install maven` - -### create a service account -### add the service account to the VM in the configuration - -## First Credentials for GCP -- use the same service account -- Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) - -- Rename the downloaded file to `keys.json` - -- Add it to `src/main/resources` - -## Device Registry - -For now, this handler supports only one registry - -## GCP Config - -- Set the projectId and the cloud region in the GCP_OTA.java -- Create a `state` subscription on the state topic -- Create a bucket: gsutil mb gs:/firmware-ota/ -- enable the Token Service API: `cloud iot token` - - # hawkBit Device Simulator The device simulator handles software update commands from the update server. It is designed to be used very conveniently, for example, from within a browser. Hence, all the endpoints use the GET verb. --Dhawkbit.device.simulator.amqp.enabled=true - -# Open Ports - -- 8080/tcp —> hawkbit -- 8083/tcp —> gcp manager -- 3306/tcp, 33060/tcp —> Mysql -- 4369/tcp, 5671-5672/tcp, 15671-15672/tcp, 25672/tcp —> rabbitMQ -# docker on debian - -if you had any difficulty installing docker compose follow the following - -1- Open `docker-compose-stack.yml` and remove the hawkBit simulator part, since we want to run the GCP Manager on the same port -``` - - image: "hawkbit/hawkbit-device-simulator:latest" - networks: - - hawknet - ports: - - "8083:8083" - deploy: - restart_policy: - condition: on-failure - environment: - - 'HAWKBIT_DEVICE_SIMULATOR_AUTOSTARTS_[0]_TENANT=DEFAULT' - - 'SPRING_RABBITMQ_VIRTUALHOST=/' - - 'SPRING_RABBITMQ_HOST=rabbitmq' - - 'SPRING_RABBITMQ_PORT=5672' - - 'SPRING_RABBITMQ_USERNAME=guest' - - 'SPRING_RABBITMQ_PASSWORD=guest' +## Run on your own workstation ``` - -2 - Run the following to start it +java -jar examples/hawkbit-device-simulator/target/hawkbit-device-simulator-*-SNAPSHOT.jar ``` -sudo docker swarm init -sudo docker stack deploy -c docker-compose-stack.yml hawkbit +Or: ``` - - -## Firebase config -Follow these steps to configurate firebase with the java sdk [steps](https://firebase.google.com/docs/admin/setup) -Generate the file and place it in `src/main/resources` and name it `firebasekeys.json` - -## MySQL Info - MYSQL_DATABASE: "hawkbit" - MYSQL_USER: "root" - port : 3306 - -## Run on your own workstation +run org.eclipse.hawkbit.simulator.DeviceSimulator ``` -mvn spring-boot:run -``` -or use the the [runSpring.sh](./runSpring.sh) - -## Create Software Distribution - -## Tag your Devices - -## Create a Target Filter - -## Configure the Rollout - -- Error threshold 0 -- Trigger threshold 100 - +## hawkBit APIs -Follow the same config as in the -![image](./images/rolloutConfig.png) +The simulator supports `DDI` as well as the `DMF` integration APIs. +In case there is no AMQP message broker (like rabbitMQ) running, you can disable the AMQP support for the device simulator, so the simulator is not trying to connect to an amqp message broker. +Configuration property `hawkbit.device.simulator.amqp.enabled=false` -## Notes +## Usage -The simulator has user authentication enabled in **cloud profile**. Default credentials: -* username : admin -* passwd : admin +### REST API +The device simulator exposes an REST-API which can be used to trigger device creation. -This can be configured/disabled by spring boot properties +Optional parameters: +* name : name prefix simulated devices (default: "dmfSimulated"), followed by counter +* amount : number of simulated devices (default: 20, capped at: 4000) +* tenant : in a multi-tenant ready hawkBit installation (default: "DEFAULT") +* api : the API which should be used for the simulated device either `dmf` or `ddi` (default: "dmf") +* endpoint : URL which defines the hawkbit DDI base endpoint (default: "http://localhost:8080") +* polldelay : number in seconds of the delay when DDI simulated devices should poll the endpoint (default: "30") +* gatewaytoken : an hawkbit gateway token to be used in case hawkbit does not allow anonymous access for DDI devices (default: "") -## hawkBit APIs -In case there is no AMQP message broker (like rabbitMQ) running, you can disable the AMQP support for the device simulator, so the simulator is not trying to connect to an amqp message broker. +Example: for 20 simulated devices by DMF API (default) +``` +http://localhost:8083/start +``` -Configuration property `hawkbit.device.simulator.amqp.enabled=false` +Example: for 10 simulated devices that start with the name prefix "activeSim": +``` +http://localhost:8083/start?amount=10&name=activeSim +``` -## Populate GCP devices +Example: for 5 simulated devices that start with the name prefix "ddi" using the Direct Device Integration API (http) authenticated by given gateway token, a pool interval of 10 seconds and a custom port for the DDI service.: +``` +http://localhost:8083/start?amount=5&name=ddi&api=ddi&gatewaytoken=d5F2mmlARiMuMOquRmLlxW4xZFHy4mEV&polldelay=10&endpoint=http://localhost:8085 ``` -http://localhost:8083/gcp -``` \ No newline at end of file diff --git a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile index 18cc6b5..e8f3693 100644 --- a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile +++ b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile @@ -1,10 +1,16 @@ -FROM openjdk:8-jre -FROM jetty +FROM openjdk:8u171-jre-alpine -MAINTAINER Charbel Kaed +ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator +# Http port EXPOSE 8083 -ADD target/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar /opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" -ENTRYPOINT ["java","-jar","/opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original b/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original deleted file mode 100644 index 5c4ebd3..0000000 --- a/hawkbit-device-simulator/docker/0.3.0-SNAPSHOT/Dockerfile.original +++ /dev/null @@ -1,18 +0,0 @@ -FROM openjdk:8u171-jre-alpine - -MAINTAINER Kai Zimmermann - -ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ - HAWKBIT_SIM_HOME=/opt/hawkbit-simulator - -# Http port -EXPOSE 8083 - -RUN set -x \ - && mkdir -p $HAWKBIT_SIM_HOME \ - && cd $HAWKBIT_SIM_HOME \ - && apk add --no-cache libressl wget \ - && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" - -WORKDIR $HAWKBIT_SIM_HOME -ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/docker/Dockerfile b/hawkbit-device-simulator/docker/Dockerfile deleted file mode 100644 index 2fec7d4..0000000 --- a/hawkbit-device-simulator/docker/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM openjdk:8u171-jre-alpine - -MAINTAINER Charbel Kaed - -# Http port -EXPOSE 8083 - -ENTRYPOINT ["java","-jar","hawkbit-gcp-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 7e87b0b..a139442 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -1,207 +1,126 @@ - - 4.0.0 - - org.eclipse.hawkbit - 0.3.0-SNAPSHOT - hawkbit-examples-parent - + - - org.springframework.amqp - spring-rabbit - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-jetty - - - org.springframework.boot - spring-boot-starter-logging - - - org.springframework.security - spring-security-web - - - org.springframework.security - spring-security-config - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-actuator - - - org.springframework.boot - spring-boot-autoconfigure - - - org.springframework.cloud - spring-cloud-commons - - - org.springframework.cloud - spring-cloud-context - - - org.apache.httpcomponents - httpclient - +--> + + 4.0.0 + + org.eclipse.hawkbit + 0.3.0-SNAPSHOT + hawkbit-examples-parent + + hawkbit-device-simulator + hawkBit :: Examples :: Device Simulator + Device Management Federation API based simulator - - - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - 1.2.0 - - - org.json - json - 20090211 - - - io.jsonwebtoken - jjwt - 0.7.0 - - - joda-time - joda-time - 2.1 - - - com.google.apis - google-api-services-cloudiot - v1-rev20181120-1.27.0 - - - com.google.oauth-client - google-oauth-client - 1.23.0 - - - com.google.api-client - google-api-client - 1.28.0 - - - com.google.auth - google-auth-library-appengine - 0.12.0 - - - - com.google.apis - google-api-services-iam - v1-rev267-1.25.0 - - - commons-cli - commons-cli - 1.4 - - - com.google.cloud - google-cloud-pubsub - 1.63.0 - - - - com.google.apis - google-api-services-storage - v1-rev149-1.25.0 - - - com.google.cloud - google-cloud-firestore - 0.81.0-beta - - - com.google.firebase - firebase-admin - 6.7.0 - - - - com.google.guava - guava - 23.6-jre - + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + ${baseDir} + org.eclipse.hawkbit.simulator.DeviceSimulator + JAR + + + + + + + + src/main/resources + + + cf + true + ${project.build.directory} + + manifest.yml + + + + - - - junit - junit - 4.12 - test - - - com.google.truth - truth - 0.34 - test - - - \ No newline at end of file + + + org.eclipse.hawkbit + hawkbit-dmf-api + + + org.eclipse.hawkbit + hawkbit-example-ddi-feign-client + ${project.version} + + + org.springframework.amqp + spring-rabbit + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-context + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-jackson + + + com.google.guava + guava + + + org.apache.httpcomponents + httpclient + + + diff --git a/hawkbit-device-simulator/src/main/appengine/app.yaml b/hawkbit-device-simulator/src/main/appengine/app.yaml deleted file mode 100644 index 0e6bd11..0000000 --- a/hawkbit-device-simulator/src/main/appengine/app.yaml +++ /dev/null @@ -1,9 +0,0 @@ -service: default -runtime: java -runtime_config: - jdk: openjdk8 -env: flex - -handlers: -- url: /.* - script: this field is required, but ignored \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java deleted file mode 100644 index e55dbbc..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java +++ /dev/null @@ -1,270 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; -import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.InputStreamContent; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.jackson2.JacksonFactory; -import com.google.api.services.iam.v1.IamScopes; -import com.google.api.services.storage.Storage; -import com.google.api.services.storage.model.Bucket; -import com.google.api.services.storage.model.Buckets; -import com.google.api.services.storage.model.Objects; -import com.google.api.services.storage.model.StorageObject; -import com.google.gson.Gson; -import com.google.gson.JsonObject; - - - - -public class GcpBucketHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(GcpBucketHandler.class); - - private static Storage storage = null; - static Gson gson = new Gson(); - - private static HttpTransport httpTransport; - private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); - - - - private static Storage getStorage() { - try { - if(storage == null) - { - GoogleCredential credential = GcpCredentials.getCredential() - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); - storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); - } - } catch (Exception e) { - e.printStackTrace(); - } - return storage; - } - - public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { - - Storage gcs = getStorage(); - String data = HawkBitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); - if(!checkIfExists(artifactName)) - { - LOGGER.info("Uploading to GCS artifact: "+artifactName); - uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); - } - } - - public static String getFirmwareInfoBucket(String artifactName) - { - StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); - if(storageObject != null) - { - JsonObject jsonObject = new JsonObject(); - LOGGER.info(artifactName+" exists!"); - jsonObject.addProperty("ObjectName", storageObject.getName()); - jsonObject.addProperty("Url", storageObject.getMediaLink()); - jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); - - JsonObject jsonConfig = new JsonObject(); - jsonConfig.add("firmware-update", jsonObject); - return gson.toJson(jsonConfig); - } - return null; - } - - - public static Map> getFirmwareInfoBucket_Map(String artifactName) - { - StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); - if(storageObject != null) - { - Map> fw_update = new HashMap<>(1); - Map mapContent = new HashMap<>(3); - LOGGER.info(artifactName+" exists!"); - mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); - mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); - mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); - fw_update.put(GcpOTA.FW_UPDATE, mapContent); - return fw_update; - } - return null; - } - - - public static Map>> getFirmwareInfoBucket_MapList(List modules) - { - Map>> fw_update_Map = - new HashMap>>(1); - - - List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) - .map(art -> art.getFilename()) - .collect(Collectors.toList()); - - List> list_fw_update = new ArrayList<>(fwNameList.size()); - - fwNameList.forEach(artifactName -> { - StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); - if(storageObject != null) - { - Map mapContent = new HashMap<>(3); - LOGGER.info(artifactName+" exists!"); - mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); - mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); - mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); - list_fw_update.add(mapContent); - } - }); - fw_update_Map.put(GcpOTA.FW_UPDATE, list_fw_update); - return fw_update_Map; - } - - private static boolean checkIfExists(String artifactName) throws IOException { - - Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - if(object.getName().equalsIgnoreCase(artifactName)) - { - LOGGER.info(artifactName+" already exists!"); - return true; - } - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - return false; - } - - public static StorageObject getStorageObjectInfo(String artifactName) { - try { - Storage.Objects.List objectsList = getStorage().objects().list(GcpOTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - if(object.getName().equalsIgnoreCase(artifactName)) - { - LOGGER.info(artifactName+" exists!"); - return object; - } - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - LOGGER.warn(artifactName+" not found"); - } catch (Exception e) { - } - return null; - } - - - private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { - ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); - String path = classLoader.getResource("keys.json").getPath(); - GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - httpTransport = GoogleNetHttpTransport.newTrustedTransport(); - - - Storage storage = new Storage.Builder( - httpTransport, - JSON_FACTORY, - credential - ).build(); - - Storage.Buckets.List bucketsList = storage.buckets().list(GcpOTA.PROJECT_ID); - Buckets buckets; - do { - buckets = bucketsList.execute(); - List items = buckets.getItems(); - if (items != null) { - for (Bucket bucket: items) { - System.out.println("[BucketHandler] BucketName : "+bucket.getName()); - } - } - bucketsList.setPageToken(buckets.getNextPageToken()); - } while (buckets.getNextPageToken() != null); - - Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); - Objects objects; - do { - objects = objectsList.execute(); - List items = objects.getItems(); - if (items != null) { - for (StorageObject object : items) { - System.out.println("[BucketHandler] ObjectName: "+object.getName()); - System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); - System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); - } - } - objectsList.setPageToken(objects.getNextPageToken()); - } while (objects.getNextPageToken() != null); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - String data) throws UnsupportedEncodingException, IOException { - return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( - data.getBytes("UTF-8")), "text/plain"); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - File data) throws FileNotFoundException, IOException { - return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), - "application/octet-stream"); - } - - private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, - InputStream data, String contentType) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(contentType, data); - Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) - .setName(objectName); - // The media uploader gzips content by default, and alters the Content-Encoding accordingly. - // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, - // so the service stores exactly what is in the InputStream, without transformation. - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } - - private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, - InputStream data) throws IOException { - InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); - Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, - mediaContent); - insertObject.getMediaHttpUploader().setDisableGZipContent(true); - return insertObject.execute(); - } -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java deleted file mode 100644 index f9aa889..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; -import com.google.api.gax.core.CredentialsProvider; -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.auth.oauth2.ServiceAccountCredentials; - -public class GcpCredentials { - - - private static Path keysFile = null; - private static GoogleCredentials googleCredentials; - private static GoogleCredential googleCredential; - private static CredentialsProvider credentialsProvider; - - private static final Logger LOGGER = LoggerFactory.getLogger(GcpCredentials.class); - - protected static GoogleCredential getCredential() { - if(googleCredential == null) { - try { - googleCredential = GoogleCredential.fromStream(Files.newInputStream(keysFile)); - } catch (IOException e) { - e.printStackTrace(); - System.out.println("Please make sure to put your keys.json in the project"); - LOGGER.error("Please make sure to put your keys.json in the project"); - } - } - return googleCredential; - } - - - protected static CredentialsProvider getCredentialProvider() { - if(credentialsProvider == null) { - try { - credentialsProvider = FixedCredentialsProvider.create( - ServiceAccountCredentials.fromStream(Files.newInputStream(keysFile))); - } catch (IOException e) { - e.printStackTrace(); - } - } - return credentialsProvider; - } - - protected static GoogleCredentials getCredentials() { - if(googleCredentials == null) { - try { - googleCredentials = GoogleCredentials.fromStream(Files.newInputStream(keysFile)); - } catch (IOException e) { - e.printStackTrace(); - } - } - return googleCredentials; - } - - public static void setKeysFilePath(String keysPath) { - LOGGER.info("==========> Setting keys path to "+keysPath); - keysFile = Paths.get(keysPath); - LOGGER.info("==========> Setting keys path to "+keysFile.toString()); - int n; - try (InputStream in = Files.newInputStream(keysFile)) { - while ((n = in.read()) != -1) { - System.out.print((char) n); - } - } catch (IOException x) { - System.err.format("IOException: %s%n", x); - } - LOGGER.info("============================================================ "); - - getCredentials(); - getCredential(); - } - -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java deleted file mode 100644 index 6b3f216..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.api.core.ApiFuture; -import com.google.api.services.iam.v1.IamScopes; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.cloud.firestore.DocumentReference; -import com.google.cloud.firestore.Firestore; -import com.google.cloud.firestore.SetOptions; -import com.google.cloud.firestore.WriteResult; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.cloud.FirestoreClient; - -public class GcpFireStore { - - private static final Logger LOGGER = LoggerFactory.getLogger(GcpFireStore.class); - - private static Firestore db; - - public static void init() { - - GoogleCredentials credentials = GcpCredentials.getCredentials() - .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); - - FirebaseOptions options = new FirebaseOptions.Builder() - .setCredentials(credentials) - .setProjectId(GcpOTA.PROJECT_ID) - .build(); - FirebaseApp.initializeApp(options); - - db = FirestoreClient.getFirestore(); - } - - - public static void addDocumentMapList(String deviceId, Map>> mapList) { - try { - DocumentReference docRef = db - .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) - .document(deviceId) - .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) - .document(deviceId); - ApiFuture result = docRef.set(mapList, SetOptions.merge()); - LOGGER.info("Update time : " + result.get().getUpdateTime()); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - } - - public static void addDocument(String deviceId, Map> map) { - try { - DocumentReference docRef = db - .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) - .document(deviceId) - .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) - .document(deviceId); - ApiFuture result = docRef.set(map); - LOGGER.info("Update time : " + result.get().getUpdateTime()); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - } - - -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java deleted file mode 100644 index 7b11c8c..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java +++ /dev/null @@ -1,426 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.util.Base64; -import java.util.List; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; -import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; -import com.google.api.client.googleapis.json.GoogleJsonResponseException; -import com.google.api.client.http.HttpRequestInitializer; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.jackson2.JacksonFactory; -import com.google.api.services.cloudiot.v1.CloudIot; -import com.google.api.services.cloudiot.v1.CloudIotScopes; -import com.google.api.services.cloudiot.v1.model.Device; -import com.google.api.services.cloudiot.v1.model.DeviceConfig; -import com.google.api.services.cloudiot.v1.model.DeviceRegistry; -import com.google.api.services.cloudiot.v1.model.DeviceState; -import com.google.api.services.cloudiot.v1.model.ListDeviceStatesResponse; -import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest; -import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; -import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; - - -public class GcpIoTHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(GcpIoTHandler.class); - - public static GoogleCredential getCredentialsFromFile() - { - GoogleCredential credential = null; - credential = GcpCredentials.getCredential() - .createScoped(CloudIotScopes.all()); - return credential; - } - - -// public static List getAllDevices(String projectId, String cloudRegion, String registryId) throws GeneralSecurityException, IOException -// { -// List allDevices_per_project = new ArrayList(); -// List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); -// for(DeviceRegistry gcp_registry : gcp_registries) -// { -// allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); -// } -// return allDevices_per_project; -// } - - public static List listDevices(String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String registryPath = - String.format( - "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); - - List devices = - service - .projects() - .locations() - .registries() - .devices() - .list(registryPath) - .execute() - .getDevices(); - - if (devices != null) { - System.out.println("Found " + devices.size() + " devices"); - for (Device d : devices) { - System.out.println("Id: " + d.getId()); - if (d.getConfig() != null) { - // Note that this will show the device config in Base64 encoded format. - System.out.println("Config: " + d.getConfig().toPrettyString()); - } - System.out.println(); - } - } else { - LOGGER.warn("Registry has no devices."); - System.out.println("Registry has no devices."); - } - return devices; - } - - public static boolean atLeastOnceConnected(String deviceId) { - try { - return atLeastOnceConnected(deviceId, - GcpOTA.PROJECT_ID, - GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME); - } catch (GeneralSecurityException | IOException e) { - e.printStackTrace(); - } - return false; - } - - - private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String deviceUniqueId = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - String lastTimeEvent = - service - .projects() - .locations() - .registries() - .devices() - .get(deviceUniqueId) - .execute() - .getLastEventTime(); - - String lastHRbeat = - service - .projects() - .locations() - .registries() - .devices() - .get(deviceUniqueId) - .execute() - .getLastHeartbeatTime(); - System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); - return (lastTimeEvent !=null || lastHRbeat!=null); - } - - - - /** - * Retrieves Device Metadata - * @return Map of metadata - * */ - public static Map getDeviceMetadata(String deviceId) { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - CloudIot service; - try { - service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String deviceUniqueId = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - GcpOTA.PROJECT_ID, - GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME, - deviceId); - - return service - .projects() - .locations() - .registries() - .devices() - .get(deviceUniqueId) - .execute().getMetadata(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } catch(GoogleJsonResponseException e) { - e.printStackTrace(); - LOGGER.error("Couldn't find the device: "+deviceId+" in the registry"); - } catch (IOException e) { - e.printStackTrace(); - } - return null; - } - - - - - - - /** Lists all of the registries associated with the given project. */ - public static List listRegistries(String projectId, String cloudRegion) - throws GeneralSecurityException, IOException { - - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; - - List registries = - service - .projects() - .locations() - .registries() - .list(projectPath) - .execute() - .getDeviceRegistries(); - - if (registries != null) { - LOGGER.info("Found " + registries.size() + " registries"); - for (DeviceRegistry r: registries) { - LOGGER.info("Id: " + r.getId()); - LOGGER.info("Name: " + r.getName()); - if (r.getMqttConfig() != null) { - LOGGER.info("Config: " + r.getMqttConfig().toPrettyString()); - } - System.out.println(); - } - } else { - LOGGER.warn("Project has no registries."); - } - return registries; - - } - - /** List all of the configs for the given device. */ - public static void listDeviceConfigs( - String deviceId, String projectId, String cloudRegion, String registryName) - { - try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - LOGGER.info("Listing device configs for " + devicePath); - List deviceConfigs = - service - .projects() - .locations() - .registries() - .devices() - .configVersions() - .list(devicePath) - .execute() - .getDeviceConfigs(); - - for (DeviceConfig config : deviceConfigs) { - LOGGER.info("\nConfig version: " + config.getVersion()); - LOGGER.info("Contents: " + config.getBinaryData()); - } - - } catch (Exception e) { - e.printStackTrace(); - } - } - - - /** List all of the configs for the given device. */ - public static long getLatestConfig( - String deviceId, String projectId, String cloudRegion, String registryName) - { - long configVersion = 0; - try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - LOGGER.info("Listing device configs for " + devicePath); - List deviceConfigs = - service - .projects() - .locations() - .registries() - .devices() - .configVersions() - .list(devicePath) - .execute() - .getDeviceConfigs(); - - - for (DeviceConfig config : deviceConfigs) { - LOGGER.info("\nConfig version: " + config.getVersion()); - LOGGER.info("Contents: " + config.getBinaryData()); - if(configVersion < config.getVersion()) - { - configVersion = config.getVersion(); - } - } - - } catch (Exception e) { - e.printStackTrace(); - } - - return configVersion; - } - - - /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ - public static void setDeviceConfiguration( - String deviceId, String projectId, String cloudRegion, String registryName, - String data, long version) - { - try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); - req.setVersionToUpdate(version); - - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - - DeviceConfig config = - service - .projects() - .locations() - .registries() - .devices() - .modifyCloudToDeviceConfig(devicePath, req).execute(); - - LOGGER.info("Updated: " + config.getVersion()); - } catch (GeneralSecurityException | IOException e) { - e.printStackTrace(); - } - - - } - - /** Retrieves device metadata from a registry. **/ - public static List getDeviceStates( - String deviceId, String projectId, String cloudRegion, String registryName) - { - - try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - LOGGER.info("Retrieving device states " + devicePath); - - ListDeviceStatesResponse resp = service - .projects() - .locations() - .registries() - .devices() - .states() - .list(devicePath).execute(); - - return resp.getDeviceStates(); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public static void sendCommand( - String deviceId, String projectId, String cloudRegion, String registryName, String data) - { - try { - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); - final CloudIot service = - new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) - .build(); - - final String devicePath = - String.format( - "projects/%s/locations/%s/registries/%s/devices/%s", - projectId, cloudRegion, registryName, deviceId); - - SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); - - // Data sent through the wire has to be base64 encoded. - Base64.Encoder encoder = Base64.getEncoder(); - String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); - req.setBinaryData(encPayload); - LOGGER.info("Sending command to %s\n", devicePath); - SendCommandToDeviceResponse res = - service - .projects() - .locations() - .registries() - .devices() - .sendCommandToDevice(devicePath, req) - .execute(); - - LOGGER.info("Command response: " + res.toString()); - - } catch (Exception e) { - e.printStackTrace(); - } - } - - /** Retrieves registry metadata from a project. **/ - public static DeviceRegistry getRegistry( - String projectId, String cloudRegion, String registryName) - throws GeneralSecurityException, IOException { - GoogleCredential credential = - GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); - JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); - HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); - final CloudIot service = new CloudIot.Builder( - GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); - - final String registryPath = String.format("projects/%s/locations/%s/registries/%s", - projectId, cloudRegion, registryName); - - return service.projects().locations().registries().get(registryPath).execute(); - } -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java deleted file mode 100644 index b3b6c83..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -public class GcpOTA { - -// public static String PROJECT_ID = "ota-iot-231619"; -// public static String CLOUD_REGION = "us-central1"; -// public static String REGISTRY_NAME = "OTA-DeviceRegistry"; -// public static String BUCKET_NAME = "ota-iot-231619.appspot.com"; - - public static String PROJECT_ID = ""; - public static String CLOUD_REGION = ""; - public static String REGISTRY_NAME = ""; - public static String BUCKET_NAME = ""; - - - public final static String FW_MSG_RECEIVED = "msg-received"; - public final static String FW_INSTALLING = "installing"; - public final static String FW_DOWNLOADING = "downloading"; - public final static String FW_INSTALLED = "installed"; - - public final static String SUBSCRIPTION_STATE_ID = "state"; - public final static String SUBSCRIPTION_FW_STATE = "fw-state"; - public final static String DEVICE_ID = "deviceId"; - - - public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; - public final static String FIRESTORE_CONFIG_COLLECTION = "config"; - public final static String FW_UPDATE = "firmware-update"; - - public final static String OBJECT_NAME = "ObjectName"; - public final static String URL = "Url"; - public final static String MD5HASH = "Md5Hash"; - - public final static boolean FW_VIA_COMMAND = false; -} - diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java deleted file mode 100644 index d64b8c7..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import org.apache.commons.cli.CommandLine; -import org.apache.commons.cli.DefaultParser; -import org.apache.commons.cli.HelpFormatter; -import org.apache.commons.cli.Option; -import org.apache.commons.cli.Options; -import org.apache.commons.cli.ParseException; - -public class GcpProperties { - - public static void parseCLI(String[] args) { - // create Options object - Options options = new Options(); - Option keys = Option.builder().argName("KEYS").hasArg().required() - .longOpt("KEYS").desc("Keys file path from a GCP project Service Account").build(); - Option gcpProjectID = Option.builder().argName("PROJECT_ID").hasArg().required() - .longOpt("PROJECT_ID").desc("GCP PROJECT_ID").build(); - Option gcpCloudRegion = Option.builder().argName("PROJECT_ID").hasArg().required() - .longOpt("CLOUD_REGION").desc("GCP CLOUD_REGION").build(); - Option gcpRegistryName = Option.builder().argName("REGISTRY_NAME").hasArg().required() - .longOpt("REGISTRY_NAME").desc("GCP REGISTRY_NAME").build(); - Option gcpBucketName = Option.builder().argName("BUCKET_NAME").hasArg().required() - .longOpt("BUCKET_NAME").desc("GCP BUCKET_NAME").build(); - - options.addOption(keys); - options.addOption(gcpProjectID); - options.addOption(gcpCloudRegion); - options.addOption(gcpRegistryName); - options.addOption(gcpBucketName); - - DefaultParser defaultParser = new DefaultParser(); - HelpFormatter formatter = new HelpFormatter(); - - CommandLine line; - try { - line = defaultParser.parse(options, args); - - if (line.hasOption("PROJECT_ID")) { - GcpOTA.PROJECT_ID = line.getOptionValue("PROJECT_ID"); - } - if (line.hasOption("CLOUD_REGION")) { - GcpOTA.CLOUD_REGION = line.getOptionValue("CLOUD_REGION"); - } - if (line.hasOption("REGISTRY_NAME")) { - GcpOTA.REGISTRY_NAME = line.getOptionValue("REGISTRY_NAME"); - } - if (line.hasOption("BUCKET_NAME")) { - GcpOTA.BUCKET_NAME = line.getOptionValue("BUCKET_NAME"); - } - if (line.hasOption("KEYS")) { - GcpCredentials.setKeysFilePath(line.getOptionValue("KEYS")); - } - - } catch (IllegalArgumentException | ParseException e) { - System.out.println(e.getMessage()); - formatter.printHelp("help", options); - System.exit(0); - } - } - -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java deleted file mode 100644 index 3b3f94b..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java +++ /dev/null @@ -1,231 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingDeque; - -import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; -import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; -import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; -import org.eclipse.hawkbit.simulator.UpdateStatus; -import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.cloud.pubsub.v1.AckReplyConsumer; -import com.google.cloud.pubsub.v1.MessageReceiver; -import com.google.cloud.pubsub.v1.Subscriber; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.pubsub.v1.ProjectSubscriptionName; -import com.google.pubsub.v1.PubsubMessage; - -public class GcpSubscriber { - - private static final BlockingQueue messages = new LinkedBlockingDeque<>(); - private static Map mapCallbacks = new HashMap(); - - private static Map mapDevices = new HashMap(); - - private static Gson gson = new Gson(); - - private static final Logger LOGGER = LoggerFactory.getLogger(GcpSubscriber.class); - - static class StateMessageReceiver implements MessageReceiver { - - @Override - public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { - messages.offer(message); - consumer.ack(); - } - } - - /** Receive messages over a subscription. */ - public static void init() { - ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of( - GcpOTA.PROJECT_ID, GcpOTA.SUBSCRIPTION_STATE_ID); - Subscriber subscriber = null; - try { - - // create a subscriber bound to the asynchronous message receiver - subscriber = - Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).setCredentialsProvider(GcpCredentials.getCredentialProvider()).build(); - subscriber.startAsync().awaitRunning(); - // Continue to listen to messages - while (true) { - PubsubMessage message = messages.take(); - if(!GcpOTA.FW_VIA_COMMAND) { - updateHawkbitStatus(message); - } - } - } catch (InterruptedException e) { - e.printStackTrace(); - } finally { - if (subscriber != null) { - subscriber.stopAsync(); - } - } - } - - - - public static void updateHawkbitStatus(PubsubMessage message){ - if(message.getData().toStringUtf8().contains(GcpOTA.SUBSCRIPTION_FW_STATE)) { - JsonObject payloadJson = gson.fromJson(message.getData() - .toStringUtf8(), JsonObject.class); - if(payloadJson.has(GcpOTA.SUBSCRIPTION_FW_STATE)) { - String deviceId = message.getAttributesMap().get(GcpOTA.DEVICE_ID); - String fw_state = payloadJson.get(GcpOTA.SUBSCRIPTION_FW_STATE).getAsString(); - - if(deviceId != null && fw_state != null) { - UpdateStatus updateStatus = null; - LOGGER.info("====> New state received "+fw_state+ " from device "+deviceId); - switch (fw_state) { - case GcpOTA.FW_MSG_RECEIVED : - updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); - sendUpate(deviceId, updateStatus); - break; - case GcpOTA.FW_DOWNLOADING: - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); - sendUpate(deviceId, updateStatus); - break; - case GcpOTA.FW_INSTALLING : - updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); - sendUpate(deviceId, updateStatus); - break; - case GcpOTA.FW_INSTALLED: - updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); - sendUpate(deviceId, updateStatus); - - //remove device and callback - mapCallbacks.remove(deviceId); - mapDevices.remove(deviceId); - - break; - default: - LOGGER.error("Unknown fw-state: "+fw_state); - updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); - sendUpate(deviceId, updateStatus); - break; - } - - } else { - LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); - - //Device never connected - if(!GcpIoTHandler.atLeastOnceConnected(deviceId)) { - LOGGER.error(deviceId+" : device was never connected"); - sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); - } - } - } else { - LOGGER.info("Ignoring message"); - } - } else { - LOGGER.info("Ignoring message"); - } - - } - - - private static String getStringFromListMap(Map>> listMap) { - JsonObject fw_update = new JsonObject(); - JsonArray fw_update_list = new JsonArray(); - - listMap.get(GcpOTA.FW_UPDATE).forEach(map -> { - JsonObject mapJsonObject = new JsonObject(); - mapJsonObject.addProperty(GcpOTA.OBJECT_NAME, map.get(GcpOTA.OBJECT_NAME)); - mapJsonObject.addProperty(GcpOTA.URL, map.get(GcpOTA.URL)); - mapJsonObject.addProperty(GcpOTA.MD5HASH, map.get(GcpOTA.MD5HASH)); - fw_update_list.add(mapJsonObject); - }); - fw_update.add(GcpOTA.FW_UPDATE,fw_update_list); - return gson.toJson(fw_update); - } - - - - private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { - Map>> data = - GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); - if(data != null) { - long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME); - LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); - GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); - - LOGGER.info("Writing to Firestore "); - GcpFireStore.addDocumentMapList(deviceId - , GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); - } - else LOGGER.error("Artifacts is empty for device "+deviceId); - } - - - - - @SuppressWarnings("unused") - private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { - String data = GcpBucketHandler.getFirmwareInfoBucket(artifactName); - if(data != null) { - long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME); - LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); - GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME, data, configVersion); - - LOGGER.info("Writing to Firestore "); - GcpFireStore.addDocument(deviceId, GcpBucketHandler.getFirmwareInfoBucket_Map(artifactName)); - } - else LOGGER.error(artifactName+" not found in bucket for device "+deviceId); - } - - - private static void sendUpate(String deviceId, UpdateStatus updateStatus) { - AbstractSimulatedDevice device = mapDevices.get(deviceId); - UpdaterCallback callback = mapCallbacks.get(deviceId); - if(device != null && callback != null) { - device.setUpdateStatus(updateStatus); - callback.sendFeedback(device); - } else { - if(device == null) { - LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); - } - if(callback == null) { - LOGGER.error("Map didnt find callback on "+ updateStatus.getResponseStatus().toString()); - } - } - } - - public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback callback, - List modules, EventTopic actionType) { - - LOGGER.info("Update device with eventTopic: "+actionType); - - //if the device is still updating, wait until it is finished - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL - || actionType == EventTopic.DOWNLOAD) { - if(!mapDevices.containsKey(device.getId())) { - LOGGER.info("ActionType "+actionType); - - sendAsyncFwUpgradeList(device.getId(), modules); - - mapCallbacks.put(device.getId(), callback); - mapDevices.put(device.getId(), device); - } else { - LOGGER.error("Device ID already exist on actionType: "+actionType); - sendUpate(device.getId(), new UpdateStatus(ResponseStatus.RUNNING, "Payload Reached")); - } - } - else { - LOGGER.error("Unsupported actionType: "+actionType); - sendUpate(device.getId(), new UpdateStatus(ResponseStatus.ERROR, "Unsupported Action")); - - } - } -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java deleted file mode 100644 index c47598d..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.eclipse.hawkbit.google.gcp; - -import java.io.IOException; -import java.io.InputStream; -import java.security.KeyManagementException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; - -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.ssl.SSLContextBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.util.StringUtils; - -import com.google.common.base.Charsets; -import com.google.common.io.ByteStreams; - -//TODO: -/** - * Use the Hawkbit Management Client to download - * software modules and put them on the bucket - * */ -public class HawkBitSoftwareModuleHandler { - - private static final Logger LOGGER = LoggerFactory.getLogger(HawkBitSoftwareModuleHandler.class); - - - private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { - final SSLContextBuilder builder = SSLContextBuilder.create(); - builder.loadTrustMaterial(null, (chain, authType) -> true); - final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); - return HttpClients.custom().setSSLSocketFactory(sslsf).build(); - } - - - - protected static String downloadFileData(final String url,final String targetToken) - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { - - final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts(); - final HttpGet request = new HttpGet(url); - - if (!StringUtils.isEmpty(targetToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); - } - - try (final CloseableHttpResponse response = httpclient.execute(request)) { - - if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { - String message = "download "+url+" failed (" + response.getStatusLine().getStatusCode()+ ")"; - LOGGER.error(message); - return null; - } - String payload = null; - try { - InputStream is = response.getEntity().getContent(); - payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); - System.out.println("Payload ==========> "+payload); - } catch (Exception e) { - e.printStackTrace(); - } - return payload; - } - } - - -} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java deleted file mode 100644 index c98c0c2..0000000 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2017 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.eclipse.hawkbit.google.gcp; - -import com.google.api.client.auth.oauth2.Credential; -import com.google.api.client.http.HttpBackOffIOExceptionHandler; -import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; -import com.google.api.client.http.HttpRequest; -import com.google.api.client.http.HttpRequestInitializer; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpUnsuccessfulResponseHandler; -import com.google.api.client.util.ExponentialBackOff; -import com.google.api.client.util.Sleeper; -import com.google.common.base.Preconditions; - -import java.io.IOException; -import java.util.logging.Logger; - -/** - * RetryHttpInitializerWrapper will automatically retry upon RPC failures, preserving the - * auto-refresh behavior of the Google Credentials. - */ -public class RetryHttpInitializerWrapper implements HttpRequestInitializer { - - /** A private logger. */ - private static final Logger LOG = Logger.getLogger(RetryHttpInitializerWrapper.class.getName()); - - /** One minutes in milliseconds. */ - private static final int ONE_MINUTE_MILLIS = 60 * 1000; - - /** - * Intercepts the request for filling in the "Authorization" header field, as well as recovering - * from certain unsuccessful error codes wherein the Credential must refresh its token for a - * retry. - */ - private final Credential wrappedCredential; - - /** A sleeper; you can replace it with a mock in your test. */ - private final Sleeper sleeper; - - /** - * A constructor. - * - * @param wrappedCredential Credential which will be wrapped and used for providing auth header. - */ - public RetryHttpInitializerWrapper(final Credential wrappedCredential) { - this(wrappedCredential, Sleeper.DEFAULT); - } - - /** - * A protected constructor only for testing. - * - * @param wrappedCredential Credential which will be wrapped and used for providing auth header. - * @param sleeper Sleeper for easy testing. - */ - RetryHttpInitializerWrapper(final Credential wrappedCredential, final Sleeper sleeper) { - this.wrappedCredential = Preconditions.checkNotNull(wrappedCredential); - this.sleeper = sleeper; - } - - /** Initializes the given request. */ - @Override - public final void initialize(final HttpRequest request) { - request.setReadTimeout(2 * ONE_MINUTE_MILLIS); // 2 minutes read timeout - final HttpUnsuccessfulResponseHandler backoffHandler = - new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff()).setSleeper(sleeper); - request.setInterceptor(wrappedCredential); - request.setUnsuccessfulResponseHandler( - new HttpUnsuccessfulResponseHandler() { - @Override - public boolean handleResponse( - final HttpRequest request, final HttpResponse response, final boolean supportsRetry) - throws IOException { - if (wrappedCredential.handleResponse(request, response, supportsRetry)) { - // If credential decides it can handle it, the return code or message indicated - // something specific to authentication, and no backoff is desired. - return true; - } else if (backoffHandler.handleResponse(request, response, supportsRetry)) { - // Otherwise, we defer to the judgment of our internal backoff handler. - LOG.info("Retrying " + request.getUrl().toString()); - return true; - } else { - return false; - } - } - }); - request.setIOExceptionHandler( - new HttpBackOffIOExceptionHandler(new ExponentialBackOff()).setSleeper(sleeper)); - } -} \ No newline at end of file diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java new file mode 100644 index 0000000..6ee6204 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Spring security configuration to grant access for all incomming requests. + */ +@Configuration +public class AllowAllWebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(final HttpSecurity httpSec) throws Exception { + httpSec.csrf().disable().authorizeRequests().antMatchers("/**").permitAll().anyRequest().authenticated(); + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java index 2beee8c..f82990f 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java @@ -75,7 +75,6 @@ public DDISimulatedDevice(final String id, final String tenant, final int pollDe this.controllerResource = controllerResource; this.deviceUpdater = deviceUpdater; this.gatewayToken = gatewayToken; - LOGGER.info("Id: %s, tenant: %s \n", id, tenant); } @Override diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java index 7092ae7..7764295 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -9,8 +9,6 @@ package org.eclipse.hawkbit.simulator; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; /** @@ -19,9 +17,6 @@ public class DMFSimulatedDevice extends AbstractSimulatedDevice { private final DmfSenderService spSenderService; - private static final Logger LOGGER = LoggerFactory.getLogger(DMFSimulatedDevice.class); - - /** * @param id * the ID of the device @@ -32,19 +27,15 @@ public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderS final int pollDelaySec) { super(id, tenant, Protocol.DMF_AMQP, pollDelaySec); this.spSenderService = spSenderService; - LOGGER.info(" Id: {}, tenant: {} \n", id, tenant); } @Override public void poll() { - LOGGER.info("handling event of tenant "+super.getTenant()); - spSenderService.createOrUpdateThing(super.getTenant(), super.getId()); } @Override public void updateAttribute(final String mode, final String key, final String value) { - System.out.println("[DMFSimulatedDevice] handling updateAttribute"); final DmfUpdateMode updateMode; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java index 16e6a11..1f2148c 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java @@ -11,7 +11,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import org.eclipse.hawkbit.google.gcp.GcpProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -53,7 +52,6 @@ TaskScheduler taskScheduler() { // Exception squid:S2095 - Spring boot standard behavior @SuppressWarnings({ "squid:S2095" }) public static void main(final String[] args) { - GcpProperties.parseCLI(args); SpringApplication.run(DeviceSimulator.class, args); } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java index 3c2110e..ee09771 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -11,7 +11,6 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; import java.security.DigestOutputStream; import java.security.KeyManagementException; @@ -33,9 +32,6 @@ import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; -import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; -import org.eclipse.hawkbit.google.gcp.GcpOTA; -import org.eclipse.hawkbit.google.gcp.GcpSubscriber; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; import org.slf4j.Logger; @@ -47,7 +43,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; -import com.google.common.base.Charsets; +import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; /** @@ -56,348 +52,306 @@ @Service public class DeviceSimulatorUpdater { - private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); - - @Autowired - private ScheduledExecutorService threadPool; - - @Autowired - private SimulatedDeviceFactory deviceFactory; - - @Autowired - private DeviceSimulatorRepository repository; - - /** - * Starting an simulated update process of an simulated device. - * - * @param tenant - * the tenant of the device - * @param id - * the ID of the simulated device - * @param modules - * the software module version from the hawkbit update server - * @param targetSecurityToken - * the target security token for download authentication - * @param gatewayToken - * as alternative to target token the gateway token for download - * authentication - * @param callback - * the callback which gets called when the simulated update - * process has been finished - * @param actionType - * indicating whether to download and install or skip - * installation due to maintenance window. - */ - public void startUpdate(final String tenant, final String id, final List modules, - final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, - final EventTopic actionType) { - - AbstractSimulatedDevice device = repository.get(tenant, id); - - // plug and play - non existing device will be auto created - if (device == null) { - device = repository - .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); - } - - device.setTargetSecurityToken(targetSecurityToken); - - if(GcpOTA.FW_VIA_COMMAND) { - threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, - TimeUnit.MILLISECONDS); - } - else //use the subscription on state - { - GcpSubscriber.updateDevice(device, callback, modules, actionType); - } - } - - private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; - - private final AbstractSimulatedDevice device; - private final UpdaterCallback callback; - private final List modules; - private final String gatewayToken; - private static String payload = ""; - - private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, - final List modules, final EventTopic actionType, final String gatewayToken) { - this.device = device; - this.callback = callback; - this.modules = modules; - this.actionType = actionType; - this.gatewayToken = gatewayToken; - } - - @Override - public void run() { - - - if(GcpOTA.FW_VIA_COMMAND) - { - device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); - callback.sendFeedback(device); - - if (!CollectionUtils.isEmpty(modules)) { - device.setUpdateStatus(simulateDownloads()); - callback.sendFeedback(device); - if (isErrorResponse(device.getUpdateStatus())) { - device.clean(); - return; - } - } - - if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { - LOGGER.info("Download & Install"); - device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); - callback.sendFeedback(device); - device.clean(); - } - } - } - - - - private void syncDownloadGCP(String deviceId, String data) - { - LOGGER.info("Attempting download to the device \n"+data); - GcpIoTHandler.sendCommand(device.getId(), GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, - GcpOTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); - } - - private UpdateStatus simulateDownloads() { - - device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); - callback.sendFeedback(device); - - final List status = new ArrayList<>(); - - LOGGER.info("Simulate downloads for "+device.getId()); - - - modules.forEach(module -> { - module.getArtifacts().forEach( - artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact)); - }); - - syncDownloadGCP(device.getId(), payload); - - final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); - result.getStatusMessages().add("Simulator: Download complete!"); - status.forEach(download -> { - result.getStatusMessages().addAll(download.getStatusMessages()); - if (isErrorResponse(download)) { - result.setResponseStatus(ResponseStatus.ERROR); - } - }); - - LOGGER.info("Download simulations complete for {}", device.getId()); - - return result; - } - - private static boolean isErrorResponse(final UpdateStatus status) { - if (status == null) { - return false; - } - - return ResponseStatus.ERROR.equals(status.getResponseStatus()); - } - - private static void handleArtifact(final String targetToken, final String gatewayToken, - final List status, final DmfArtifact artifact) { - - LOGGER.info(" handleArtifact "+artifact.getSize()); - if (artifact.getUrls().containsKey("HTTPS")) { - status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())); - } else if (artifact.getUrls().containsKey("HTTP")) { - status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, - artifact.getHashes().getSha1(), artifact.getSize())); - } - } - - private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, - final String sha1Hash, final long size) { - LOGGER.info(" downloadingUrl "+url); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, - hideTokenDetails(targetToken), sha1Hash, size); - } - - try { - return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); - } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { - LOGGER.error("Failed to download " + url, e); - return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); - } - - } - - private static 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.isEmpty(targetToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); - } else if (!StringUtils.isEmpty(gatewayToken)) { - request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); - } - - final String sha1HashResult; - try (final CloseableHttpResponse response = httpclient.execute(request)) { - - if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { - final String message = wrongStatusCode(url, response); - return new UpdateStatus(ResponseStatus.ERROR, message); - } - - if (response.getEntity().getContentLength() != size) { - final String message = wrongContentLength(url, size, response); - return new UpdateStatus(ResponseStatus.ERROR, message); - } - - // Exception squid:S2070 - not used for hashing sensitive - // data - @SuppressWarnings("squid:S2070") - final MessageDigest md = MessageDigest.getInstance("SHA-1"); - - //overallread = getOverallRead(response, md); - payload = getPayload(response); - - // if (overallread != size) { - // final String message = incompleteRead(url, size, overallread); - // return new UpdateStatus(ResponseStatus.ERROR, message); - // } - // - // sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); - } - - // if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { - // final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); - // return new UpdateStatus(ResponseStatus.ERROR, message); - // } - - final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; - LOGGER.debug(message); - return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 getPayload(final CloseableHttpResponse response) - throws IOException { - try { - InputStream is = response.getEntity().getContent(); - payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); - LOGGER.info("Payload: "+payload); - } catch (Exception e) { - e.printStackTrace(); - } - return payload; - } - - 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, - final String sha1HashResult) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " - + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; - LOGGER.error(message); - return message; - } - - private static String incompleteRead(final String url, final long size, final long overallread) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size - + BUT_GOT_LOG_MESSAGE + overallread + ")"; - LOGGER.error(message); - return message; - } - - private static String wrongContentLength(final String url, final long size, - final CloseableHttpResponse response) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size - + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; - LOGGER.error(message); - return message; - } - - private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { - final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() - + ")"; - LOGGER.error(message); - return message; - } - - private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() - throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { - final SSLContextBuilder builder = SSLContextBuilder.create(); - builder.loadTrustMaterial(null, (chain, authType) -> true); - final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); - return HttpClients.custom().setSSLSocketFactory(sslsf).build(); - } - } - - /** - * Callback interface which is called when the simulated update process has - * been finished and the caller of starting the simulated update process can - * send the result back to the hawkBit update server. - */ - @FunctionalInterface - public interface UpdaterCallback { - /** - * Callback method to indicate that the simulated update process has - * been finished. - * - * @param device - * the device which has been updated - */ - void sendFeedback(AbstractSimulatedDevice device); - } + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); + + @Autowired + private ScheduledExecutorService threadPool; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Starting an simulated update process of an simulated device. + * + * @param tenant + * the tenant of the device + * @param id + * the ID of the simulated device + * @param modules + * the software module version from the hawkbit update server + * @param targetSecurityToken + * the target security token for download authentication + * @param gatewayToken + * as alternative to target token the gateway token for download + * authentication + * @param callback + * the callback which gets called when the simulated update + * process has been finished + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void startUpdate(final String tenant, final String id, final List modules, + final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, + final EventTopic actionType) { + + AbstractSimulatedDevice device = repository.get(tenant, id); + + // plug and play - non existing device will be auto created + if (device == null) { + device = repository + .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); + } + + device.setTargetSecurityToken(targetSecurityToken); + + threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, + TimeUnit.MILLISECONDS); + } + + private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; + + private final AbstractSimulatedDevice device; + private final UpdaterCallback callback; + private final List modules; + private final String gatewayToken; + + private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, + final List modules, final EventTopic actionType, final String gatewayToken) { + this.device = device; + this.callback = callback; + this.modules = modules; + this.actionType = actionType; + this.gatewayToken = gatewayToken; + } + + @Override + public void run() { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); + callback.sendFeedback(device); + + if (!CollectionUtils.isEmpty(modules)) { + device.setUpdateStatus(simulateDownloads()); + callback.sendFeedback(device); + if (isErrorResponse(device.getUpdateStatus())) { + device.clean(); + return; + } + } + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); + callback.sendFeedback(device); + device.clean(); + } + } + + private UpdateStatus simulateDownloads() { + + device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); + callback.sendFeedback(device); + + final List status = new ArrayList<>(); + + LOGGER.info("Simulate downloads for {}", device.getId()); + + modules.forEach(module -> module.getArtifacts().forEach( + artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact))); + + final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); + result.getStatusMessages().add("Simulator: Download complete!"); + status.forEach(download -> { + result.getStatusMessages().addAll(download.getStatusMessages()); + if (isErrorResponse(download)) { + result.setResponseStatus(ResponseStatus.ERROR); + } + }); + + LOGGER.info("Download simulations complete for {}", device.getId()); + + return result; + } + + private static boolean isErrorResponse(final UpdateStatus status) { + if (status == null) { + return false; + } + + return ResponseStatus.ERROR.equals(status.getResponseStatus()); + } + + private static void handleArtifact(final String targetToken, final String gatewayToken, + final List status, final DmfArtifact artifact) { + + if (artifact.getUrls().containsKey("HTTPS")) { + status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } else if (artifact.getUrls().containsKey("HTTP")) { + status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } + } + + private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, + final String sha1Hash, final long size) { + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, + hideTokenDetails(targetToken), sha1Hash, size); + } + + try { + return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); + } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + LOGGER.error("Failed to download " + url, e); + return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); + } + + } + + private static 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.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } else if (!StringUtils.isEmpty(gatewayToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); + } + + final String sha1HashResult; + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + final String message = wrongStatusCode(url, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + if (response.getEntity().getContentLength() != size) { + final String message = wrongContentLength(url, size, response); + return new UpdateStatus(ResponseStatus.ERROR, 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(ResponseStatus.ERROR, message); + } + + sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); + } + + if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { + final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + final String message = "Downloaded " + url + " (" + overallread + " bytes)"; + LOGGER.debug(message); + return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, + final String sha1HashResult) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " + + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; + LOGGER.error(message); + return message; + } + + private static String incompleteRead(final String url, final long size, final long overallread) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size + + BUT_GOT_LOG_MESSAGE + overallread + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongContentLength(final String url, final long size, + final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size + + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() + + ")"; + LOGGER.error(message); + return message; + } + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + } + + /** + * Callback interface which is called when the simulated update process has + * been finished and the caller of starting the simulated update process can + * send the result back to the hawkBit update server. + */ + @FunctionalInterface + public interface UpdaterCallback { + /** + * Callback method to indicate that the simulated update process has + * been finished. + * + * @param device + * the device which has been updated + */ + void sendFeedback(AbstractSimulatedDevice device); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index efe8f45..e6024a4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.simulator; - import java.net.URL; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -19,7 +18,7 @@ import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder; +import org.springframework.cloud.openfeign.support.ResponseEntityDecoder; import org.springframework.hateoas.hal.Jackson2HalModule; import org.springframework.stereotype.Service; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index e1c9ab3..f4eb195 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -7,271 +7,219 @@ * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.hawkbit.simulator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; + import java.net.MalformedURLException; import java.net.URL; -import java.security.GeneralSecurityException; -import java.util.List; +import java.util.Collections; +import java.util.Optional; -import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; -import org.eclipse.hawkbit.google.gcp.GcpOTA; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; +import org.eclipse.hawkbit.simulator.amqp.SimulatedUpdate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.google.api.services.cloudiot.v1.model.Device; -import com.google.gson.Gson; - /** * REST endpoint for controlling the device simulator. */ @RestController public class SimulationController { - private final DeviceSimulatorRepository repository; - - private final SimulatedDeviceFactory deviceFactory; - - private final AmqpProperties amqpProperties; - - private final SimulationProperties simulationProperties; - - private static final Logger LOGGER = LoggerFactory.getLogger(SimulationController.class); - - Gson gson = new Gson(); - - @Autowired - public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { - this.repository = repository; - this.deviceFactory = deviceFactory; - this.amqpProperties = amqpProperties; - this.simulationProperties = simulationProperties; - } - - - /** - * The start resource to start a device creation. - * - * @param name - * the name prefix of the generated device naming - * @param amount - * the amount of devices to be created - * @param tenant - * the tenant to create the device to - * @param api - * the api-protocol to be used either {@code dmf} or {@code ddi} - * @param endpoint - * the URL endpoint to be used of the hawkbit-update-server for - * DDI devices - * @param pollDelay - * number of delay in seconds to delay polling of DDI - * devices - * @param gatewayToken - * the hawkbit-update-server gatewaytoken in case authentication - * is enforced in hawkbit - * @return a response string that devices has been created - * @throws MalformedURLException - */ - @GetMapping("/gcp") - ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, - @RequestParam(value = "amount", defaultValue = "1") final int amount, - @RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "api", defaultValue = "dmf") final String api, - @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, - @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, - @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) - throws MalformedURLException { - - final Protocol protocol; - switch (api.toLowerCase()) { - case "dmf": - protocol = Protocol.DMF_AMQP; - break; - case "ddi": - protocol = Protocol.DDI_HTTP; - break; - default: - return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); - } - - if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { - return ResponseEntity.badRequest() - .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" - + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); - } - - - try { - List allDevices_gcp = GcpIoTHandler.listDevices(GcpOTA.PROJECT_ID, - GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME); - for(Device gcp_device : allDevices_gcp) - { - LOGGER.info("GCP Device: "+gcp_device.getId()); - repository.add(deviceFactory. - createSimulatedDevice(gcp_device.getId(), - simulationProperties.getDefaultTenant(), - protocol, pollDelay, - new URL(endpoint), gatewayToken)); - } - - } catch (GeneralSecurityException | IOException e) { - e.printStackTrace(); - - } - return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); - } - - - - /** - * The start resource to start a device creation. - * - * @param name - * the name prefix of the generated device naming - * @param amount - * the amount of devices to be created - * @param tenant - * the tenant to create the device to - * @param api - * the api-protocol to be used either {@code dmf} or {@code ddi} - * @param endpoint - * the URL endpoint to be used of the hawkbit-update-server for - * DDI devices - * @param pollDelay - * number of delay in seconds to delay polling of DDI - * devices - * @param gatewayToken - * the hawkbit-update-server gatewaytoken in case authentication - * is enforced in hawkbit - * @return a response string that devices has been created - * @throws MalformedURLException - */ - @GetMapping("/start") - ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, - @RequestParam(value = "amount", defaultValue = "20") final int amount, - @RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "api", defaultValue = "dmf") final String api, - @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, - @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, - @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) - throws MalformedURLException { - - final Protocol protocol; - switch (api.toLowerCase()) { - case "dmf": - protocol = Protocol.DMF_AMQP; - break; - case "ddi": - protocol = Protocol.DDI_HTTP; - break; - default: - return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); - } - - if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { - return ResponseEntity.badRequest() - .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" - + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); - } - - for (int i = 0; i < amount; i++) { - final String deviceId = name + i; - repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, - (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, - new URL(endpoint), gatewayToken)); - } - - return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); - } - - /** - * Update an attribute of a device. - * - * NOTE: This represents not the expected client behaviour for DDI, since a - * DDI client shall only update its attributes if requested by hawkBit. - * - * @param tenant - * The tenant the device belongs to - * @param controllerId - * The controller id of the device that should be updated. - * @param mode - * Update mode ('merge', 'replace', or 'remove') - * @param key - * Key of the attribute to be updated - * @param value - * Value of the attribute - * @return HTTP OK (200) if the update has been triggered. - */ - @GetMapping("/attributes") - ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "controllerid") final String controllerId, - @RequestParam(value = "mode", defaultValue = "merge") final String mode, - @RequestParam(value = "key") final String key, - @RequestParam(value = "value", required = false) final String value) { - - final AbstractSimulatedDevice simulatedDevice = repository - .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); - - if (simulatedDevice == null) { - return ResponseEntity.notFound().build(); - } - - simulatedDevice.updateAttribute(mode, key, value); - - return ResponseEntity.ok("Update triggered"); - } - - /** - * Remove a simulated device - * - * @param tenant - * The tenant the device belongs to - * @param controllerId - * The controller id of the device that should be removed. - * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) - * if not found. - */ - @GetMapping("/remove") - ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, - @RequestParam(value = "controllerid") final String controllerId) { - - final AbstractSimulatedDevice controller = repository - .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); - - if (controller == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok("Deleted"); - } - - - @GetMapping("/hi") - ResponseEntity hi() { - return ResponseEntity.ok("hi"); - } - - - /** - * Reset the device simulator by removing all simulated devices - * - * @return A response string that the simulator has been reset - */ - @GetMapping("/reset") - ResponseEntity reset() { - - repository.clear(); - - return ResponseEntity.ok("All simulated devices have been removed."); - } - - private boolean isDmfDisabled() { - return !amqpProperties.isEnabled(); - } + private final DeviceSimulatorRepository repository; + + private final SimulatedDeviceFactory deviceFactory; + + private final AmqpProperties amqpProperties; + + private final SimulationProperties simulationProperties; + + private Optional spSenderService = Optional.empty(); + + @Autowired + public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, + final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { + this.repository = repository; + this.deviceFactory = deviceFactory; + this.amqpProperties = amqpProperties; + this.simulationProperties = simulationProperties; + } + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/start") + ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "20") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return createAmqpDisabledResponse(); + } + + for (int i = 0; i < amount; i++) { + final String deviceId = name + i; + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + private ResponseEntity createAmqpDisabledResponse() { + return ResponseEntity.badRequest().body( + "The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + /** + * Update an attribute of a device. + * + * NOTE: This represents not the expected client behaviour for DDI, since a + * DDI client shall only update its attributes if requested by hawkBit. + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be updated. + * @param mode + * Update mode ('merge', 'replace', or 'remove') + * @param key + * Key of the attribute to be updated + * @param value + * Value of the attribute + * @return HTTP OK (200) if the update has been triggered. + */ + @GetMapping("/attributes") + ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId, + @RequestParam(value = "mode", defaultValue = "merge") final String mode, + @RequestParam(value = "key") final String key, + @RequestParam(value = "value", required = false) final String value) { + + final AbstractSimulatedDevice simulatedDevice = repository + .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (simulatedDevice == null) { + return ResponseEntity.notFound().build(); + } + + simulatedDevice.updateAttribute(mode, key, value); + + return ResponseEntity.ok("Update triggered"); + } + + /** + * Remove a simulated device + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be removed. + * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) + * if not found. + */ + @GetMapping("/remove") + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId) { + + final AbstractSimulatedDevice controller = repository + .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (controller == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok("Deleted"); + } + + /** + * Reset the device simulator by removing all simulated devices + * + * @return A response string that the simulator has been reset + */ + @GetMapping("/reset") + ResponseEntity reset() { + + repository.clear(); + + return ResponseEntity.ok("All simulated devices have been removed."); + } + + /** + * Report action as FINISHED Sends an UpdateActionStatus event with value + * FINISHED + * + * @return A response string that the action_finished event was sent + */ + @GetMapping("/finishAction") + ResponseEntity finishAction(@RequestParam(value = "actionId") final long actionId, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerId") final String controllerId) { + + if (!spSenderService.isPresent()) { + return createAmqpDisabledResponse(); + } + + final String theTenant = tenant != null && !tenant.isEmpty() ? tenant : simulationProperties.getDefaultTenant(); + final AbstractSimulatedDevice device = repository.get(theTenant, controllerId); + + if (device == null) { + return ResponseEntity.notFound().build(); + } + + spSenderService.get().finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + Collections.singletonList("Simulation Finished.")); + + return ResponseEntity.ok(String.format("Action with id: [%d] reported as FINISHED", actionId)); + } + + @Autowired(required = false) + public void setSpSenderService(final DmfSenderService spSenderService) { + this.spSenderService = Optional.of(spSenderService); + } + + private boolean isDmfDisabled() { + return !amqpProperties.isEnabled(); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java index a30833e..3d34a36 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java @@ -114,7 +114,7 @@ public static class Autostart { /** * Amount of simulated devices. */ - private int amount = 0; + private int amount = 20; /** * Tenant name for the simulation. diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java index 478cd3f..58a173d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -11,8 +11,6 @@ import java.net.MalformedURLException; import java.net.URL; -import org.eclipse.hawkbit.google.gcp.GcpFireStore; -import org.eclipse.hawkbit.google.gcp.GcpSubscriber; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,50 +27,40 @@ @Component @ConditionalOnProperty(prefix = "hawkbit.device.simulator", name = "autostart", matchIfMissing = true) public class SimulatorStartup implements ApplicationListener { - private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); + private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); - @Autowired - private SimulationProperties simulationProperties; + @Autowired + private SimulationProperties simulationProperties; - @Autowired - private DeviceSimulatorRepository repository; + @Autowired + private DeviceSimulatorRepository repository; - @Autowired - private SimulatedDeviceFactory deviceFactory; + @Autowired + private SimulatedDeviceFactory deviceFactory; - @Autowired - private AmqpProperties amqpProperties; + @Autowired + private AmqpProperties amqpProperties; - @Override - public void onApplicationEvent(final ApplicationReadyEvent event) { - System.out.println("AutoStarting application ..."); - LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); - - - amqpProperties.setEnabled(true); - - LOGGER.info("Init Firestore ... "); - GcpFireStore.init(); - LOGGER.info("Init Subscriber ... "); - GcpSubscriber.init(); + @Override + public void onApplicationEvent(final ApplicationReadyEvent event) { + LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); - //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket - simulationProperties.getAutostarts().forEach(autostart -> { - LOGGER.info("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); - for (int i = 0; i < autostart.getAmount(); i++) { - final String deviceId = autostart.getName() + i; - try { - if (amqpProperties.isEnabled()) { - repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, - autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), - new URL(autostart.getEndpoint()), autostart.getGatewayToken())); - } + simulationProperties.getAutostarts().forEach(autostart -> { + LOGGER.debug("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); + for (int i = 0; i < autostart.getAmount(); i++) { + final String deviceId = autostart.getName() + i; + try { + if (amqpProperties.isEnabled()) { + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), + new URL(autostart.getEndpoint()), autostart.getGatewayToken())); + } - } catch (final MalformedURLException e) { - LOGGER.error("Creation of simulated device at startup failed.", e); - } - } - }); - } + } catch (final MalformedURLException e) { + LOGGER.error("Creation of simulated device at startup failed.", e); + } + } + }); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java index 6088aa6..0687b33 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java @@ -44,12 +44,12 @@ public UpdateStatus(final ResponseStatus responseStatus, final String message) { } /** - * Constructor including status message. + * Constructor including status messages. * * @param responseStatus * of the update - * @param messages - * of the update status + * @param statusMessages + * list of status messages */ public UpdateStatus(final ResponseStatus responseStatus, final List statusMessages) { this(responseStatus); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java index d235af0..3e8bbe6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java @@ -14,23 +14,19 @@ import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; import org.eclipse.hawkbit.simulator.SimulationProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.FanoutExchange; import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.retry.backoff.ExponentialBackOffPolicy; -import org.springframework.retry.support.RetryTemplate; import com.google.common.collect.Maps; @@ -43,33 +39,6 @@ @ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "enabled") public class AmqpConfiguration { - private static final Logger LOGGER = LoggerFactory.getLogger(AmqpConfiguration.class); - - @Autowired - private AmqpProperties amqpProperties; - - @Bean - RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { - final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); - rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); - - final RetryTemplate retryTemplate = new RetryTemplate(); - retryTemplate.setBackOffPolicy(new ExponentialBackOffPolicy()); - rabbitTemplate.setRetryTemplate(retryTemplate); - - rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { - if (ack) { - LOGGER.debug("Message with correlation ID {} confirmed by broker.", correlationData.getId()); - } else { - LOGGER.error("Broker is unable to handle message with correlation ID {} : {}", correlationData.getId(), - cause); - } - - }); - - return rabbitTemplate; - } - @Bean DmfReceiverService dmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, @@ -83,6 +52,21 @@ DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final Amq return new DmfSenderService(rabbitTemplate, amqpProperties, simulationProperties); } + @Bean + public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { + final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + + // It is necessary to define rabbitTemplate as a Bean and set + // Jackson2JsonMessageConverter explicitly here in order to convert only + // OUTCOMING messages to json. In case of INCOMING messages, + // Jackson2JsonMessageConverter can not handle messages with NULL + // payload (e.g. REQUEST_ATTRIBUTES_UPDATE), so the + // SimpleMessageConverter is used instead per default. + rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); + + return rabbitTemplate; + } + /** * Creates the receiver queue from update server for receiving message from * update server. @@ -90,11 +74,16 @@ DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final Amq * @return the queue */ @Bean - Queue receiverConnectorQueueFromHawkBit() { - final Map arguments = getTTLMaxArgs(); - + Queue receiverConnectorQueueFromHawkBit(final AmqpProperties amqpProperties) { return QueueBuilder.nonDurable(amqpProperties.getReceiverConnectorQueueFromSp()).autoDelete() - .withArguments(arguments).build(); + .withArguments(getTTLMaxArgs()).build(); + } + + private static Map getTTLMaxArgs() { + final Map args = Maps.newHashMapWithExpectedSize(2); + args.put("x-message-ttl", Duration.ofDays(1).toMillis()); + args.put("x-max-length", 100_000); + return args; } /** @@ -103,7 +92,7 @@ Queue receiverConnectorQueueFromHawkBit() { * @return the exchange */ @Bean - FanoutExchange exchangeQueueToConnector() { + FanoutExchange exchangeQueueToConnector(final AmqpProperties amqpProperties) { return new FanoutExchange(amqpProperties.getSenderForSpExchange(), false, true); } @@ -115,15 +104,18 @@ FanoutExchange exchangeQueueToConnector() { * @return the binding and create the queue and exchange */ @Bean - Binding bindReceiverQueueToSpExchange() { - return BindingBuilder.bind(receiverConnectorQueueFromHawkBit()).to(exchangeQueueToConnector()); + Binding bindReceiverQueueToSpExchange(final AmqpProperties amqpProperties) { + return BindingBuilder.bind(receiverConnectorQueueFromHawkBit(amqpProperties)) + .to(exchangeQueueToConnector(amqpProperties)); } - private static Map getTTLMaxArgs() { - final Map args = Maps.newHashMapWithExpectedSize(2); - args.put("x-message-ttl", Duration.ofDays(1).toMillis()); - args.put("x-max-length", 100_000); - return args; - } + @Configuration + @ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "customVhost") + protected static class CachingConnectionFactoryInitializer { + CachingConnectionFactoryInitializer(final CachingConnectionFactory connectionFactory, + final AmqpProperties amqpProperties) { + connectionFactory.setVirtualHost(amqpProperties.getCustomVhost()); + } + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java index 9bafd4f..1a0c246 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java @@ -51,6 +51,8 @@ public class AmqpProperties { */ private int deadLetterTtl = 60_000; + private String customVhost; + public boolean isCheckDmfHealth() { return checkDmfHealth; } @@ -90,4 +92,12 @@ public boolean isEnabled() { public void setEnabled(final boolean enabled) { this.enabled = enabled; } + + public String getCustomVhost() { + return customVhost; + } + + public void setCustomVhost(final String customVhost) { + this.customVhost = customVhost; + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 64859ff..0fa117d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -8,11 +8,9 @@ */ package org.eclipse.hawkbit.simulator.amqp; -import java.io.FileNotFoundException; -import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -23,7 +21,6 @@ import org.eclipse.hawkbit.dmf.amqp.api.MessageType; import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; -import org.eclipse.hawkbit.google.gcp.GcpBucketHandler; import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; @@ -37,281 +34,217 @@ import org.springframework.messaging.handler.annotation.Header; import org.springframework.scheduling.annotation.Scheduled; -import com.google.gson.Gson; -import com.google.gson.JsonObject; - - /** * Handle all incoming Messages from hawkBit update server. * */ public class DmfReceiverService extends MessageService { - private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); - - private final DmfSenderService spSenderService; - - private final DeviceSimulatorUpdater deviceUpdater; - - private final DeviceSimulatorRepository repository; - - private final Set openPings = new HashSet(); - - private Gson gson = new Gson(); - - /** - * Constructor. - * - * @param rabbitTemplate - * for sending messages - * @param amqpProperties - * for amqp configuration - * @param spSenderService - * to send messages - * @param deviceUpdater - * simulator service for updates - * @param repository - * to manage simulated devices - */ - DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, - final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, - final DeviceSimulatorRepository repository) { - super(rabbitTemplate, amqpProperties); - this.spSenderService = spSenderService; - this.deviceUpdater = deviceUpdater; - this.repository = repository; - LOGGER.info("Init"); - } - - /** - * Method to validate if content type is set in the message properties. - * - * @param message - * the message to get validated - */ - private void checkContentTypeJson(final Message message) { - LOGGER.info(" checkJson "+message.getBody()); - if (message.getBody().length == 0) { - return; - } - final MessageProperties messageProperties = message.getMessageProperties(); - final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); - if (null != headerContentType) { - messageProperties.setContentType(headerContentType); - } - final String contentType = messageProperties.getContentType(); - if (contentType != null && contentType.contains("json")) { - return; - } - throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); - } - - /** - * Handle the incoming Message from Queue with the property - * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). - * - * @param message - * the incoming message - * @param type - * the action type - * @param thingId - * the thing id in message header - * @param tenant - * the device belongs to - */ - @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") - public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, - @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, - @Header(MessageHeaderKey.TENANT) final String tenant) { - - try { - - final MessageType messageType = MessageType.valueOf(type); - - LOGGER.info(" Message received :\n"+message.toString()); - - if (MessageType.EVENT.equals(messageType)) { - checkContentTypeJson(message); - handleEventMessage(message, thingId); - return; - } - - if (MessageType.THING_DELETED.equals(messageType)) { - checkContentTypeJson(message); - repository.remove(tenant, thingId); - return; - } - - if (MessageType.PING_RESPONSE.equals(messageType)) { - final String correlationId = message.getMessageProperties().getCorrelationIdString(); - if (!openPings.remove(correlationId)) { - LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); - } - - if (LOGGER.isDebugEnabled()) { - LOGGER.info("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, - correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); - } - - return; - } - - LOGGER.info("No valid message type property."); - - } catch (Exception e) { - e.printStackTrace(); - } - } - - @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) - void checkDmfHealth() { - LOGGER.info("Message CheckDmfHealth "); - - if (!amqpProperties.isCheckDmfHealth()) { - return; - } - - if (openPings.size() > 5) { - LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); - } else { - LOGGER.debug("Currently {} open pings", openPings.size()); - } - - repository.getTenants().forEach(tenant -> { - final String correlationId = UUID.randomUUID().toString(); - spSenderService.ping(tenant, correlationId); - openPings.add(correlationId); - LOGGER.debug("Ping tenant {%s} with correlationId {%s}", tenant, correlationId); - }); - } - - private void handleEventMessage(final Message message, final String thingId) { - - LOGGER.info("handling event "+thingId); - - final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); - if (eventHeader == null) { - logAndThrowMessageError(message, "Event Topic is not set"); - } - - // Exception squid:S2259 - Checked before - @SuppressWarnings({ "squid:S2259" }) - final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); - LOGGER.info("EventTopic "+eventTopic); - - switch (eventTopic) { - case DOWNLOAD_AND_INSTALL: - case DOWNLOAD: - LOGGER.info("Download with message:\n"+message.toString()); - handleUpdateProcess(message, thingId, eventTopic); - break; - case CANCEL_DOWNLOAD: - LOGGER.info("Cancel Download with message:\n"+message.toString()); - handleCancelDownloadAction(message, thingId); - break; - case REQUEST_ATTRIBUTES_UPDATE: - LOGGER.info("Attributes update with message:\n"+message.toString()); - handleAttributeUpdateRequest(message, thingId); - break; - default: - LOGGER.info("No valid event property."); - break; - } - } - - private void handleAttributeUpdateRequest(final Message message, final String thingId) { - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - LOGGER.info("handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); - spSenderService.updateAttributesOfThing(tenant, thingId); - } - - public void handleCancelDownloadAction(final Message message, final String thingId) { - System.out.println("[DmfReceiverService] handling Cancel/Download Action "+thingId); - - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - System.out.println(message.toString()); - //final Long actionId = convertMessage(message, Long.class); - JsonObject actionIdJson = gson.fromJson(message.getBody().toString(), JsonObject.class); - if(actionIdJson.has("actionId")) { - final long actionId = actionIdJson.get("actionId").getAsLong(); - final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); - spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); - } else { - LOGGER.error("Action ID does not exist in message: "+message.toString()); - } - } - - private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { - LOGGER.info(" handling update "+thingId); - - final MessageProperties messageProperties = message.getMessageProperties(); - final Map headers = messageProperties.getHeaders(); - final String tenant = (String) headers.get(MessageHeaderKey.TENANT); - - final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, - DmfDownloadAndUpdateRequest.class); - final Long actionId = downloadAndUpdateRequest.getActionId(); - final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); - System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); - - - //TODO: This does not work if the there are two or more fws (os and app) - // Following options to consider: - //1- merge into one ZIP and upload to Bucket instead and point the device to it - //2- upload each file separately and upload it to a folder which is the device id, and point the device to the folder - downloadAndUpdateRequest.getSoftwareModules().forEach(module -> { - module.getArtifacts().forEach( - artifact -> - { - try { - LOGGER.info("Handling artifact : "+artifact.getFilename()); - GcpBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } - }); - }); - - - deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, - null, device -> sendFeedback(actionId, device), actionType); - } - - private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { - LOGGER.info(" sendFeedback event "+device.getId()); - - switch (device.getUpdateStatus().getResponseStatus()) { - case SUCCESSFUL: - spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), - device.getUpdateStatus().getStatusMessages()); - break; - case ERROR: - spSenderService.finishUpdateProcessWithError( - new SimulatedUpdate(device.getTenant(), device.getId(), actionId), - device.getUpdateStatus().getStatusMessages()); - break; - case DOWNLOADING: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - case DOWNLOADED: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - case RUNNING: - spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, - device.getUpdateStatus().getStatusMessages(), actionId); - break; - default: - break; - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); + + private final DmfSenderService spSenderService; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final DeviceSimulatorRepository repository; + + private final Set openPings = Collections.synchronizedSet(new HashSet<>()); + + /** + * Constructor. + * + * @param rabbitTemplate + * for sending messages + * @param amqpProperties + * for amqp configuration + * @param spSenderService + * to send messages + * @param deviceUpdater + * simulator service for updates + * @param repository + * to manage simulated devices + */ + DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + super(rabbitTemplate, amqpProperties); + this.spSenderService = spSenderService; + this.deviceUpdater = deviceUpdater; + this.repository = repository; + } + + /** + * Method to validate if content type is set in the message properties. + * + * @param message + * the message to get validated + */ + private void checkContentTypeJson(final Message message) { + if (message.getBody().length == 0) { + return; + } + final MessageProperties messageProperties = message.getMessageProperties(); + final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); + if (null != headerContentType) { + messageProperties.setContentType(headerContentType); + } + final String contentType = messageProperties.getContentType(); + if (contentType != null && contentType.contains("json")) { + return; + } + throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); + } + + /** + * Handle the incoming Message from Queue with the property + * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). + * + * @param message + * the incoming message + * @param type + * the action type + * @param thingId + * the thing id in message header + * @param tenant + * the device belongs to + */ + @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") + public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, + @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, + @Header(MessageHeaderKey.TENANT) final String tenant) { + final MessageType messageType = MessageType.valueOf(type); + + if (MessageType.EVENT.equals(messageType)) { + checkContentTypeJson(message); + handleEventMessage(message, thingId); + return; + } + + if (MessageType.THING_DELETED.equals(messageType)) { + checkContentTypeJson(message); + repository.remove(tenant, thingId); + return; + } + + if (MessageType.PING_RESPONSE.equals(messageType)) { + final String correlationId = message.getMessageProperties().getCorrelationId(); + if (!openPings.remove(correlationId)) { + LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, + correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); + } + + return; + } + + LOGGER.info("No valid message type property."); + } + + @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) + void checkDmfHealth() { + if (!amqpProperties.isCheckDmfHealth()) { + return; + } + + if (openPings.size() > 5) { + LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); + } else { + LOGGER.debug("Currently {} open pings", openPings.size()); + } + + repository.getTenants().forEach(tenant -> { + final String correlationId = UUID.randomUUID().toString(); + spSenderService.ping(tenant, correlationId); + openPings.add(correlationId); + LOGGER.debug("Ping tenant {} with correlationId {}", tenant, correlationId); + }); + } + + private void handleEventMessage(final Message message, final String thingId) { + final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); + if (eventHeader == null) { + logAndThrowMessageError(message, "Event Topic is not set"); + } + // Exception squid:S2259 - Checked before + @SuppressWarnings({ "squid:S2259" }) + final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); + switch (eventTopic) { + case DOWNLOAD_AND_INSTALL: + case DOWNLOAD: + handleUpdateProcess(message, thingId, eventTopic); + break; + case CANCEL_DOWNLOAD: + handleCancelDownloadAction(message, thingId); + break; + case REQUEST_ATTRIBUTES_UPDATE: + handleAttributeUpdateRequest(message, thingId); + break; + default: + LOGGER.info("No valid event property."); + break; + } + } + + private void handleAttributeUpdateRequest(final Message message, final String thingId) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + + spSenderService.updateAttributesOfThing(tenant, thingId); + } + + private void handleCancelDownloadAction(final Message message, final String thingId) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + final Long actionId = convertMessage(message, Long.class); + + final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); + spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + } + + private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + + final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, + DmfDownloadAndUpdateRequest.class); + final Long actionId = downloadAndUpdateRequest.getActionId(); + final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, + null, device -> sendFeedback(actionId, device), actionType); + } + + private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case ERROR: + spSenderService.finishUpdateProcessWithError( + new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case DOWNLOADING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case DOWNLOADED: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case RUNNING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + default: + break; + } + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index eb910e2..eb62973 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; @@ -21,14 +22,13 @@ import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; -import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; import org.eclipse.hawkbit.simulator.SimulationProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.connection.CorrelationData; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.amqp.rabbit.support.CorrelationData; import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; /** @@ -36,360 +36,310 @@ */ public class DmfSenderService extends MessageService { - private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); - - private final String spExchange; - - private final SimulationProperties simulationProperties; - - /** - * - * @param rabbitTemplate - * the rabbit template - * @param amqpProperties - * the amqp properties - * @param simulationProperties - * for attributes update class - */ - DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, - final SimulationProperties simulationProperties) { - super(rabbitTemplate, amqpProperties); - spExchange = AmqpSettings.DMF_EXCHANGE; - this.simulationProperties = simulationProperties; - System.out.println("[DmfSenderService] init"); - } - - public void ping(final String tenant, final String correlationId) { - System.out.println("[DmfSenderService] ping with correlationId "+correlationId); - - final MessageProperties messageProperties = new MessageProperties(); - messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); - messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); - messageProperties.setCorrelationIdString(correlationId); - messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); - messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); - - sendMessage(spExchange, new Message(null, messageProperties)); - } - - /** - * Finish the update process. This will send a action status to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * a description according the update process - * @param actionType - * indicating whether to download and install or skip - * installation due to maintenance window. - */ - public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { - System.out.println("[DmfSenderService] Update Process"); - final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, - updateResultMessages); - sendMessage(spExchange, updateResultMessage); - } - - /** - * Finish update process with error and send error to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * list of messages for error - */ - public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { - System.out.println("[DmfSenderService] update error"); - sendErrorgMessage(update, updateResultMessages); - LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, - update.getThingId()); - } - - /** - * Send a message if the message is not null. - * - * @param address - * the exchange name - * @param message - * the amqp message which will be send if its not null - */ - public void sendMessage(final String address, final Message message) { - - - if (message == null) { - System.out.println("[DmfSenderService] received a null message"); - return; - } - System.out.println("[DmfSenderService] send message "+message.toString()); - message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); - - final String correlationId = UUID.randomUUID().toString(); - - if (isCorrelationIdEmpty(message)) { - message.getMessageProperties().setCorrelationIdString(correlationId); - } - - if (LOGGER.isTraceEnabled()) { - LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); - } else { - LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); - } - - rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); - } - - private static boolean isCorrelationIdEmpty(final Message message) { - System.out.println("[DmfSenderService] coorelation"); - - return message.getMessageProperties().getCorrelationIdString() == null - || message.getMessageProperties().getCorrelationIdString().length() <= 0; - } - - /** - * Convert object and message properties to message. - * - * @param object - * to get converted - * @param messageProperties - * to get converted - * @return converted message - */ - public Message convertMessage(final Object object, final MessageProperties messageProperties) { - Message m = rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); - System.out.println("[DmfSenderService] Converted Message "+m.toString()); - return m; - } - - /** - * Send an error message to SP. - * - * @param tenant - * the tenant - * @param updateResultMessages - * the error message description to send - * @param actionId - * the ID of the action for the error message - */ - public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { - - - final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, - actionId); - System.out.println("[DmfSenderService] send error message "); - - sendMessage(spExchange, message); - } - - /** - * Send a warning message to SP. - * - * @param update - * the simulated update object - * @param updateResultMessages - * a warning description - */ - public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { - - final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); - System.out.println("[DmfSenderService] warning message "); - - sendMessage(spExchange, message); - } - - /** - * Method to send a action status to SP. - * - * @param tenant - * the tenant - * @param actionStatus - * the action status - * @param updateResultMessages - * the message to get send - * @param actionId - * the cached value - */ - public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, - final List updateResultMessages, final Long actionId) { - System.out.println("[DmfSenderService] send action message"); - - final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); - sendMessage(message); - - } - - /** - * Create new thing created message and send to update server. - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - */ - public void createOrUpdateThing(final String tenant, final String targetId) { - System.out.println("[DmfSenderService] create/update "); - - sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); - - LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); - } - - /** - * Create new attribute update message and send to update server. - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - */ - public void updateAttributesOfThing(final String tenant, final String targetId) { - System.out.printf("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); - Map metadata = GcpIoTHandler.getDeviceMetadata(targetId); - sendMessage(spExchange, - updateAttributes(tenant, - targetId, - DmfUpdateMode.MERGE, - metadata)); - } - - /** - * Create new attribute update message for specific attribute and send to - * update server - * - * @param tenant - * the tenant to create the target - * @param targetId - * the ID of the target to create or update - * @param mode - * the update mode ('merge', 'replace', or 'remove') - * @param key - * the key of the attribute - * @param value - * the value of the attribute - */ - public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, - final String key, final String value) { - System.out.println("[DmfSenderService] updateAttributesOfThing"); - - sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); - } - - private Message thingCreatedMessage(final String tenant, final String targetId) { - System.out.println("[DmfSenderService] thingCreatedMessage"); - - final MessageProperties messagePropertiesForSP = new MessageProperties(); - messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); - messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); - messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); - messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); - messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); - messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); - return new Message(null, messagePropertiesForSP); - } - - private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { - System.out.println("[DmfSenderService] createAttributeUpdateMessage"); - - final MessageProperties messagePropertiesForSP = new MessageProperties(); - messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); - messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); - messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); - messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); - messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); - return messagePropertiesForSP; - } - - private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, - final Map attributes) { - System.out.println("[DmfSenderService] AttributeUpdateMessage"); - final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); - final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); - attributeUpdate.setMode(mode); - attributeUpdate.getAttributes().putAll(attributes); - - Message m = convertMessage(attributeUpdate, messagePropertiesForSP); - System.out.println("Converted Message "+m.toString()); - return m; - } - - - - /** - * Send a created message to SP. - * - * @param message - * the message to get send - */ - private void sendMessage(final Message message) { - LOGGER.info("[DmfSenderService] sending "+message.toString()); - - sendMessage(spExchange, message); - } - - /** - * Send error message to SP. - * - * @param context - * the current context - * @param updateResultMessages - * a list of descriptions according the update process - */ - private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { - System.out.println("[DmfSenderService] error"); - - final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); - sendMessage(spExchange, message); - } - - /** - * Create a action status message. - * - * @param actionStatus - * the ActionStatus - * @param actionMessage - * the message description - * @param actionId - * the action id - * @param cacheValue - * the cacheValue value - */ - private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, - final List updateResultMessages, final Long actionId) { - System.out.println("[DmfSenderService] createActionStatusMessage"); - - final MessageProperties messageProperties = new MessageProperties(); - final Map headers = messageProperties.getHeaders(); - final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); - headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - headers.put(MessageHeaderKey.TENANT, tenant); - headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); - headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); - actionUpdateStatus.addMessage(updateResultMessages); - - return convertMessage(actionUpdateStatus, messageProperties); - } - - private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, - final List updateResultMessages) { - System.out.println("[DmfSenderService] createUpdateResultMessage"); - - final MessageProperties messageProperties = new MessageProperties(); - final Map headers = messageProperties.getHeaders(); - final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), - actionStatus); - headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); - headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); - headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); - headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); - actionUpdateStatus.addMessage(updateResultMessages); - return convertMessage(actionUpdateStatus, messageProperties); - } - - private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, - final DmfActionStatus status) { - System.out.println("[DmfSenderService] createActionStatusMessage"); - - return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); - } + private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); + + private final String spExchange; + + private final SimulationProperties simulationProperties; + + /** + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param simulationProperties + * for attributes update class + */ + DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + super(rabbitTemplate, amqpProperties); + spExchange = AmqpSettings.DMF_EXCHANGE; + this.simulationProperties = simulationProperties; + } + + public void ping(final String tenant, final String correlationId) { + final MessageProperties messageProperties = new MessageProperties(); + messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); + messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); + messageProperties.setCorrelationId(correlationId); + messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); + messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); + + sendMessage(spExchange, new Message(null, messageProperties)); + } + + /** + * Finish the update process. This will send a action status to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a description according the update process + */ + public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { + final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, + updateResultMessages); + sendMessage(spExchange, updateResultMessage); + } + + /** + * Finish update process with error and send error to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * list of messages for error + */ + public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { + sendErrorgMessage(update, updateResultMessages); + LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, + update.getThingId()); + } + + /** + * Send a message if the message is not null. + * + * @param address + * the exchange name + * @param message + * the amqp message which will be send if its not null + */ + public void sendMessage(final String address, final Message message) { + if (message == null) { + return; + } + message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); + + final String correlationId = UUID.randomUUID().toString(); + + if (isCorrelationIdEmpty(message)) { + message.getMessageProperties().setCorrelationId(correlationId); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); + } else { + LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); + } + + rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); + } + + private static boolean isCorrelationIdEmpty(final Message message) { + return message.getMessageProperties().getCorrelationId() == null + || message.getMessageProperties().getCorrelationId().length() <= 0; + } + + /** + * Convert object and message properties to message. + * + * @param object + * to get converted + * @param messageProperties + * to get converted + * @return converted message + */ + public Message convertMessage(final Object object, final MessageProperties messageProperties) { + return rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); + } + + /** + * Send an error message to SP. + * + * @param tenant + * the tenant + * @param updateResultMessages + * the error message description to send + * @param actionId + * the ID of the action for the error message + */ + public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { + final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, + actionId); + sendMessage(spExchange, message); + } + + /** + * Send a warning message to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a warning description + */ + public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); + sendMessage(spExchange, message); + } + + /** + * Method to send a action status to SP. + * + * @param tenant + * the tenant + * @param actionStatus + * the action status + * @param updateResultMessages + * the message to get send + * @param actionId + * the cached value + */ + public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); + sendMessage(message); + + } + + /** + * Create new thing created message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void createOrUpdateThing(final String tenant, final String targetId) { + sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); + + LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void updateAttributesOfThing(final String tenant, final String targetId) { + sendMessage(spExchange, updateAttributes(tenant, targetId, DmfUpdateMode.MERGE, + simulationProperties.getAttributes().stream().collect(Collectors + .toMap(SimulationProperties.Attribute::getKey, SimulationProperties.Attribute::getValue)))); + + LOGGER.debug("Create update attributes message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message for specific attribute and send to + * update server + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + * @param mode + * the update mode ('merge', 'replace', or 'remove') + * @param key + * the key of the attribute + * @param value + * the value of the attribute + */ + public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, + final String key, final String value) { + sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); + } + + private Message thingCreatedMessage(final String tenant, final String targetId) { + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return new Message(null, messagePropertiesForSP); + } + + private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return messagePropertiesForSP; + } + + private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, + final Map attributes) { + final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); + final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); + attributeUpdate.setMode(mode); + attributeUpdate.getAttributes().putAll(attributes); + + return convertMessage(attributeUpdate, messagePropertiesForSP); + } + + /** + * Send a created message to SP. + * + * @param message + * the message to get send + */ + private void sendMessage(final Message message) { + sendMessage(spExchange, message); + } + + /** + * Send error message to SP. + * + * @param context + * the current context + * @param updateResultMessages + * a list of descriptions according the update process + */ + private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); + sendMessage(spExchange, message); + } + + /** + * Create a action status message. + * + * @param actionStatus + * the ActionStatus + * @param actionMessage + * the message description + * @param actionId + * the action id + * @param cacheValue + * the cacheValue value + */ + private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, tenant); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, + final List updateResultMessages) { + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), + actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, + final DmfActionStatus status) { + return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java index d47ab59..10ba185 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java @@ -35,8 +35,6 @@ public class MessageService { * the rabbit template * @param amqpProperties * the amqp properties - * @param messageConverter - * the message converter */ public MessageService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties) { this.rabbitTemplate = rabbitTemplate; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java index 3c46e89..6aaa700 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java @@ -24,7 +24,15 @@ public class SimulatedUpdate implements Serializable { private final Long actionId; private transient LocalDateTime startCacheTime; - SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { + /** + * @param tenant + * the tenant for this thing and for this simulated update + * @param thingId + * the thing id that this simulated update correlates to + * @param actionId + * the id of the action related to this simulated update + */ + public SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { this.tenant = tenant; this.thingId = thingId; this.actionId = actionId; diff --git a/hawkbit-device-simulator/src/main/resources/.gitignore b/hawkbit-device-simulator/src/main/resources/.gitignore deleted file mode 100644 index 81dd324..0000000 --- a/hawkbit-device-simulator/src/main/resources/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/keys.json -/rsa_cert.pem -/rsa_private.pem -/firestorekeys.json diff --git a/hawkbit-device-simulator/src/main/resources/application.properties b/hawkbit-device-simulator/src/main/resources/application.properties index b7a0588..addd4b0 100644 --- a/hawkbit-device-simulator/src/main/resources/application.properties +++ b/hawkbit-device-simulator/src/main/resources/application.properties @@ -28,7 +28,8 @@ hawkbit.device.simulator.attributes[1].value=1.1 hawkbit.device.simulator.attributes[2].key=serial hawkbit.device.simulator.attributes[2].value=${random.value} -endpoints.health.enabled=true +management.endpoints.enabled-by-default=false +management.endpoint.health.enabled=true ## Configuration for local RabbitMQ integration spring.rabbitmq.username=guest @@ -38,5 +39,4 @@ spring.rabbitmq.host=localhost spring.rabbitmq.port=5672 spring.rabbitmq.dynamic=true -security.basic.enabled=false server.port=8083 diff --git a/hawkbit-device-simulator/src/main/resources/logback-spring.xml b/hawkbit-device-simulator/src/main/resources/logback-spring.xml index 4a77556..9403ee4 100644 --- a/hawkbit-device-simulator/src/main/resources/logback-spring.xml +++ b/hawkbit-device-simulator/src/main/resources/logback-spring.xml @@ -12,7 +12,7 @@ - + diff --git a/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml b/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml deleted file mode 100644 index 8beb8e5..0000000 --- a/hawkbit-device-simulator/src/main/webapp/WEB-INF/appengine-web.xml +++ /dev/null @@ -1,6 +0,0 @@ - - true - java8 - 1 - \ No newline at end of file From 350821dea3076e62de4ae9e2253551f2c1cf7df1 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:50:55 -0400 Subject: [PATCH 50/54] sync --- hawkbit-device-simulator/appEngineDeploy.sh | 1 - hawkbit-device-simulator/dockerBuild.sh | 4 -- .../images/rolloutConfig.png | Bin 171370 -> 0 bytes hawkbit-device-simulator/logAppEngine.sh | 1 - hawkbit-device-simulator/mvn.sh | 1 - hawkbit-device-simulator/pullCompileRun.sh | 2 - hawkbit-device-simulator/runSpring.sh | 2 - .../vmInstallDependencies.sh | 41 ------------------ 8 files changed, 52 deletions(-) delete mode 100755 hawkbit-device-simulator/appEngineDeploy.sh delete mode 100755 hawkbit-device-simulator/dockerBuild.sh delete mode 100644 hawkbit-device-simulator/images/rolloutConfig.png delete mode 100755 hawkbit-device-simulator/logAppEngine.sh delete mode 100755 hawkbit-device-simulator/mvn.sh delete mode 100644 hawkbit-device-simulator/pullCompileRun.sh delete mode 100755 hawkbit-device-simulator/runSpring.sh delete mode 100644 hawkbit-device-simulator/vmInstallDependencies.sh diff --git a/hawkbit-device-simulator/appEngineDeploy.sh b/hawkbit-device-simulator/appEngineDeploy.sh deleted file mode 100755 index fdbb0eb..0000000 --- a/hawkbit-device-simulator/appEngineDeploy.sh +++ /dev/null @@ -1 +0,0 @@ -mvn appengine:deploy diff --git a/hawkbit-device-simulator/dockerBuild.sh b/hawkbit-device-simulator/dockerBuild.sh deleted file mode 100755 index 86ce52f..0000000 --- a/hawkbit-device-simulator/dockerBuild.sh +++ /dev/null @@ -1,4 +0,0 @@ -mvn clean install -mvn package -docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbel/ota . -docker run --network="docker_default" -v /Users/charbelk/dev/OSS/HawkBit-GCP/hawkbit-gcp-integrator/src/main/resources/:/opt/resources -d --name=HawkBit-GCP -p 8083:8083 charbel/ota:latest --PROJECT_ID=ota-iot-231619 --CLOUD_REGION=us-central1 --REGISTRY_NAME=OTA-DeviceRegistry --BUCKET_NAME=ota-iot-231619.appspot.com --KEYS=/opt/resources/keys.json \ No newline at end of file diff --git a/hawkbit-device-simulator/images/rolloutConfig.png b/hawkbit-device-simulator/images/rolloutConfig.png deleted file mode 100644 index e69428718589b84232fb8b8a4ea8920adebd272b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 171370 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 z zjAY5Bw>?Nk0-Wg(jfJ&mRbq3-Ec^Noc6O>gfeCShW<(gp!G*B#ZeO}&#Jdm?Iv+CM zNHZ!A)Ccp%yapOTv4(q(mG$mgWH#zME z&GzGHnUB!k@>!Ahl;z)}iVNZSqUM{;u!Q>x8gXOJ zk_v}((cW@sqM+5UK15O}D!d+O7KZsp`j}hL&@a6;%qMdZ*#obpHnsh`OT`5E3whq- z{H#$eD-T~cLNzDgKd3d@=*|*Y7#egMhg}@kju=fOnHNMEzZ4kZKTe7?IT0Axa}#NY zom@?=y7NFQYU?LeEEf-c#zI5snJTsKXf8|+LP?oe_j4dt=AGHGA7&`{8BOf99}rG8 zn7-Gx8sEt|wjh1a_3ONr36@f{({S?PsR&4wx)pPFubV`49fG zR=5|xU5!SlQzL@in9Y6-@035*CHgGy3lEh#c7QzdoiTsAf$#VBQ~W0H4d80EV+%?M z0e(yL?QL+nmFwj$d|b6KB(rj1;Kt683|QnJDI1`r!7Uyxiyyc|jo7NuaGBKN*IG%f zyShVMjQ56(zCT>X*$n!wGWk~UI|wb4% zs3VFh1a4bFGnUe5m?}$Xdr_aVB;n_YlFLsg@pQXC=~6xsyXp6*0A7}U7wK30iSS0C zb|0#}i1a5iPB4mfWR!gPp9Fl4+hBfNl6*g3{~;EC5UssgWRNWX$~}m~fAgkgO3H}I zWfMB}{|&nxDHeVHcK}-xi@P57-&Hd_0z~eKM_Z2>|IinIlEQu; z0^vwX|6=&a`OAwYjERVo$% z7&oz%8{~girGVt%^I(8Y;Te}w`EN=M-sypHZ#9L(VgD&Ze=X+!UxX=)%Nt&gU~mKB z)g0gpVbMr_AlbV300?9|y<#5m?Y}?=&&$ zzA=p2%j1bT5HD|93@N$;6_6O#_)AHiY&rS;Hf@Q<@_*eI;Wp%1`_O-{SQKqM+$*?p zA&j6Y?71uvpvfZo3?}bPMp1>(svBxFtpl(;s4d)1kv^ z4)i43k)(M1%qiJrxt{~8d+c+u#-HM-%Y+{(xvl00Pm@Tky92Z$sorYeSG{F*+Ry_f zIlSr!zNdaxG!r#y|7;W+gZ6qM;0=xOhl6*XWVhT8E#vJv=$mT~dY}&_J^vt&{^M{e zKLO*0IlogxOH676guoagF6e1QiC;2-$LM5C)Dc($S^3#43gf%14To=ZQA)qXYE-Xn zq+|Xiv;X>|G5#@l(laTjQE=k}+TGKIh%Ed#P1lbvzBM@+RK6qpN0$*WxCS|rWS8~x zcQ#{2aryi?4Pfo?y`yL(TjL68n!%?isvS0H8U8q};GWEgNp{SwAmJ4nUD0jDPBz@I zTx@#e7dNUsVUu%41S=M&GW$-xh1 zY455nJX>ZN;6v+u^(Vpv^oJa>f!}K{|9RkJT0OVQMw-NM1nv`oE97@qgs!VzcP$G@ zkO7wNF`L=1Y_>T+f>!jROnE9klarE#`ULyGVDJee`W40$m$wyaRDr0r7s`= zPUx$9e==^?-4)7Flf~tsGgL6Ltez`TP?q0_?iasYoq{aL9Pc@e}*Gaqk zVw7YPI9C*J0f%oluHk;DvX0xHMCD5I`zF9FCvrX6)*1|bE5l77lz9SJu9Z#4NqYHZ z!s#b{yU0m*mA?W&b~C0*>&1Ey5^?{(1yA@kZC@sNRdXN?4ykpm?I|EOD#r8z&~|YyLDkbEej6FV2h#iKOCE5| zNgjYZ`f#;9&Xx+nd;*bFc{qvedu%HAB+GKCdX8Rf9i8Z)ct;0un2rb9An;?X<11n{M?tKjU2)*5X??>Fp-??Ylb$Zfqi8<7ae#*&lU6AoyRXB(HO`m6HfJ$Gg7eb%;YOeu!b4c$a zV*yYwtvWjS8OD%o;w3jJKw{bn0C&SZ6R`hD17+zo(0&~4=#5VF$`tP`Gs&v@Q@~9m zHMKY#TsGx@Ir#%MMokemHo5@WmSUj=OaUI?6{&Y(U1F3HrzKvI!MJUu z_kz+TP^hBlLvdGeMfdJ6a|hsHq{*B#?mQ7JlFaDFl4hwggkgbuY?&z{ieoj*azYS@ z11@y*rbSSa(ARWt{SNDO5;hch59DJrL@l^>`2)|#+w|wO$i2Ca=1`NBUI{Er zbHmjUoC8AuTu0YQE@<#8s6avOQMP+!Cz0Nws;vU~?NI{eDw)$ge`8Vldzx)u>|jrK zdvF69j_s(QU1=WpC4As3k<9`9&y_xR$N-&p7bDD)ig;%bNyv8jkXZ3#ejHQ{B$;u8 zJV79+L15ZSirmNzR|~rRG`ZOMHFYUoCO9>V{UK_h#tbO(+ATc^#>qS`nu2GG(Nc|K zzn%!{uqC|c;y(f=->oC!(Ak%|Yu>#hw(;|&Tfg540}2$E2k%Yy6``tTk-w8^$fN7d zkd`3dpmyipc`6yCMZD=#gf6aG8+WIrP0r7pU;PeKi$5LwPL*~>04oa46c9q@E#qt7 zJLO7}FQ}~MO+y12P~Pum{@|fB7n)}>>dTa7DiYs+N6U=zCYfV2nC*ksClrJAt{EAf z?`L_V@~(`XSlNzY2Y#-nmqZ7i*W3(?f`EuFbIJJ>EVPl7})Su2D8YFr@ zyy@pKtWM|NaRLpAyoF0CUu&XZf-rG%DMvp!gv4RbwBX4t5vD*zT`bLW++cHOFA zFKDf zw~pT0$O#@&46OL;cs?Q$9Q8(!yywK{UxUm2|^-F%)u0FdM!D@-KU zMHhjVY)tD&y_*J^$j`B6<4V{>k<3z1C3w?{BywmqD>p{sZy&$WF?6 z-&I?&x?*paw+Y0fw>>f_To$9G!j9T38$7SC2vgb;wu1VctAH>3oQU(%Mrf;au){ZM z|4v2oh3O^YsIx3W1bb-=^uhSOTV-eRw;1@#XM<7=MAV)*f+s~qLs31eLth-z>* z_q>X|rs6d1f-lGPH#$vh*~uRqT>?-3X=&Wc3;Y00JFCwx=g#a(f2!y(%2aEl^yRlX ze?8BLMsJxc#|DP}W9B}%n8XANl}x0)J2&tS3^Z~K&Wjz!@^)MAm6paDtF3Kc^_QBD z#%Yjtew*;ZJnd-%XoXpR>>@PJ;jQjam-}PJM}8>Uea>P~HYi?FQJ`*HHXkatqk3b>L#|(Uyhj>Z zXz%$vZF(vr`^Y*!63Qx6lFtd8Pq=sl>Dc}tT=IX3ONkfCoL9A+_|<7=+EkEN+=x~1 zW9*sB!w0)G#kqj*ye~)jo@G-km&(}cmM1BuE zu?T_V(b8;;HhJLHFQRm2MGdRO-iC@>--5Zl43>PJlFFqoO3w`0%jKvs*runZp>^qi6$(al7ttvZj0ve8oks6 zq6f=wp9m3fb>GXSDA$-OKK~TKw(dWfhQ3_vkSRk@*C$lqc~W_62aDYZixs*@C%QeF1lciiqYZRJeB1bt6Bzul>`F zpaL{&zT`9+i8-OE(!()kE^<(U1v=y&`M`slTb7hW#T9Qwy3Gf*n^A9pI?Y6jR!1~@ zzGUX+tK<2nmg`w{g}e(te4WDralem;xQ}miJ?~}0X?-!Qao!!<4_a0_?PVMtz2&_( zN&qT^Di0I#T=tsc9Q8MDUV4eBbmNY>m7WjlwGjJ8k8EK}CgTh8*qkVkaIznMw*Jnx zfS1Mgx*$O|9S^huc`dUCoYV27#!(_WOHA}!x)YnLJ()S4Sh~f;)3)K;1g<=-2j7YC zaydPj8i<{5Y0O%S^Ci`%CPqq=_z8MI$FPh#19LJitO@z7pai1sWLuAp;|oni3x3eu za|nz;Pfp3uV+bTvQTr;|-c9h5S<7)_!k744P=Cyp(xRaPJ(IbdM!d=O2OaUw+*UwDE455pflQvj?fOD4k6j z7i^qSaM0yk(8=IRnYHrSgHgAG?!m?z5wB}~-Pxe0w$>@@fVkzSS*y8Egw_n52bGI* zzNFH|X-q=<3G7si```pa=0NpGE^avU-KFz2vf6N$bBt0@9%_UiP(t2IlyWwYEd9kK z{m(zYL7lxf&%HE_i7!xeMc+tt$`iH?n2oVGvDHdzLabi zINvPk^eGu@Ne%Y!nXMnzF!!%_Kc|ggCYVf+blG^?GU!QqR}4;$TVKBh`(&|S#)Vy1 zK1qcwSmgQOxNBi;wDf(;nNOTfPQQhDtF<}RNjJA(*a3e%d0iLnUGu9;&jXxc4skqW zggonnYM|79WM9888W;C@`9}vawVTnL1TO*T1W)s{xck;}DV+|5^YK9vXqksGO%u1~ zanWg*(sK+lOGFVf3~t^BAfk$u7)CnX`mU6ro#lzRiFD!`ZuQgOJIgiLl7434QN^8h ziR?(|cZmG@Ar^OBsC#_MRcya(i*-(zu8?GzYXJpzp4H0rXx&Kt8@R>WrH$zgxe%Y~ zjtaF^UB-R+-(qG(r7p?C`iE=7(^8J53yWVKb4=o@PQEH=oXzrLpYGbjC%*aWSIk zTX%Q!*!X7yP-v}+(2Ra@$cLsy)a$EN_w_z;>OHkV?r4Jvnpj9-#lTH8;>D2aov-%*#3v@)n$E=^|f_Q7<+K?JLX8Hr@@xm zELQVQt>oe!tb;SweZOOwCFFk-Gm{q%Q^XpyEuYD!zKdv(!J5&u%f>M*KRo zX|GLNrJz56H2;1!T!V?;A9UWIjP1H2qAO!rkl5soU|N zB&v`1Zlj5RG>m*ak9w;s2>+1a%=vE#{vbvB}I;qY)uUNr{?^L)JAa-)36RbaSs z%e5}#-jhp@>LZbiZSxCl_rm-GQ&02(S1kyIdwYHZ*?kt6kZbrzq4;_nk-IY^h5M`A z2B9WO&IHPliH%y|V~AzQBWlEAI`!h$CMdLNM&i@1P2QJ5w)%>GsaR%7czRy#Hsfs9 zrzprxbjrB@;<^7Bv60(h0WmSZ)4S9QKMFoItyz^A7AzdPJ!2Lzo;qSKkhSqu7}`v~ zYFqi3eN&}U-~>dsPTdyqNQ@cX&@K=℘-n0 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 z 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 diff --git a/hawkbit-device-simulator/logAppEngine.sh b/hawkbit-device-simulator/logAppEngine.sh deleted file mode 100755 index 27c3c7e..0000000 --- a/hawkbit-device-simulator/logAppEngine.sh +++ /dev/null @@ -1 +0,0 @@ - gcloud app logs tail -s default diff --git a/hawkbit-device-simulator/mvn.sh b/hawkbit-device-simulator/mvn.sh deleted file mode 100755 index 5cac501..0000000 --- a/hawkbit-device-simulator/mvn.sh +++ /dev/null @@ -1 +0,0 @@ -mvn clean install \ No newline at end of file diff --git a/hawkbit-device-simulator/pullCompileRun.sh b/hawkbit-device-simulator/pullCompileRun.sh deleted file mode 100644 index e853d13..0000000 --- a/hawkbit-device-simulator/pullCompileRun.sh +++ /dev/null @@ -1,2 +0,0 @@ -git pull -./runSpring.sh diff --git a/hawkbit-device-simulator/runSpring.sh b/hawkbit-device-simulator/runSpring.sh deleted file mode 100755 index 58540b1..0000000 --- a/hawkbit-device-simulator/runSpring.sh +++ /dev/null @@ -1,2 +0,0 @@ -mvn clean install -mvn spring-boot:run diff --git a/hawkbit-device-simulator/vmInstallDependencies.sh b/hawkbit-device-simulator/vmInstallDependencies.sh deleted file mode 100644 index 055a75a..0000000 --- a/hawkbit-device-simulator/vmInstallDependencies.sh +++ /dev/null @@ -1,41 +0,0 @@ -echo 'Installing git' -sudo apt-get install git - -echo 'Installing jdk 8' -sudo apt-get install openjdk-8-jdk - -echo 'Installing maven' -sudo apt-get install maven -sudo update-alternatives --config java - -echo 'Installing docker' -sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common -curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - -sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" -sudo apt-get update -sudo apt-get install docker-ce docker-ce-cli containerd.io - -echo 'cloning hawbkit and installing it' -mkdir hawkbit -git clone https://github.com/eclipse/hawkbit.git -cd hawkbit -mvn clean install - -echo 'cloning gcp hawkbit module' -git clone https://github.com/charbull/hawkbit-examples.git -cd hawkbit-examples -mvn clean install -cd hawkbit-device-simulator/ -chmod 777 runSpring.sh -#./runSpring.sh - -echo 'Creating topic state and subscription state' -gcloud pubsub topics create state -gcloud pubsub subscriptions create --topic state state - -echo 'things you should do manually' -echo '- Creating a service account hawkbit-poc' -echo '- Get the keys and put it in: hawkbit-examples/hawkbit-device-simulator/src/main/resources' - -#sudo docker swarm init -#sudo docker stack deploy -c docker-compose-stack.yml hawkbit \ No newline at end of file From 0291ca7ef5fa52a5dd5dc436f46dc81a231b5552 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:57:22 -0400 Subject: [PATCH 51/54] removed unused files --- hawkbit-gcp-iot-core/appEngineDeploy.sh | 1 - hawkbit-gcp-iot-core/dockerBuild.sh | 4 -- hawkbit-gcp-iot-core/logAppEngine.sh | 1 - hawkbit-gcp-iot-core/mvn.sh | 1 - hawkbit-gcp-iot-core/pullCompileRun.sh | 2 - .../src/main/appengine/app.yaml | 9 ---- .../eclipse/hawkbit/google/gcp/GcpOTA.java | 5 --- hawkbit-gcp-iot-core/vmInstallDependencies.sh | 41 ------------------- 8 files changed, 64 deletions(-) delete mode 100755 hawkbit-gcp-iot-core/appEngineDeploy.sh delete mode 100755 hawkbit-gcp-iot-core/dockerBuild.sh delete mode 100755 hawkbit-gcp-iot-core/logAppEngine.sh delete mode 100755 hawkbit-gcp-iot-core/mvn.sh delete mode 100644 hawkbit-gcp-iot-core/pullCompileRun.sh delete mode 100644 hawkbit-gcp-iot-core/src/main/appengine/app.yaml delete mode 100644 hawkbit-gcp-iot-core/vmInstallDependencies.sh diff --git a/hawkbit-gcp-iot-core/appEngineDeploy.sh b/hawkbit-gcp-iot-core/appEngineDeploy.sh deleted file mode 100755 index fdbb0eb..0000000 --- a/hawkbit-gcp-iot-core/appEngineDeploy.sh +++ /dev/null @@ -1 +0,0 @@ -mvn appengine:deploy diff --git a/hawkbit-gcp-iot-core/dockerBuild.sh b/hawkbit-gcp-iot-core/dockerBuild.sh deleted file mode 100755 index 86ce52f..0000000 --- a/hawkbit-gcp-iot-core/dockerBuild.sh +++ /dev/null @@ -1,4 +0,0 @@ -mvn clean install -mvn package -docker build -f ./docker/0.3.0-SNAPSHOT/Dockerfile -t charbel/ota . -docker run --network="docker_default" -v /Users/charbelk/dev/OSS/HawkBit-GCP/hawkbit-gcp-integrator/src/main/resources/:/opt/resources -d --name=HawkBit-GCP -p 8083:8083 charbel/ota:latest --PROJECT_ID=ota-iot-231619 --CLOUD_REGION=us-central1 --REGISTRY_NAME=OTA-DeviceRegistry --BUCKET_NAME=ota-iot-231619.appspot.com --KEYS=/opt/resources/keys.json \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/logAppEngine.sh b/hawkbit-gcp-iot-core/logAppEngine.sh deleted file mode 100755 index 27c3c7e..0000000 --- a/hawkbit-gcp-iot-core/logAppEngine.sh +++ /dev/null @@ -1 +0,0 @@ - gcloud app logs tail -s default diff --git a/hawkbit-gcp-iot-core/mvn.sh b/hawkbit-gcp-iot-core/mvn.sh deleted file mode 100755 index 5cac501..0000000 --- a/hawkbit-gcp-iot-core/mvn.sh +++ /dev/null @@ -1 +0,0 @@ -mvn clean install \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/pullCompileRun.sh b/hawkbit-gcp-iot-core/pullCompileRun.sh deleted file mode 100644 index e853d13..0000000 --- a/hawkbit-gcp-iot-core/pullCompileRun.sh +++ /dev/null @@ -1,2 +0,0 @@ -git pull -./runSpring.sh diff --git a/hawkbit-gcp-iot-core/src/main/appengine/app.yaml b/hawkbit-gcp-iot-core/src/main/appengine/app.yaml deleted file mode 100644 index 0e6bd11..0000000 --- a/hawkbit-gcp-iot-core/src/main/appengine/app.yaml +++ /dev/null @@ -1,9 +0,0 @@ -service: default -runtime: java -runtime_config: - jdk: openjdk8 -env: flex - -handlers: -- url: /.* - script: this field is required, but ignored \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java index b3b6c83..71724fc 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -2,11 +2,6 @@ public class GcpOTA { -// public static String PROJECT_ID = "ota-iot-231619"; -// public static String CLOUD_REGION = "us-central1"; -// public static String REGISTRY_NAME = "OTA-DeviceRegistry"; -// public static String BUCKET_NAME = "ota-iot-231619.appspot.com"; - public static String PROJECT_ID = ""; public static String CLOUD_REGION = ""; public static String REGISTRY_NAME = ""; diff --git a/hawkbit-gcp-iot-core/vmInstallDependencies.sh b/hawkbit-gcp-iot-core/vmInstallDependencies.sh deleted file mode 100644 index 055a75a..0000000 --- a/hawkbit-gcp-iot-core/vmInstallDependencies.sh +++ /dev/null @@ -1,41 +0,0 @@ -echo 'Installing git' -sudo apt-get install git - -echo 'Installing jdk 8' -sudo apt-get install openjdk-8-jdk - -echo 'Installing maven' -sudo apt-get install maven -sudo update-alternatives --config java - -echo 'Installing docker' -sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common -curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - -sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" -sudo apt-get update -sudo apt-get install docker-ce docker-ce-cli containerd.io - -echo 'cloning hawbkit and installing it' -mkdir hawkbit -git clone https://github.com/eclipse/hawkbit.git -cd hawkbit -mvn clean install - -echo 'cloning gcp hawkbit module' -git clone https://github.com/charbull/hawkbit-examples.git -cd hawkbit-examples -mvn clean install -cd hawkbit-device-simulator/ -chmod 777 runSpring.sh -#./runSpring.sh - -echo 'Creating topic state and subscription state' -gcloud pubsub topics create state -gcloud pubsub subscriptions create --topic state state - -echo 'things you should do manually' -echo '- Creating a service account hawkbit-poc' -echo '- Get the keys and put it in: hawkbit-examples/hawkbit-device-simulator/src/main/resources' - -#sudo docker swarm init -#sudo docker stack deploy -c docker-compose-stack.yml hawkbit \ No newline at end of file From a4a785693d5e850b58a74a3b1a50b00995bc4a5f Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 14:58:39 -0400 Subject: [PATCH 52/54] removed simulationProperties --- .../org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index eb910e2..30a12e2 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -40,8 +40,6 @@ public class DmfSenderService extends MessageService { private final String spExchange; - private final SimulationProperties simulationProperties; - /** * * @param rabbitTemplate @@ -55,7 +53,6 @@ public class DmfSenderService extends MessageService { final SimulationProperties simulationProperties) { super(rabbitTemplate, amqpProperties); spExchange = AmqpSettings.DMF_EXCHANGE; - this.simulationProperties = simulationProperties; System.out.println("[DmfSenderService] init"); } From 6e746da4a9eec87c6cbb7cccd5df391c21d7ba61 Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 15:03:40 -0400 Subject: [PATCH 53/54] update copyright --- hawkbit-gcp-iot-core/pom.xml | 2 -- .../hawkbit/google/gcp/GcpBucketHandler.java | 9 +++++++++ .../hawkbit/google/gcp/GcpCredentials.java | 9 +++++++++ .../hawkbit/google/gcp/GcpFireStore.java | 9 +++++++++ .../hawkbit/google/gcp/GcpIoTHandler.java | 9 +++++++++ .../eclipse/hawkbit/google/gcp/GcpOTA.java | 9 +++++++++ .../hawkbit/google/gcp/GcpProperties.java | 9 +++++++++ .../hawkbit/google/gcp/GcpSubscriber.java | 9 +++++++++ .../gcp/HawkBitSoftwareModuleHandler.java | 14 +++++++++----- .../gcp/RetryHttpInitializerWrapper.java | 19 ++++++------------- 10 files changed, 78 insertions(+), 20 deletions(-) diff --git a/hawkbit-gcp-iot-core/pom.xml b/hawkbit-gcp-iot-core/pom.xml index 7e87b0b..27d5ec6 100644 --- a/hawkbit-gcp-iot-core/pom.xml +++ b/hawkbit-gcp-iot-core/pom.xml @@ -58,8 +58,6 @@ hawkbit-example-ddi-feign-client ${project.version} - org.springframework.amqp spring-rabbit diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java index e55dbbc..1a86216 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.io.ByteArrayInputStream; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java index f9aa889..47eb1aa 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.io.IOException; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java index 6b3f216..448e81d 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.util.Collections; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java index 7b11c8c..9493995 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.io.IOException; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java index 71724fc..076daf6 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; public class GcpOTA { diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java index d64b8c7..d519ea1 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import org.apache.commons.cli.CommandLine; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java index 3b3f94b..ed63747 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.util.HashMap; diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java index c47598d..d34462b 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java @@ -1,3 +1,12 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + package org.eclipse.hawkbit.google.gcp; import java.io.IOException; @@ -21,11 +30,6 @@ import com.google.common.base.Charsets; import com.google.common.io.ByteStreams; -//TODO: -/** - * Use the Hawkbit Management Client to download - * software modules and put them on the bucket - * */ public class HawkBitSoftwareModuleHandler { private static final Logger LOGGER = LoggerFactory.getLogger(HawkBitSoftwareModuleHandler.class); diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java index c98c0c2..391441c 100644 --- a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java @@ -1,17 +1,10 @@ -/* - * Copyright 2017 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 +/** + * Copyright 2019 Google LLC * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.hawkbit.google.gcp; From 8725fb55813c7a7df6aa1bfa652bf568154ba78c Mon Sep 17 00:00:00 2001 From: charbull Date: Wed, 17 Apr 2019 15:06:10 -0400 Subject: [PATCH 54/54] added developer --- hawkbit-gcp-iot-core/pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/hawkbit-gcp-iot-core/pom.xml b/hawkbit-gcp-iot-core/pom.xml index 27d5ec6..fbefed8 100644 --- a/hawkbit-gcp-iot-core/pom.xml +++ b/hawkbit-gcp-iot-core/pom.xml @@ -14,6 +14,20 @@ hawkBit :: GCP :: Manager Device Management Federation API with GCP + + + charbull + charbelk@google.com + Google LLC + https://www.google.com + + Lead + Committer + + + + +