Application Design

Azure IoT Hub를 이용한 Firmware Update기능 + Java enum을 활용한 디자인패턴 적용

멋진그이름 2019. 10. 8. 13:14

<배경>

- Azure IoT Hub를 통하여 Device - Server 간에 메시지 전송이 가능하며, 필요할 때 디바이스 원격호출 및 제어가 가능하다.

https://icthuman.tistory.com/entry/Azure-IoT-Hub-%EC%99%80-Device-%EC%97%B0%EA%B2%B0-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%A0%84%EC%86%A1-%EB%B0%8F-%EC%A0%9C%EC%96%B4

 

Azure IoT Hub 와 Device 연결, 메시지 전송 및 제어

<개요> - Azure IoT Hub를 사용하면 Device와 Server를 간편하게 연결하여 D2C (Device to Cloud), C2D (Cloud to Device) 메시지를 쉽게 전달할 수 있습니다. - 디바이스의 메시지는 일반적으로 json string으로..

icthuman.tistory.com

- 원격호출 방식은 일반적인 Browser, Server 시스템 구조와 거의 동일하다.

- Device - Server 간에 공유하는 영역에 값을 Read / Write 함으로 특정로직을 구성할 수 있다.

 

<개요>

 - Server는 Client에 어떤 Action을 하도록 전달하고, Client는 작업 후 상태는 Update하며,

 - Server는 상태를 모니터링한다는 점은 일반적인 시스템구성과 크게 다르지 않다.

 - 다만 사용하는 용어가 IoT 에 특화된 용어를 사용할 뿐이며, 사용되는 기술은 90%이상 동일하다.

IoT Platfrom 일반 System
Device Client
Device Twin Shared Area (e.g. zookeeper, Redis)
Configuration Class, 구조체, JSON Value
Telemetry Data Message
Device Control RPC
Firmware Application Logic

 - 제공되는 API, SDK들을 활용하여 펌웨어 업데이트를 구현하는 샘플시나리오가 있지만 실제 비지니스에서 사용하기에는 적합하지 않다.

 

<작업내용>

- Firmware Update를 위한 Azure SDK 를 wrapping 하여 일반 개발자가 손쉽게 사용할 수 있으며, 유지보수가 쉽도록 한다.

 

IoT Hub를 이용한 Firmware Update 절차

Firmware Update 구현한 샘플코드는 GitHub을 통해서 확인할 수 있다. https://github.com/Azure-Samples/azure-iot-samples-node

 

Azure-Samples/azure-iot-samples-node

azure-iot-node-samples provides a set of easy-to-understand, continuously-tested samples for using Azure IoT Hub and Azure IoT Hub Device Provisioning Service using Node.js SDK. - Azure-Samples/azu...

github.com

<Back-end App>

1. 먼저 IoT Hub에 접근할 수 있는 RegistryManager를 생성한다.

var Registry = require('azure-iothub').Registry;
const chalk = require('chalk');

var connectionString = process.argv[2];
var fwVersion = '2.8.5';
var fwPackageURI = 'https://secureuri/package.bin';
var fwPackageCheckValue = '123456abcde';
var sampleConfigId = 'firmware285';

2. Update할 Configuration 을 정의한다.

