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": "image-widget",
        "version": "1.0.0",
        "description": "Display image and event list.",
        "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",
                "@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.737.0",
                "core-js": "^3.4.0",
                "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';
import { C8yCommonService } from './custom-widgets/c8ycommon.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,
                C8yCommonService
        ],
        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",
                        "./test.ts"
                ],

                // 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: {
                        "./test.ts": ["webpack", "sourcemap"]
                },

                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 {
        AlarmService,
        AlarmStatus,
        EventService,
        FetchClient,
        InventoryService,
        IFetchResponse,
        IResultList,
        IManagedObject,
        IResult,
        IMeasurement,
        IMeasurementCreate,
        ISeries,
        ISeriesFilter,
        IManagedObjectBinary,
        IAlarm,
        IEvent,
        IdentityService,
        MeasurementService,
        InventoryBinaryService,
        Realtime
} from "@c8y/client";

@Injectable()
export class C8yService {
        private static MEASUREMENT_PAGE_SIZE = 50 as const;
        private static ALARM_PAGE_SIZE = 50 as const;
        private static EVENT_PAGE_SIZE = 50 as const;

        constructor(
                private measurementService: MeasurementService,
                private alarmService: AlarmService,
                private eventService: EventService,
                private identityService: IdentityService,
                private inventoryService: InventoryService,
                private inventoryBinaryService: InventoryBinaryService,
                private fetchClient: FetchClient
        ) {}

        /**
         * デバイスの Measurement 情報を取得する
         *
         * @param deviceId デバイスID
         * @param param filter に設定するパラメーター
         *
         * @returns IMeasurement[] の Promise
         */
         getMeasurements(
                 param: {
                        deviceId?: string,
                        filter?: MeasurementFilter
                 }
        ): Promise<IResultList<IMeasurement>> {
                // 渡された filter を変更すると解決しづらいバグにつながるため、deep copy
                const requestFilter : any = (param.filter)? JSON.parse(JSON.stringify(param.filter)) : {};
                if (param.deviceId) {
                        requestFilter.source = param.deviceId;
                }
                return this.measurementService.list(requestFilter);
        }

        /**
         * デバイスの Measurement Series 情報を取得する
         *
         * @param filter Measurementをfilterする条件 (deviceId,dateFrom,dateToは必須)
         *
         * @returns IResult<ISeries> の Promise
         */
        getMeasurementSeries(filter: ISeriesFilter): Promise<IResult<ISeries>> {
                return this.measurementService.listSeries(filter);
        }

        /**
         * デバイスの Alarm 情報を取得する
         *
         * @param param {deviceId?, filter?} デバイスID, alarm 取得の際に指定する filter
         *
         * @returns IResultList<IAlarm> の Promise
         */
        getAlarms(param: {
                deviceId?: string;
                filter?: AlarmFilter;
        }): Promise<IResultList<IAlarm>> {
                // 渡された 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.alarmService.list(requestFilter);
        }

        /**
         * デバイスの 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);
        }


        /**
         * 指定されたデバイスから報告された最後の Measurement を取得する。
         * 最近の Measurement の Promise が返却される。
         *
         * @param deviceId デバイスID
         * @param type measurement の type
         *
         * @returns 最近の IMeasurement の Promise
         */
        getLastMeasurement(
                deviceId: string,
                type: string
        ): Promise<IMeasurement> {
                const filter: any = {
                        type: type,
                        revert: true,
                        dateFrom: "2022-01-01",
                        source: deviceId,
                        pageSize: 1
                };

                return this.measurementService.list(filter).then(resultList => resultList.data[0]);
        }

        /**
         * MO ID をキーに MO を取得する
         *
         * @param moId MO ID
         *
         * @returns 指定IDの MO 詳細情報
         */
        getParentAssetId(moId: string): Promise<IResultList<IManagedObject>> {
                const query: any = {
                        id: moId,
                };
                const filter: any = {
                        withParents: true,
                };
                return this.inventoryService.listQuery(query, filter);
        }

