1.1.5.5. カスタムウィジェット・microserviceコード例を確認する

本項では、以下の実装において必要なカスタムウィジェット・microserviceのコード例を記載しています。

  • 実装2: IoT機器からの画像送信・Things Cloudでの画像表示

なお、本書中の設定値の「< >」の表記については、ご利用の環境により各自入力いただく箇所となります("<"から">"までを設定値に置き換えてください)。

注釈

  • 本項では例として、開発用ディレクトリを「C:\tmp」としています。お使いの環境に合わせて読み替えてください。

microservice用コードを確認する

本実装のためのmicroservice作成用コードをまとめています。

注釈

  • 本項では、標準提供されていない「microservice」機能を利用しています。
    「microservice」のご利用にあたっては、提供環境や費用などの諸条件について個別対応とさせていただいております。
    「microservice」機能に関しては iot-app@ntt.com までご相談ください。
    案件相談、提供環境や費用などの諸条件に関しては iot-info@ntt.com までご相談ください。
  • 本項では開発用ディレクトリを「C:\tmp\microservice」としています。お使いの環境に合わせて読み替えてください。

「app.ts」

C:\tmp\microservice\app.ts

"use strict";

require("dotenv").config();

import * as express from "express";
import { routes } from "./routes";

const app: express.Express = express();

// Application endpoints
routes(app);

// Server listening on port 80
app.use(express.json());
app.listen(80);
console.log(`node-microservice started on port 80`);

「routes.ts」

C:\tmp\microservice\routes.ts

"use strict";

import * as express from "express";
import * as AWS from "aws-sdk";
import { FetchClient, IFetchOptions, IAuthentication } from "@c8y/client";

export function routes(app: express.Express): any {
const baseUrl = process.env.C8Y_BASEURL;
        // Temporary Credentialsの取得
        app.route("/tempcred").get((req, res) => {
                const authorization = req.get("Authorization");
                const cookie = req.get("Cookie");
                const headers = { authorization, cookie };
                const auth = new MicroserviceClientRequestAuth(headers);
                const fetchClient = new FetchClient(auth, baseUrl);

                (async () => {
                        try {
                                const options: IFetchOptions = {
                                        method: "GET",
                                        headers: { "Content-Type": "application/json" },
                                };
                                //keystore APIを叩く
                                const resp = await fetchClient.fetch(`/inventory/managedObjects/${req.query.keystoreid}`, options);
                                //結果(wasabi鍵情報)をcredsに入れる
                                const creds = await resp.json();
                                //AWS STSインスタンスを生成
                                const sts = new AWS.STS({
                                        apiVersion: "2011-06-15",
                                        endpoint: new AWS.Endpoint("wasabisys.com"),
                                        accessKeyId: creds.com_WasabiCredentials.accessKey,
                                        secretAccessKey: creds.com_WasabiCredentials.secretKey,
                                        region: "us-east-1",
                                });
                                //Temporary Credentialsを取得し返す
                                sts.getSessionToken({ DurationSeconds: 900 }, (err, data) => {
                                        if (err) {
                                                res.json(err);
                                        } else {
                                                data.Credentials["region"] = creds.com_WasabiCredentials.region;
                                                data.Credentials["bucket"] = creds.com_WasabiCredentials.bucket;
                                                console.log(data.Credentials);
                                                res.json(data.Credentials);
                                        }
                                });
                        } catch (err) {
                                res.json(err);
                        }
                })();
        });
}

/**
* Allows to use either Cookie-Auth or Basic-Auth
* of a microservice client request header
* for Authorization to the Cumulocity API.
*/
class MicroserviceClientRequestAuth implements IAuthentication {
        xsrfToken: any;
        authTokenFromCookie: any;
        authTokenFromHeader: any;

/**
* Authenticates using the credentials which were
* provided within the request headers of the
* client call to the microservice.
* @param headers
*/
        constructor(headers: any = {}) {
                this.xsrfToken = this.getCookieValue(headers, "XSRF-TOKEN");
                this.authTokenFromCookie = this.getCookieValue(headers, "authorization");
                this.authTokenFromHeader = headers.authorization;
        }
        updateCredentials(credentials = {}) {
                return undefined;
        }
        getFetchOptions(options) {
                const headers = Object.assign(
                        { Authorization: this.authTokenFromCookie ? `Bearer ${this.authTokenFromCookie}` : this.
                        authTokenFromHeader },
                        this.xsrfToken ? { "X-XSRF-TOKEN": this.xsrfToken } : undefined
                );
                options.headers = Object.assign(headers, options.headers);
                return options;
        }
        getCometdHandshake(config: any = {}) {
                const KEY = "com.cumulocity.authn";
                const xsrfToken = this.xsrfToken;
                let token = this.authTokenFromCookie;
                if (!token && this.authTokenFromHeader) {
                        token = this.authTokenFromHeader.replace("Basic ", "").replace("Bearer ", "");
                }
                const ext = (config.ext = config.ext || {});
                ext[KEY] = Object.assign(ext[KEY] || {}, Object.assign({ token }, xsrfToken ? { xsrfToken } : undefined));
                return config;
        }
        logout() {
                if (this.authTokenFromCookie) {
                        delete this.authTokenFromCookie;
                }
                if (this.authTokenFromHeader) {
                        delete this.authTokenFromHeader;
                }
                if (this.xsrfToken) {
                        delete this.xsrfToken;
                }
        }
        getCookieValue(headers, name) {
                try {
                        const value = headers && headers.cookie && headers.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)");
                        return value ? value.pop() : undefined;
                } catch (ex) {
                        return undefined;
                }
        }
}