// <configuration>
var firmwareConfig = {
  id: sampleConfigId,
  content: {
    deviceContent: {
      'properties.desired.firmware': {
        fwVersion: fwVersion,
        fwPackageURI: fwPackageURI,
        fwPackageCheckValue: fwPackageCheckValue
      }
    }
  },

  // Maximum of 5 metrics per configuration
  metrics: {
    queries: {
      current: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'current\'',
      applying: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND ( properties.reported.firmware.fwUpdateStatus=\'downloading\' OR properties.reported.firmware.fwUpdateStatus=\'verifying\' OR properties.reported.firmware.fwUpdateStatus=\'applying\')',
      rebooting: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'rebooting\'',
      error: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'error\'',
      rolledback: 'SELECT deviceId FROM devices WHERE configurations.[[firmware285]].status=\'Applied\' AND properties.reported.firmware.fwUpdateStatus=\'rolledback\''
    }
  },

  // Specify the devices the firmware update applies to
  targetCondition: 'tags.devicetype = \'chiller\'',
  priority: 20
};
// </configuration>

deviceContent에는 Device가 참조할 값들이 들어가고, metrics에는 작업 수행 중 Device 가 기록하는 값(DeviceTwin)을 조회하여 모니터링 할 수 있는 쿼리가 들어간다.

 

3. RegistryManager를 통해서 해당 Configuration 정보를 등록한다.

registry.addConfiguration(firmwareConfig, function(err) {
    if (err) {
      console.log('Add configuration failed: ' + err);
      done();
    } else {
      console.log('Add configuration succeeded');
      done();
    }
  });

 

<Device-App>

1. 초기상태를 세팅한다.

// Send firmware update status to the hub
function initializeStatus(callback) {
  var patch = {
    firmware: {
      currentFwVersion: '1.0.0',
      pendingFwVersion: '',
      fwUpdateStatus: 'current',
      fwUpdateSubstatus: '',
      lastFwUpdateStartTime: '',
      lastFwUpdateEndTime: ''
    }
  };
  deviceTwin.properties.reported.update(patch, function(err) {
    callback(err);
  });
}

 

2. 바라보고 있는 변수값(fwVersion, fwUpdateStatus 등)에 변경이 있는지 감지하고, 

값에 따라서 sendStatusUpdate, sendStartingUpdate, initiateFirmwareUpdateFlow 함수를 호출한다.

// <initiateUpdate>
        // Handle firmware desired property updates - this triggers the firmware update flow
        twin.on('properties.desired.firmware', function(fwUpdateDesiredProperties) {
          console.log(chalk.green('\nCurrent firmware version: ' + twin.properties.reported.firmware.currentFwVersion));
          console.log(chalk.green('Starting firmware update flow using this data:'));
          console.log(JSON.stringify(fwUpdateDesiredProperties, null, 2));
          desiredFirmwareProperties = twin.properties.desired.firmware;

          if (fwUpdateDesiredProperties.fwVersion == twin.properties.reported.firmware.currentFwVersion) {
            sendStatusUpdate('current', 'Firmware already up to date', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }
          if (fwUpdateInProgress) {
            sendStatusUpdate('current', 'Firmware update already running', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }
          if (!fwUpdateDesiredProperties.fwPackageURI.startsWith('https')) {
            sendStatusUpdate('error', 'Insecure package URI', function (err) {
              if (err) {
                console.error(chalk.red('Error occured sending status update : ' + err.message));
              }
              return;
            });
          }

          fwUpdateInProgress = true;

          sendStartingUpdate(fwUpdateDesiredProperties.fwVersion, function (err) {
            if (err) {
              console.error(chalk.red('Error occured sending starting update : ' + err.message));
            }
            return;
          });
          initiateFirmwareUpdateFlow(function(err, result) {
            fwUpdateInProgress = false;
            if (!err) {
              console.log(chalk.green('Completed firmwareUpdate flow. New version: ' + result));
              sendFinishedUpdate(result, function (err) {
                if (err) {
                  console.error(chalk.red('Error occured sending finished update : ' + err.message));
                }
                return;
              });
            }
          }, twin.properties.reported.firmware.currentFwVersion);
        });

 

3. 실제 작업을 수행하는 initiateFirmwareUpdateFlow 함수의 구성으로 

downloadImage, verifyImage, applyImage, reboot 를 순차적으로 수행한다. 필요한 로직을 상태에 맞게 구현한다.

각 함수가 수행되고 나면 현재 상태를 update 한다.

// <firmwareupdateflow>
// Implementation of firmwareUpdate flow
function initiateFirmwareUpdateFlow(callback, currentVersion) {

  async.waterfall([
    downloadImage,
    verifyImage,
    applyImage,
    reboot
  ], function(err, result) {
    if (err) {
      console.error(chalk.red('Error occured firmwareUpdate flow : ' + err.message));
      sendStatusUpdate('error', err.message, function (err) {
        if (err) {
          console.error(chalk.red('Error occured sending status update : ' + err.message));
        }
      });
      setTimeout(function() {
        console.log('Simulate rolling back update due to error');
        sendStatusUpdate('rolledback', 'Rolled back to: ' + currentVersion, function (err) {
          if (err) {
            console.error(chalk.red('Error occured sending status update : ' + err.message));
          }
        });
        callback(err, result);
      }, 5000);
    } else {
      callback(null, result);
    }
  });
}

 

위와 같이 Back-end App 와 Device-App을 구성하면

- Server에서 Device에 어떤 명령을 전달하고

- Device는 전달받은 명령을 수행하고 상태를 변경하며, 

- Server에서는 현재 상태를 모니터링하는 작업을 수행할 수 있다.

 

다만 이와 같은 코드는 실제 시스템에서 사용하기에는 적합하지 않는 Design이기 때문에 다음과 같이 Backend-App의 Refactoring을 진행합니다.(예제코드는 javascript로 작성되었으며 리팩토링은 본인에게 친숙한 Java를 사용하였습니다.)

 

1. Java SDK의 RegistryManager를 생성하고 Configuration에 담을 객체를 생성한다.

@Component
public class AzureIotHubConfigurationManager {
        public void addConfiguration(String serviceCode, String deviceModelCode, AzureDeviceFirmwareUpdateInfo azureDeviceFirmwareUpdateInfo) throws IOException, ServiceNotFoundException, IotHubException, DeviceModelNotFoundException {
        // 1. serviceCode validation
        // 2. deviceModelCode validation

        RegistryManager registryManager = RegistryManager.createFromConnectionString(getAzureIotHubConnectionString());
        Configuration configuration = new Configuration(azureDeviceFirmwareUpdateInfo.getConfigurationId());

        ConfigurationContent configurationContent = new ConfigurationContent();
        configurationContent.setDeviceContent(azureDeviceFirmwareUpdateInfo.firmwareUpdateInfoToMap());

        ConfigurationMetrics configurationMetrics = new ConfigurationMetrics();
        configurationMetrics.setQueries( azureDeviceFirmwareUpdateInfo.getMetricQueries() );

        configuration.setContent(configurationContent);
        configuration.setTargetCondition("tags.serviceCode= \'"+serviceCode+"\'"+
                                        " AND " +
                                        " tags.deviceModelCode = \'"+deviceModelCode+"\'");
        configuration.setPriority(20);

        registryManager.addConfiguration(configuration);

    }
}

 

2. Azure IoT Hub에 연관된 정보는 모두 한 곳에 모아두고, 관련된 행위 역시 하나의 클래스로 작업하도록 한다.(SRP원칙)

@Getter
@Setter
@Builder
public class AzureDeviceFirmwareUpdateInfo {
    private String configurationId;
    private String fwVersion;
    private String fwPackageURI;
    private String fwPackageCheckValue;

필요한 정보들을 멤버변수로 선언한다.

 

public java.util.Map firmwareUpdateInfoToMap(){
        java.util.Map temp = new HashMap();
        temp.put("fwVersion", fwVersion);
        temp.put("fwPackageURI", fwPackageURI);
        temp.put("fwPackageCheckValue", fwPackageCheckValue);

        java.util.Map map = new HashMap();
        map.put("properties.desired.firmware", temp);
        return map;
    }

실제로 Azure SDK에서는 Map의 형태로 입력을 받아야 하기 때문에 Map형태로 정보를 제공하는 Method역시 Class내부에 작성한다.

 

 Azure IoT Hub 샘플에서 정의하고 있는 Firmware Update단계는, enum type을 활용하면 효과적으로 처리할 수 있으며 이 때 사용할 String value도 함께 할당하도록 하였다.

public enum FirmwareStatus{
        CURRENT("current"),
        REBOOTING("rebooting"),
        ERROR("error"),
        ROLLEDBACK("rolledback"),
        APPLYING("applying"),
        DOWNLOADING("downloading"),
        VERIFYING("verifying");

        private String label;
        FirmwareStatus(String label){
            this.label = label;
        }
    }

 해당 값을 String으로만 처리하는 경우에는 유효하지 않는 코드값이 들어오거나, 오타가 발생하는등의 오류를 잡아내기가 힘들며 또한 코드값이 변경/추가되었을때 유지보수가 용이하지 않기 때문에 되도록 enum type사용을 권장한다.

또한, enum type을 사용하더라도 코드값에 따른 로직분기의 경우 if - else if문의 반복을 통해서 수행하는 경우가 많은데 추후 요구사항의 변경이 발생하였을 때 코드의 가독성을 떨어뜨리고, 버그를 만드는 원인이 된다. 이부분 역시 enum type을 활용하여 소스를 깔끔하게 유지할 필요가 있다.

if(status == FirmwareStatus.CURRENT){

}else if(status == FirmwareStatus.APPLYING){

}
.....
else{

}

 

Firmware Update의 모니터링쿼리를 살펴보면 각 Status에 따라 쿼리의 조건문이 다르고, 인자의 수에 따라서도 미묘하게 다르게 생성되어야 하지만 공통으로 공유하는 조건도 있다.

 

 예를 들어서 CURRENT인 경우 상태가 'current'인 device에 대해서 조회가 이루어져야 하며, APPLYING의 경우는 'downloading', 'verifying', 'applying' 의 3가지 경우에 대해서 조회가 이루져야 한다. 그러나 조회조건의 configurationId는 동일하다.

 해당 로직을 외부에서 각각 구현한다면 중복로직이 존재하게 되고 추후 변경사항이 발생할 경우 영향을 받는 범위도 넓다. 이는 OCP원칙에 위배되기 때문에 확장에는 열려있고 변경에는 닫히도록 작성할 필요가 있다.

public enum FirmwareUpdate{
        CURRENT(Arrays.asList(FirmwareStatus.CURRENT)),
        REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)),
        ERROR(Arrays.asList(FirmwareStatus.ERROR)),
        ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)),
        APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING));

        List<FirmwareStatus> statusList;

        FirmwareUpdate(List<FirmwareStatus> statusList){
            this.statusList=statusList;
        }

        String query(String configurationId){

            String temp = "SELECT deviceId FROM devices WHERE configurations.[[" + configurationId + "]].status=\'Applied\' AND ";

            if (statusList.size() == 0) {
                throw new ArrayIndexOutOfBoundsException("the size of status list should be positive number");
            } else if (statusList.size() == 1) {
                temp += "properties.reported.firmware.fwUpdateStatus=\'" + statusList.get(0).label + "\'";
            } else {
                temp += "(";

                int count=0;
                for (FirmwareStatus status : statusList) {
                    temp += "properties.reported.firmware.fwUpdateStatus=\'" + status.label + "\'";
                    count++;
                    if(count < statusList.size()){
                        temp+=" OR ";
                    }
                }
                temp += ")";
            }
            return temp;
        }
    }