        /**
         * MO ID をキーに MO を取得する
         *
         * @param moId MO ID
         *
         * @returns 指定IDの MO 詳細情報
         */
        getMO(moId: string): Promise<IResult<IManagedObject>> {
                return this.inventoryService.detail(moId);
        }

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

        /**
         * Things Cloud デバイスID、外部IDタイプを指定して外部IDを取得する
         *
         * @param       deviceId デバイスID
         * @param   externalIdType 外部IDタイプ
         * @returns     外部IDで解決する Promise(見つからない場合、null で解決)
         */
        async getExternalId(
                deviceId: string,
                externalIdType: string
        ): Promise<string> {
                const externalIds = await this.identityService.list(deviceId);
                for (const element of externalIds.data) {
                        if (element.type === externalIdType) return element.externalId;
                }
                return null;
        }

        /**
         * 複数デバイスの最新 Event 各1件を取得する
         *
         * @param deviceIds デバイスIDの配列
         * @param fragmentType カスタムフラグメント名
         *
         * @returns 各デバイスの最新 Event の配列
         */
        getLatestEvents(deviceIds: string[], fragmentType?: string): Promise<any> {
                const filter: any = {
                        dateFrom: "2020-01-01",
                        revert: false,
                        fragmentType: fragmentType || "com_ImgaeDownloadable",
                        pageSize: 1,
                        source: "",
                };

                const latestEvents = deviceIds.map((data) => {
                        filter.source = data;
                        return this.eventService.list(filter);
                });

                return Promise.all(latestEvents);
        }

        /**
         * メジャーメントデータ作成
         * @param data 作成するメジャーメント
         *
         * @returns 作成したメジャーメント
         */
        createMeasurement(data: Partial<IMeasurementCreate>): Promise<IResult<IMeasurement>> {
                return this.measurementService.create(data);
        }

        /**
         * イベントデータ作成
         * @param data 作成するイベント
         *
         * @returns 作成したイベント
         */
         createEvent(data: IEvent): Promise<IResult<IEvent>> {
                return this.eventService.create(data);
        }

        /**
         * 指定アラームID のアラームステータスを更新する
         *
         * @param alarmId アラームID
         * @param alarmStatus アラームステータス
         *
         * @returns 更新後のアラーム
         */
        updateAlarmStatus(
                alarmId: string,
                alarmStatus: AlarmStatus
        ): Promise<IResult<IAlarm>> {
                const entity: Partial<IAlarm> = {
                        id: alarmId,
                        status: alarmStatus,
                };
                return this.alarmService.update(entity);
        }

        /**
         * 指定された Binary データを inventory の binary として保存します
         *
         * @param binaryData バイナリデータ(画像など)
         *
         * @returns 保存後の IManagaedObjectBinary
         */
        createBinary(binaryData: Buffer | File | Blob): Promise<IResult<IManagedObjectBinary>> {
                return this.inventoryBinaryService.create(binaryData);
        }

        /**
         * 指定 ID の InventoryBinary を取得する
         *
         * @param binaryId InventoryBinary ID
         *
         * @returns IfetchResponse の Promise
         */
        getBinary(binaryId: number | string): Promise<IFetchResponse>{
                return this.inventoryBinaryService.download(binaryId);
        }

        /**
         * ID をキーに ManaggedObject を取得する
         *
         * @param id ManaggedObject ID
         *
         * @returns 指定IDの ManagedObject 詳細情報
         */
        async getManagedObjectById(id: string): Promise<IManagedObject> {
                return (await this.inventoryService.detail(id)).data;
        }
}

/**
 * getAlarms で利用される filter。
 */
export interface AlarmFilter {
        /** 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;
        /** filter する type 値(c8y_ThresholdAlarm など) */
        type?: string;
        source?: string;
        withTotalPages?: boolean;
}