「package.json」

C:\tmp\microservice\package.json

{
        "name": "wasabi-img",
        "version": "1.0.0",
        "description": "Things Cloud microservice application",
        "main": "app.js",
        "dependencies": {
                "@c8y/client": "^1004.6.15",
                "@types/express": "^4.17.3",
                "@types/node": "^13.9.1",
                "@types/source-map-support": "^0.5.1",
                "aws-sdk": "^2.1220.0",
                "dotenv": "^8.1.0",
                "express": "^4.17.0",
                "source-map-support": "^0.5.16"
        },
        "scripts": {
                "start": "node app.js",
                "build": "tsc -p .",
                "build:watch": "tsc -p . -w",
                "microservice2": "rm wasabi-img.tar && rm wasabi-img.zip && tsc -p . && docker rmi -f wasabi-img && docker build -t wasabi-img . && docker save -o wasabi-img.tar wasabi-img && jar cvf wasabi-img.zip cumulocity.json wasabi-img.tar",
                "microservice": "tsc -p . && docker rmi -f wasabi-img && docker build -t wasabi-img . && docker save -o wasabi-img.tar wasabi-img && jar cvf wasabi-img.zip cumulocity.json wasabi-img.tar",
                "test": "echo \"Error: no test specified\" && exit 1"
        },
        "author": "Your name",
        "license": "MIT"
}

「tsconfig.json」

C:\tmp\microservice\tsconfig.json

{
        "compilerOptions": {
                "target": "es5",
                "module": "commonjs",
                "sourceMap": true,
                "forceConsistentCasingInFileNames": true
        },
        "exclude": ["node_modules"]
}

「Dockerfile」

C:\tmp\microservice\Dockerfile

FROM node:alpine

WORKDIR /usr/app