위와 같이 각 Status에 따라서 동작하는 고유의 로직은 enum형태로 가지고 있도록 하며, 외부에는 query 메소드만 노출하도록 한다.

만약 Status 에 따라서 더욱 구체적인 구현이 필요하다면 query 메소드를  abstract로 정의하고 type별로 개별구현하는 것도 가능하다.

import java.util.Arrays;
import java.util.List;


public enum FirmwareUpdate{
            CURRENT(Arrays.asList(FirmwareStatus.CURRENT)){
                String query(){
                    return null;
                }
            },
            REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)){
                String query(){
                    return null;
                }
            },
            ERROR(Arrays.asList(FirmwareStatus.ERROR)){
                String query(){
                    return null;
                }
            },
            ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)){
                String query(){
                    return null;
                }
            },
            APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING)){
                String query(){
            return null;
        }
    };

    List<FirmwareStatus> statusList;

    FirmwareUpdate(List<FirmwareStatus> statusList){
        this.statusList=statusList;
    }

    abstract String query();

}

 

전체 완성된 소스는 다음과 같다.

@Getter
@Setter
@Builder
public class AzureDeviceFirmwareUpdateInfo {
    private String configurationId;
    private String fwVersion;
    private String fwPackageURI;
    private String fwPackageCheckValue;