/**
 * 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;
}

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

「custom-widgets\c8ycommon.service.ts」

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

import { Injectable } from "@angular/core";
import { C8yService } from "./c8y.service";
import { IManagedObject } from "@c8y/client";

@Injectable()
export class C8yCommonService {

        constructor(private c8yService: C8yService) {}

        /**
         * MO ID に対し、サブアセットを取得する。
         * ID がデバイスの場合、単独のデバイスが設定され、アセットの場合、再帰的に
         * 子アセットのデバイスが設定される。
         *
         * @param {string} moId MO ID
         *
         * @returns デバイスIDの配列
         */
        async getSubAssets(moId: string): Promise<string[]> {
                const mos = await this.getSubAssetMOs(moId);
                return mos.map( mo => mo.id );
        }

        /**
         * MO ID に対し、サブアセットを取得する。
         * ID がデバイスの場合、単独のデバイスが設定され、アセットの場合、再帰的に
         * 子アセットのデバイスが設定される。
         *
         * @param {string} moId MO ID
         *
         * @returns デバイス ManagedObject の配列
         */
        async getSubAssetMOs(moId: string): Promise<IManagedObject[]> {
                const devices: IManagedObject[] = [];
                await this.getSubAssetMOsImpl(moId, devices);

                return devices;
        }

        private async getSubAssetMOsImpl(moId: string, devices: IManagedObject[]): Promise<void> {
                const res = await this.c8yService.getMO(moId);

                if (res.data.childAssets.references.length > 0) {
                        // 指定されたのはグループ
                        for (const data of res.data.childAssets.references) {
                                await this.getSubAssetMOsImpl(data.managedObject.id, devices); // 再帰
                        }
                } else {
                        // 指定されたのはデバイス
                        if (devices.map( mo => mo.id).indexOf(res.data.id) > -1) return;
                        devices.push(res.data);
                }
        }

        /**
         * 指定された ManagedObject および子、孫、、の ManagedObject を取得し、返却します。
         * @param parentAssetId 親アセット(またはデバイス)ID
         * @returns 自分自身、および子アセット/子デバイスからなる ManagedObject 配列
         */
                async getAllSubAssets(parentAssetId: string): Promise<any[]> {
                const assets = [];
                await this.getAllSubAssetsImpl(parentAssetId, assets);
                return assets;
        }

        private async getAllSubAssetsImpl(parentAssetId: string, assets: any[]): Promise<void> {
                const parentAsset: IManagedObject = await this.c8yService.getManagedObjectById(parentAssetId);
                if (assets.map( (asset: IManagedObject) => asset.id).indexOf(parentAsset.id) == -1)
                        assets.push(parentAsset);
                if (parentAsset.childAssets.references.length > 0) {
                        for (const child of parentAsset.childAssets.references) {
                                await this.getAllSubAssetsImpl(child.managedObject.id, assets);
                        }
                        // const promises = parentAsset.childAssets.references.map( async (child) =>
                        //       await this.getAllSubAssetsImpl(child.managedObject.id, assets) );
                        // Promise.all(promises);
                }
        }
}

「custom-widgets\image.service.ts」

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

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { IMediaDownloadable } from './image.type';
import * as AWS from 'aws-sdk';

import { C8yService } from "./c8y.service";

@Injectable()
export class ImageService {

        private s3: AWS.S3;

        constructor(
                private c8yService: C8yService
        ) { }

        /**
         * AWS S3 API から指定オブジェクトをダウンロードする
         *
         * @param {IMediaDownloadable} mediaDownloadable カスタムフラグメント
         *
         * @returns オブジェクトが通知される observable
         */
        getS3Object$(mediaDownloadable: IMediaDownloadable): Observable<any> {
                return new Observable((observer) => {
                        this.c8yService.getMO(mediaDownloadable.keystoreId)
                                .then((res) => {
                                        this.s3 = this.createS3Instance(res.data.com_WasabiCredentials);
                                        this.s3.getObject(
                                                { Bucket: res.data.com_WasabiCredentials.bucket, Key: mediaDownloadable.fileInformation.name },
                                                (err, data) => {
                                                        if (err) {
                                                                console.debug(err);
                                                                observer.error(err);
                                                        } else {
                                                                observer.next(data);
                                                        }
                                                }
                                        );
                                })
                                .catch((err) => {
                                        observer.error(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.accessKey,
                        secretAccessKey: wasabiCredentials.secretKey,
                        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 {}