COPY ./package.json ./
RUN npm install
COPY ./*.js ./

CMD ["npm", "start"]

「cumulocity.json」

C:\tmp\microservice\cumulocity.json

{
        "apiVersion": "v1",
        "version": "1.0.0-SNAPSHOT",
        "contextPath": "wasabi-img",
        "provider": {
                "name": "NTT Communications"
        },
        "isolation": "MULTI_TENANT",
        "requiredRoles": [],
        "roles": []
}

カスタムウィジェット用コードを確認する

本実装のためのカスタムウィジェット作成用コードをまとめています。

注釈

  • 本項では開発用ディレクトリを「C:\tmp\event-widget」としています。お使いの環境に合わせて読み替えてください。

「package.json」

C:\tmp\event-widget\package.json

{
  "name": "cockpit",
  "version": "0.1.0",
  "description": "[TEST]View Image from wasabi",
  "main": "index.js",
  "scripts": {
    "start": "c8ycli server -u https://<Things Cloudのテナント名>.je1.thingscloud.ntt.com",
    "build": "c8ycli build",
    "deploy": "c8ycli deploy",
    "test": "karma start karma.conf.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@angular/animations": "8.2.13",
    "@angular/cdk": "8.2.3",
    "@angular/common": "8.2.13",
    "@angular/compiler": "8.2.13",
    "@angular/core": "^8.2.13",
    "@angular/forms": "8.2.13",
    "@angular/platform-browser": "8.2.13",
    "@angular/platform-browser-dynamic": "8.2.13",
    "@angular/router": "8.2.13",
    "@angular/upgrade": "8.2.13",
    "@aws-sdk/client-s3": "^3.347.1",
    "@c8y/ng1-modules": "1006.6.32",
    "@c8y/ngx-components": "1006.6.32",
    "@c8y/style": "1006.6.32",
    "@observablehq/plot": "^0.3.2",
    "@types/dom-mediacapture-record": "^1.0.7",
    "angular": "1.6.9",
    "aws-sdk": "^2.1392.0",
    "core-js": "^3.4.0",
    "cumulocity-ui-build": "http://resources.cumulocity.com/webapps/ui/9.20.16.tar.gz",
    "hls": "0.0.1",
    "hls.js": "^0.14.16",
    "jquery": "^3.5.1",
    "rxjs": "~6.4.0",
    "url-search-params-polyfill": "6.0.0",
    "zone.js": "~0.9.1"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "0.803.23",
    "@angular/compiler-cli": "8.2.13",
    "@angular/language-service": "8.2.13",
    "@c8y/cli": "1006.6.32",
    "@types/jasmine": "^3.5.7",
    "bootstrap": "^4.5.0",
    "css-loader": "^3.4.2",
    "html-loader": "^1.1.0",
    "jasmine": "^3.6.1",
    "jasmine-core": "^3.6.0",
    "karma": "^5.1.0",
    "karma-chrome-launcher": "^3.1.0",
    "karma-jasmine": "^3.3.1",
    "karma-jasmine-html-reporter": "^1.5.4",
    "karma-safari-launcher": "^1.0.0",
    "karma-sourcemap-loader": "^0.3.7",
    "karma-webpack": "^4.0.2",
    "mockdate": "^3.0.5",
    "to-string-loader": "^1.1.6",
    "ts-loader": "^8.0.1",
    "typescript": "3.5.3",
    "webpack-dev-server": "^3.11.2"
  },
  "c8y": {
    "application": {
      "name": "cockpit",
      "contextPath": "cockpit",
      "key": "cockpit-application-key",
      "brandingCssVars": {
        "brand-primary": "#1357ad",
        "brand-complementary": "#a4c8cd",
        "brand-dark": "#0e407f",
        "brand-light": "#94aac5",
        "gray-text": "#444",
        "link-color": "#1357ad",
        "link-hover-color": "#0b3468",
        "body-background-color": "#f8f8f8",
        "brand-logo-img": "url(/apps/public/ui-assets-ntt/logo.svg?nocache=8324918234)",
        "brand-logo-img-height": "25%",
        "navigator-platform-logo": "url(/apps/public/ui-assets-ntt/tenant-logo.svg?nocache=8324918234)",
        "navigator-platform-logo-height": "23%",
        "navigator-font-family": "'Frutiger Neue', 'Helvetica Neue', Helvetica, Arial, sans-serif",
        "navigator-app-name-size": "16px",
        "navigator-app-icon-size": "0",
        "navigator-bg-color": "#1357ad",
        "navigator-header-bg": "white",
        "navigator-title-color": "#1357ad",
        "navigator-text-color": "rgba(255, 255, 255, 0.7)",
        "navigator-separator-color": "rgba(255, 255, 255, 0.05)",
        "navigator-active-color": "#1357ad",
        "navigator-active-bg": "#fdd34c",
        "header-color": "white",
        "header-text-color": "#1357ad",
        "header-hover-color": "var(--brand-primary, #1357ad)",
        "header-border-color": "rgba(117, 117, 117, 0.05)",
        "font-family-base": "'Frutiger Neue', 'Helvetica Neue', Helvetica, Arial, sans-serif",
        "headings-font-family": "'Frutiger Neue', 'Helvetica Neue', Helvetica, Arial, sans-serif"
      },
      "extraCssUrls": [
        "/apps/public/ui-assets-ntt/style.css"
      ],
      "faviconUrl": "/apps/public/ui-assets-ntt/favicon.ico?nocache=08563753891373449",
      "globalTitle": "NTT",
      "docsBaseUrl": "https://developer.ntt.com/iot/docs",
      "tabsHorizontal": true,
      "upgrade": true,
      "rightDrawer": false,
      "sensorPhone": false,
      "contentSecurityPolicy": "base-uri 'self'; default-src 'self' 'unsafe-inline' http: https: ws: wss:; connect-src 'self' *.billwerk.com http: https: ws: wss: blob:;  script-src 'self' open.mapquestapi.com *.twitter.com *.twimg.com swc.safie.link 'unsafe-inline' 'unsafe-eval' data:; style-src * 'unsafe-inline' blob:; img-src * data: blob:; font-src * data:; frame-src *; media-src * data: blob:;"
    },
    "cli": {}
  }
}

「app.module.ts」

C:\tmp\event-widget\app.module.ts

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule as NgRouterModule } from '@angular/router';
import { UpgradeModule as NgUpgradeModule } from '@angular/upgrade/static';
import { HttpClientModule } from '@angular/common/http';

import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { CollapseModule } from 'ngx-bootstrap/collapse';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { TimepickerModule } from 'ngx-bootstrap/timepicker';

import { CoreModule, RouterModule } from '@c8y/ngx-components';
import {
        DashboardUpgradeModule,
        UpgradeModule,
        HybridAppModule,
        UPGRADE_ROUTES
} from '@c8y/ngx-components/upgrade';
import { AssetsNavigatorModule } from '@c8y/ngx-components/assets-navigator';
import { CockpitDashboardModule } from '@c8y/ngx-components/context-dashboard';
import { ReportsModule } from '@c8y/ngx-components/reports';
import { SensorPhoneModule } from '@c8y/ngx-components/sensor-phone';
import { BinaryFileDownloadModule } from '@c8y/ngx-components/binary-file-download';

import { S3Module } from './custom-widgets/s3.module';

import { C8yService } from './custom-widgets/c8y.service';

@NgModule({
        imports: [
                // Upgrade module must be the first
                UpgradeModule,
                BrowserAnimationsModule,

                BsDropdownModule.forRoot(),
                CollapseModule.forRoot(),
                BsDatepickerModule.forRoot(),
                TimepickerModule.forRoot(),

                RouterModule.forRoot(),
                NgRouterModule.forRoot([...UPGRADE_ROUTES], { enableTracing: false, useHash: true }),
                CoreModule.forRoot(),
                AssetsNavigatorModule,
                ReportsModule,
                NgUpgradeModule,
                DashboardUpgradeModule,
                CockpitDashboardModule,
                SensorPhoneModule,
                BinaryFileDownloadModule,

                HttpClientModule,
                S3Module,
        ],
        declarations: [
        ],
        entryComponents: [
        ],
        providers: [
                C8yService,
        ],
        schemas: [
                CUSTOM_ELEMENTS_SCHEMA
        ]
})
export class AppModule extends HybridAppModule {
        constructor(protected upgrade: NgUpgradeModule) {
                super();
        }
}

「index.ts」

C:\tmp\event-widget\index.ts

import './polyfills';
import './ng1';

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

declare const __MODE__: string;
if (__MODE__ === 'production') {
  enableProdMode();
}

export function bootstrap() {
  return platformBrowserDynamic()
    .bootstrapModule(AppModule).catch((err) => console.log(err));
}

「karma.conf.js」

C:\tmp\event-widget\karma.conf.js

// Karma configuration
// Generated on Mon Jul 27 2020 10:38:49 GMT+0900 (日本標準時)

module.exports = function(config) {
  config.set({

    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: './custom-widgets',


    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ['jasmine'],


    plugins: [  // 追記箇所
      require("karma-jasmine"),
      require("karma-chrome-launcher"),
      require("karma-safari-launcher"),
      require("karma-jasmine-html-reporter"),
      require("karma-webpack"),
      require("karma-sourcemap-loader")
    ],

    client:{
      clearContext: false
    },

    // list of files / patterns to load in the browser
    files: [
      "../node_modules/zone.js/dist/zone.js"
    ],


    // list of files / patterns to exclude
    exclude: [
    ],


    // preprocess matching files before serving them to the browser
    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
    preprocessors: {

    },

    webpack: { // 追記箇所: webpack の設定
      resolve: {
        extensions: [".ts", ".js"]
      },
      module: {
        rules: [
          { test: /\.ts$/, use: [{ loader: "ts-loader" }] },
          { test: /\.html$/, use: [{ loader: "html-loader" }] }, // 外部htmlファイルの読み込み用
          { test: /\.css$/, loaders: ["to-string-loader", "style-loader", "css-loader"] } // 外部cssファイルの読み込み用
        ]
      },
      mode: "development",
      devtool: 'inline-source-map'
    },



    // test results reporter to use
    // possible values: 'dots', 'progress'
    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
    reporters: ['progress', 'kjhtml'],


    // web server port
    port: 9876,


    // enable / disable colors in the output (reporters and logs)
    colors: true,


    // level of logging
    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
    logLevel: config.LOG_INFO,


    // enable / disable watching file and executing tests whenever any file changes
    autoWatch: true,


    // start these browsers
    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
    browsers: ['Chrome'],


    // Continuous Integration mode
    // if true, Karma captures browsers, runs the tests and exits
    singleRun: false,

    // Concurrency level
    // how many browser should be started simultaneous
    concurrency: Infinity,

    // timeout
    browserNoActivityTimeout: 60000
  })
}

「ng1.ts」

C:\tmp\event-widget\ng1.ts

import '@c8y/ng1-modules/core';
import '@c8y/ng1-modules/imageWidget/cumulocity.json';
import '@c8y/ng1-modules/assetPropertyWidget/cumulocity.json';
import '@c8y/ng1-modules/devicePropertyWidget/cumulocity.json';
import '@c8y/ng1-modules/alarms/cumulocity.json';
import '@c8y/ng1-modules/assetTable/cumulocity.json';
import '@c8y/ng1-modules/eventsBinary/cumulocity.json';
import '@c8y/ng1-modules/devicemanagement-alarmList/cumulocity.json';
import '@c8y/ng1-modules/deviceSelector/cumulocity.json';
import '@c8y/ng1-modules/kpi/cumulocity.json';
import '@c8y/ng1-modules/kpiAdmin/cumulocity.json';
import '@c8y/ng1-modules/devicemanagement-location/cumulocity.json';
import '@c8y/ng1-modules/dashboard2/cumulocity.json';
import '@c8y/ng1-modules/dashboardUI/cumulocity.json';
import '@c8y/ng1-modules/groupsHierarchy/cumulocity.json';
import '@c8y/ng1-modules/measurements/cumulocity.json';
import '@c8y/ng1-modules/map/cumulocity.json';
import '@c8y/ng1-modules/alarmAssets/cumulocity.json';
import '@c8y/ng1-modules/deviceControlMessage/cumulocity.json';
import '@c8y/ng1-modules/deviceControlRelay/cumulocity.json';
import '@c8y/ng1-modules/deviceControlRelayArray/cumulocity.json';
import '@c8y/ng1-modules/cockpit-cockpitConfig/cumulocity.json';
import '@c8y/ng1-modules/cockpit-assetCount/cumulocity.json';
import '@c8y/ng1-modules/cockpit-alarmRecent/cumulocity.json';
import '@c8y/ng1-modules/cockpit-reports/cumulocity.json';
import '@c8y/ng1-modules/cockpit-smartRulesUI/cumulocity.json';
import '@c8y/ng1-modules/cockpit-dataPointExplorerUI/cumulocity.json';
import '@c8y/ng1-modules/alarmsEventsExplorer/cumulocity.json';
import '@c8y/ng1-modules/deviceDatabase4/cumulocity.json';
import '@c8y/ng1-modules/modbusWidget4/cumulocity.json';
import '@c8y/ng1-modules/scada/cumulocity.json';
import '@c8y/ng1-modules/imageWidget/cumulocity.json';
import '@c8y/ng1-modules/htmlWidget/cumulocity.json';
import '@c8y/ng1-modules/applicationLinks/cumulocity.json';
import '@c8y/ng1-modules/quickLinks/cumulocity.json';
import '@c8y/ng1-modules/helpAndSupport/cumulocity.json';
import '@c8y/ng1-modules/eventList/cumulocity.json';
import '@c8y/ng1-modules/export/cumulocity.json';
import '@c8y/ng1-modules/dataPointTable/cumulocity.json';
import '@c8y/ng1-modules/switchDisplay/cumulocity.json';
import '@c8y/ng1-modules/trafficLightWidget/cumulocity.json';
import '@c8y/ng1-modules/infoGauge/cumulocity.json';

「polyfills.ts」

C:\tmp\event-widget\polyfills.ts

/**
 * This file includes polyfills needed by Angular and is loaded before the app.
 * You can add your own extra polyfills to this file.
 *
 * This file is divided into 2 sections:
 *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
 *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
 *      file.
 *
 * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
 * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
 * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
 *
 * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
 */