    public java.util.Map firmwareUpdateInfoToMap(){
        java.util.Map temp = new HashMap();
        temp.put("fwVersion", fwVersion);
        temp.put("fwPackageURI", fwPackageURI);
        temp.put("fwPackageCheckValue", fwPackageCheckValue);

        java.util.Map map = new HashMap();
        map.put("properties.desired.firmware", temp);
        return map;
    }

    public java.util.Map getMetricQueries(){
        java.util.Map temp = new HashMap();

        temp.put(FirmwareStatus.CURRENT.label, FirmwareUpdate.CURRENT.query(configurationId) );
        temp.put(FirmwareStatus.REBOOTING.label, FirmwareUpdate.REBOOTING.query(configurationId) );
        temp.put(FirmwareStatus.ERROR.label, FirmwareUpdate.ERROR.query(configurationId) );
        temp.put(FirmwareStatus.ROLLEDBACK.label, FirmwareUpdate.ROLLEDBACK.query(configurationId) );
        temp.put(FirmwareStatus.APPLYING.label, FirmwareUpdate.APPLYING.query(configurationId) );

        return temp;
    }

    public enum FirmwareStatus{
        CURRENT("current"),
        REBOOTING("rebooting"),
        ERROR("error"),
        ROLLEDBACK("rolledback"),
        APPLYING("applying"),
        DOWNLOADING("downloading"),
        VERIFYING("verifying");