/***************************************************************************************************
 * BROWSER POLYFILLS
 */

/** IE9, IE10, IE11, Evergreen browsers require the following polyfills. */
import "@angular-devkit/build-angular/src/angular-cli-files/models/es5-jit-polyfills.js";
import "@angular-devkit/build-angular/src/angular-cli-files/models/es5-polyfills.js";
import "@angular-devkit/build-angular/src/angular-cli-files/models/jit-polyfills.js";

/**
 * By default, zone.js will patch all possible macroTask and DomEvents
 * user can disable parts of macroTask/DomEvents patch by setting following flags
 */

(window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
(window as any).__zone_symbol__BLACK_LISTED_EVENTS = [
  "scroll",
  "mousemove",
  "message",
  "mouseover",
  "mouseout",
  "mouseenter",
  "mouseleave",
]; // disable patch specified eventNames

/***************************************************************************************************
 * Zone JS is required by default for Angular itself.
 */
import "zone.js/dist/zone"; // Included with Angular CLI.

/***************************************************************************************************
 * APPLICATION IMPORTS
 */
import "url-search-params-polyfill";
import { addPolyfills } from "@c8y/ngx-components/polyfills";
addPolyfills();

「tsconfig.json」

C:\tmp\event-widget\tsconfig.json

{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "sourceMap": true,
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es5",
    "typeRoots": [
      "node_modules/@types"
    ],
    "lib": [
      "dom",
      "es2016"
    ],
  }
}

「custom-widgets\image-widget\image-widget.component.css」

C:\tmp\event-widget\custom-widgets\image-widget\image-widget.component.css

.video-container {
        text-align: center;
}

.sticky-container {
        position: sticky;
        top: 0px;
        background-color: #ffffff;
        z-index: 100;
}

.video {
        width: 95%;
        margin: auto;
}
i.fa-play-circle-o {
        font-size: 30px;
}
.play-btn {
        color: #3f4a50;
        cursor: pointer;
}
.play-btn {
        color: #75bcf1;
}
.timeline-list>div.active .timeline-item-content .list-item-actions:before, .timeline-list>li.active .timeline-item-content .list-item-actions:before {
        display: none !important;
}

「custom-widgets\image-widget\image-widget.component.html」

C:\tmp\event-widget\custom-widgets\image-widget\image-widget.component.html

<div class="row" style="margin: 8px">
        <div class="col-xs-8 col-sm-7 sticky-container">
                <div class="card-header sticky-container">
                        <h4 class="card-title text-primary">撮影画像</h4>
                </div>
                <div class="image-container">
                        <img #img width="100%" />
                </div>
        </div>
        <div class="col-xs-4 col-sm-5">
                <div class="card">
                        <div class="card-header sticky-container">
                                <h4 class="card-title text-primary">イベント一覧</h4>
                        </div>
                        <div class="card-inner-scroll">
                                <ul class="timeline-list">
                                        <li class="timeline-list-item flex-row" [class.active]="event.active" *ngFor="let event of events | slice: 0:listNumber">
                                                <small class="timeline-item-date text-right">
                                                        <i c8y-icon="clock-o" class="fa fw fa-clock-o"></i>
                                                        <strong>{{ event.time | date: "yy/MM/dd" }}</strong>
                                                        <br />
                                                        <span class="text-muted" title="このイベントの最終発生日時">{{ event.time | date: "shortTime" }}</span>
                                                </small>
                                                <div class="timeline-item-content flex-row wrap play-btn" (click)="onPlayClick(event)" (click)="event.isCollapsed = !event.isCollapsed">
                                                        <div class="list-item-actions">
                                                                <button type="button" title="Expand" class="collapse-btn">
                                                                        <i c8yIcon="chevron-down"></i>
                                                                </button>
                                                        </div>
                                                        <div class="list-item-body">
                                                                <span>
                                                                        {{ event.text }}<br />
                                                                        <br class="visible-xs visible-sm" />
                                                                        <small>
                                                                                <a (click)="$event.stopPropagation()" class="text-muted" href="#/device/{{ event.source.id }}"> <i c8yIcon="cog"></i>{{ event.source.name }} </a>
                                                                        </small>
                                                                </span>
                                                        </div>
                                                        <div class="collapse no-cursor m-l-40" [collapse]="!event.isCollapsed" [isAnimated]="true" (click)="$event.stopPropagation()">
                                                                <div>
                                                                        <div class="legend form-block" translate>詳細</div>
                                                                        <dl class="dl-inline small text-muted">
                                                                                <dt>タイプ</dt>
                                                                                <dd>{{ event.type }}</dd>
                                                                        </dl>
                                                                        <dl class="dl-inline small text-muted">
                                                                                <dt translate>情報</dt>
                                                                                <dd>{{ event.com_ImageDownloadable | json }}</dd>
                                                                        </dl>
                                                                        <dl class="dl-inline small text-muted">
                                                                                <dt translate>サーバー時間</dt>
                                                                                <dd>{{ event.creationTime | date: "yy/MM/dd HH:mm:ss" }}</dd>
                                                                        </dl>
                                                                </div>
                                                        </div>
                                                </div>
                                        </li>
                                </ul>
                        </div>
                        <button id="more" type="button" class="btn btn-primary btn-lg btn-block" (click)="onMoreClick()"><i class="fa fw fa-repeat"></i>&nbsp;Load more...</button>
                </div>
        </div>