        private String label;
        FirmwareStatus(String label){
            this.label = label;
        }
    }

    public enum FirmwareUpdate{
        CURRENT(Arrays.asList(FirmwareStatus.CURRENT)),
        REBOOTING(Arrays.asList(FirmwareStatus.REBOOTING)),
        ERROR(Arrays.asList(FirmwareStatus.ERROR)),
        ROLLEDBACK(Arrays.asList(FirmwareStatus.ROLLEDBACK)),
        APPLYING(Arrays.asList(FirmwareStatus.APPLYING, FirmwareStatus.DOWNLOADING, FirmwareStatus.VERIFYING));

        List<FirmwareStatus> statusList;

        FirmwareUpdate(List<FirmwareStatus> statusList){
            this.statusList=statusList;
        }

        String query(String configurationId){

            String temp = "SELECT deviceId FROM devices WHERE configurations.[[" + configurationId + "]].status=\'Applied\' AND ";

            if (statusList.size() == 0) {
                throw new ArrayIndexOutOfBoundsException("the size of status list should be positive number");
            } else if (statusList.size() == 1) {
                temp += "properties.reported.firmware.fwUpdateStatus=\'" + statusList.get(0).label + "\'";
            } else {
                temp += "(";

                int count=0;
                for (FirmwareStatus status : statusList) {
                    temp += "properties.reported.firmware.fwUpdateStatus=\'" + status.label + "\'";
                    count++;
                    if(count < statusList.size()){
                        temp+=" OR ";
                    }
                }
                temp += ")";
            }
            return temp;
        }
    }
}
@Component
public class AzureIotHubConfigurationManager {

    public void addConfiguration(String serviceCode, String deviceModelCode, AzureDeviceFirmwareUpdateInfo azureDeviceFirmwareUpdateInfo) throws IOException, ServiceNotFoundException, IotHubException, DeviceModelNotFoundException {
        // 1. serviceCode validation
        // 2. deviceModelCode validation

        RegistryManager registryManager = RegistryManager.createFromConnectionString();
        Configuration configuration = new Configuration(azureDeviceFirmwareUpdateInfo.getConfigurationId());

        ConfigurationContent configurationContent = new ConfigurationContent();
        configurationContent.setDeviceContent(azureDeviceFirmwareUpdateInfo.firmwareUpdateInfoToMap());

        ConfigurationMetrics configurationMetrics = new ConfigurationMetrics();
        configurationMetrics.setQueries( azureDeviceFirmwareUpdateInfo.getMetricQueries() );

        configuration.setContent(configurationContent);
        configuration.setTargetCondition("tags.serviceCode= \'"+serviceCode+"\'"+
                                        " AND " +
                                        " tags.deviceModelCode = \'"+deviceModelCode+"\'");
        configuration.setPriority(20);

        registryManager.addConfiguration(configuration);

    }
}

MS에서 기본적으로 제공하는 Sample을 보다 간결하게 정리하고 유지보수성을 높였다.

각자 필요에 맞게 Logic을 추가로 구성해주면 IoT Hub의 Firmware Update를 보다 편하게 사용할 수 있다.

 

<추가가능한 로직>

- 값의 유효성이나 비지니스 로직을 고려한 Validation Logic (e.g. 서비스코드, 디바이스모델코드)

- Map에 넣을 때 key값을 변수값으로 바꿀수 있도록 처리(e.g. ${azure.firmware.version} )

- AzureDeviceFirmwareUpdateInfo내에 Target Condition포함 (복수개의 target condition 사용시 개선방법)

- Priority의 경우 값이 클 경우 우선순위가 앞에 해당한다. 기존에 Configuration과 우선순위를 비교하여 작업하는 로직 추가가능