</div>

「custom-widgets\image-widget\image-widget.component.ts」

C:\tmp\event-widget\custom-widgets\image-widget\image-widget.component.ts

import { Component, Input, OnInit, ViewChild, ElementRef } from "@angular/core";
import { IEvent, IResultList } from "@c8y/client";
import { ImageService } from "../image.service";
import { C8yService, EventFilter } from "../c8y.service";

@Component({
        selector: "image-widget",
        templateUrl: "./image-widget.component.html",
        styleUrls: ["./image-widget.component.css"],
})
export class ImageWidget implements OnInit {
        private static EVENT_PAGE_SIZE = 50; //取得するイベントリスト数
        listNumber = 3; //表示するイベント数

        @Input() config;
        @ViewChild("img", { static: false }) private recordedVideo: ElementRef;
        events: IEvent[];
        eventList: Promise<IResultList<IEvent>>;
        resultList: IResultList<IEvent>;
        activeEvent: IEvent;

        constructor(private imageService: ImageService, private c8yService: C8yService) {}

        ngOnInit(): void {
                const realtime = this.c8yService.getRealtime();

                this.activeEvent = null;
                this.events = [];
                const filter: EventFilter = {
                        dateFrom: "2020-01-01",
                        dateTo: "2024-12-31",
                        revert: false,
                        pageSize: ImageWidget.EVENT_PAGE_SIZE,
                };

                this.eventList = this.c8yService.getEvents({
                        deviceId: this.config.device.id,
                        filter: filter,
                });
                this.eventList.then((resultList) => this.updateResultList(resultList));

                realtime.subscribe(`/events/${this.config.device.id}`, (data) => {
                        if (this.containsEventId(this.events, data.data.data)) return;
                        this.updateEvents([data.data.data]);
                });
        }

        /**
         * targetEvent が過去のイベントと重複しているか判定する
         *
         * @param {IEvent[]} events イベントの配列
         * @param {IEvent} targetEvent ターゲットイベントの配列
         *
         * @returns true(重複している) or false(重複していない)
         */
        containsEventId(events: IEvent[], targetEvent: IEvent): boolean {
                for (const event of events) {
                        if (event.id === targetEvent.id) return true;
                }
                return false;
        }

        /**
         * 再生ボタンクリック時、Wasabi からオブジェクトをダウンロードする
         *
         * @param {IEvent} event Event
         */
        onPlayClick(event: IEvent): void {
                if (this.activeEvent) this.activeEvent.active = false;
                event.active = true;
                this.activeEvent = event;

                this.imageService.getS3Object$(event.com_ImageDownloadable).subscribe((data) => {
                        const recordedVideoUrl = URL.createObjectURL(new Blob([data.Body], { type: "image/jpeg" }));

                        this.recordedVideo.nativeElement.src = recordedVideoUrl;
                        console.debug(data.Body.length);
                });
        }

        /**
         * Load more クリック時に過去のアラームを読み込む
         */
        onMoreClick(): void {
                this.listNumber += 3;
        }

        private updateResultList(resultList: IResultList<IEvent>): void {
                this.resultList = resultList;
                this.updateEvents(resultList.data);
        }

        private updateEvents(events: IEvent[]): void {
                for (let i = 0; i < events.length; i++) {
                        if (!events[i].com_ImageDownloadable) continue;
                        if (this.events.indexOf(events[i]) >= 0) continue;
                        this.events.push(events[i]);
                }
                this.events.sort((a, b) => {
                        if (a.time > b.time) return -1;
                        if (a.time < b.time) return 1;
                        return 0;
                });
        }
}

「custom-widgets\image-widget\image-widget-config.component.html」

C:\tmp\event-widget\custom-widgets\image-widget\image-widget-config.component.html

<div class="form-group">
        <c8y-form-group>
                <label translate>画像表示・画像情報一覧 設定</label>
        </c8y-form-group>
</div>

「custom-widgets\image-widget\image-widget-config.component.ts」

C:\tmp\event-widget\custom-widgets\image-widget\image-widget-config.component.ts

import { Component, Input } from '@angular/core';

@Component({
        selector: 'image-widget-config',
        templateUrl: './image-widget-config.component.html'
})
export class ImageWidgetConfig {
        @Input() config: any = {};
}

「custom-widgets\c8y.service.ts」

C:\tmp\event-widget\custom-widgets\c8y.service.ts

import { Injectable } from "@angular/core";
import {
        EventService,
        FetchClient,
        IResultList,
        IEvent,
        Realtime
} from "@c8y/client";

@Injectable()
export class C8yService {

        constructor(
                private eventService: EventService,
                private fetchClient: FetchClient
        ) { }

        /**
         * デバイスの Event 情報を取得する
         *
         * @param param {deviceId?, filter?} デバイスID, event 取得の際に指定する filter
         *
         * @returns ResultList<IEvent> の Promise
         */
        getEvents(param: {
                deviceId?: string;
                filter?: EventFilter;
        }): Promise<IResultList<IEvent>> {
                // 渡された filter を変更すると解決しづらいバグにつながるため、deep copy
                const requestFilter: any = (param.filter) ? JSON.parse(JSON.stringify(param.filter)) : {};
                if (param.deviceId) {
                        requestFilter.source = param.deviceId;
                }
                requestFilter.withTotalPages = true;

                return this.eventService.list(requestFilter);
        }


        /**
         * リアルタイムにイベントなどを取得するための Realtime オブジェクトを取得する
         *
         * @returns Realtime のインスタンス
         */
        getRealtime(): Realtime {
                return new Realtime(this.fetchClient);
        }

}

/**
 * getEvents で利用される filter。
 */
export interface EventFilter {
        /** ISO-8601 形式(2021-05-17T12:00:13.527+09:00 など) */
        dateFrom?: string;
        /** ISO-8601 形式(2021-05-17T12:00:13.527+09:00 など) */
        dateTo?: string;
        /**
         * dateTo または dateFrom とセットで利用する必要がある。
         * true の時、古い順、false の時、新しい順となる。
         */
        revert?: boolean;
        pageSize?: number;
        fragmentType?: string;
        source?: string;
        withTotalPages?: boolean;
}

「custom-widgets\image.service.ts」

C:\tmp\event-widget\custom-widgets\image.service.ts

import { Injectable } from "@angular/core";
import { IFetchOptions, FetchClient } from "@c8y/client";
import { Observable } from "rxjs";
import { IMediaDownloadable } from "./image.type";
import * as AWS from "aws-sdk";

@Injectable()
export class ImageService {
  private s3: AWS.S3;

  constructor(private fetchClient: FetchClient) {}

  /**
   * AWS S3 API から指定オブジェクトをダウンロードする
   *
   * @param {IMediaDownloadable} mediaDownloadable カスタムフラグメント
   *
   * @returns オブジェクトが通知される observable
   */
  getS3Object$(mediaDownloadable: IMediaDownloadable): Observable<any> {
    return new Observable((observer) => {
      (async () => {
        try {
          const options: IFetchOptions = {
            method: "GET",
            headers: { "Content-Type": "application/json" },
          };
          const resp = await this.fetchClient.fetch(`/service/wasabi-img/tempcred?keystoreid=${mediaDownloadable.keystoreId}`, options);
          const creds = await resp.json();
          this.s3 = this.createS3Instance(creds);
          this.s3.getObject(
            {
              Bucket: creds.bucket,
              Key: mediaDownloadable.fileInformation.name,
            },
            (err, data) => {
              if (err) {
                console.debug(err);
                observer.error(err);
              } else {
                observer.next(data);
              }
            }
          );
        } catch (err) {
          console.log("error occurred:" + err);
        }
      })();
    });
  }

  /**
   * AWS S3 のインスタンスを作成する
   *
   * @param {any} wasabiCredentials Wasabi keystore ID の wasabiCredentials フラグメント
   *
   * @returns AWS S3 インスタンス
   */
  private createS3Instance(wasabiCredentials: any): AWS.S3 {
    return new AWS.S3({
      endpoint: "s3.ap-northeast-1-ntt.wasabisys.com",
      accessKeyId: wasabiCredentials.AccessKeyId,
      secretAccessKey: wasabiCredentials.SecretAccessKey,
      sessionToken: wasabiCredentials.SessionToken,
      region: wasabiCredentials.region,
      signatureVersion: "v4",
    });
  }
}

「custom-widgets\image.type.ts」

C:\tmp\event-widget\custom-widgets\image.type.ts

export interface IMediaDownloadable {
                fileInformation: {
                                lastUpdated: string;
                                size: number;
                                name: string;
                                encoding: string;
                                'mime-type': string;
                };
                downloadURL: string;
                keystoreId: string;
}

export interface IVideoDownloadable extends IMediaDownloadable { }

「custom-widgets\preview-image.ts」

C:\tmp\event-widget\custom-widgets\preview-image.ts

export const previewImage = 'data:image/png;base64,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';

「custom-widgets\s3.module.ts」

C:\tmp\event-widget\custom-widgets\s3.module.ts

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HOOK_COMPONENTS } from '@c8y/ngx-components';

import { CollapseModule } from 'ngx-bootstrap/collapse';

import { ImageWidget } from './image-widget/image-widget.component';
import { ImageWidgetConfig } from './image-widget/image-widget-config.component';

import { ImageService } from './image.service';

import * as previewImage from './preview-image';

@NgModule({
                imports: [
                                CommonModule,
                                FormsModule,
                                CollapseModule.forRoot()
                ],
                declarations: [
                                ImageWidget,
                                ImageWidgetConfig,
                ],
                entryComponents: [
                                ImageWidget,
                                ImageWidgetConfig,
                ],
                providers: [
                                ImageService,
                                {
                                                provide: HOOK_COMPONENTS,
                                                multi: true,
                                                useValue: {
                                                id: 'image-widget.widget',
                                                label: '画像表示・画像情報一覧',
                                                previewImage: previewImage.previewImage,
                                                description: 'IoT機器で撮影した画像の一覧と、選択した画像を表示します。',
                                                component: ImageWidget,
                                                configComponent: ImageWidgetConfig
                                                }
                                },
                ],
                schemas: [
                        CUSTOM_ELEMENTS_SCHEMA
                ]
})

export class S3Module {}