じぶんSORACOMコンソールをAPIでkintoneに作ってみた

# はじめに

こちらは [SORACOM Advent Calendar 2023](https://qiita.com/advent-calendar/2023/soracom) 16日目の記事です。

IoT には欠かせないプラットホーム SORACOM、そのユーザコンソールはビジュアルかつ多機能で素晴らしいと感じています。しかしながら、普段の仕事では必要な機能だけに絞って頻繁に使うツールで確認・操作できると更に便利です。

SORACOM にはそんなユーザでも満足させる API が提供されています。今回はこの API を活用して、私が普段使いしている kintone でじぶんコンソールを作ってみました。

![SORACOM010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/67560f31-fdfd-8a37-0339-6b8a7656b5c2.png)

# じぶんコンソール要件

私の欲しいじぶんコンソールは以下です。

**・要件1：SIM の IMSI を入力するだけで、欲しいSIMの情報を取得**
**・要件2：直近の SIM のセッション履歴と ping の通信確認を個別、又は一括で把握**
**・要件3：SIM で通信する機器の設定画面にリモートでアクセス**
**・要件4：普段使いしている kintone のアプリで管理**

私の場合 SORACOM の SIM を高知の田舎で活用しているため、山間で電波がギリギリ届く場所でも運用しています。ゆえに、SIM のセッション状況や ping での機器との通信を死活監視は重要な要件になります。
さらに、機器の設定変更や機器のログなどをリモートで確認できると、移動時間（片道3時間超えも）が省けるため、こちらも重要な要件になります。
これらの頻繁に行いたい通信の確認や設定変更作業などを、普段使いしている kintoneアプリからも簡単にできれば、保守の負荷が大幅に軽減されます。

# 今回利用する SORACOM API について 

今回のじぶんコンソールを実現するために利用する SORACOM API は以下です。

## auth
API アクセスの認証を行い、認証後は APIキーとトークンで API を利用できます。
認証はルートユーザの email と password でも可能ですが、万一トラブルが発生した場合を考えると SAM ユーザーの認証キーでの認証が良いでしょう。
今回の実装では SAM ユーザーの認証キーを利用しました。

**POST /auth** でAPIキーとトークンを取得します。デフォルトの有効期限は24時間です。
**POST /auth/logout** でAPIキーとトークンを無効化します。

https://users.soracom.io/ja-jp/tools/api/reference/#/Auth

:::note alert
セキュリティの観点からも、APIキーとトークンは APIの呼び出しが終わった時点で無効化します。
:::

## subscribers
SIM に関する情報の取得や、操作、解約が可能で、今回は SIM の情報を取得するために利用しました。

**GET /subscribers/{imsi}** で、指定した SIM 情報を取得できます。取得できる情報は割当られた IPアドレスなど多岐にわたります。

**POST /subscribers/{imsi}/downlink/ping** で、指定した SIM への ping 結果を取得できます。わざわざIPアドレスを調べる必要もなく、IMSI 情報だけで結果を取得できるのは簡単で助かります。

https://users.soracom.io/ja-jp/tools/api/reference/#/Subscriber

但し、**GET /subscribers/{IMSI}** では**グループ名**は取得できません。グループ名が欲しい場合は取得した**groupId**で **GET /groups/{groupId}** を呼び出します。

https://users.soracom.io/ja-jp/tools/api/reference/#/Group

## sims
subscribers 同様に SIM に関する情報の取得や、操作、解約が可能ですが、SIMのセッション情報は sims でしか取得できません。今回は SIM のセッション情報を取得するために利用しました。

**GET /sims/{simid}/events/sessions** で指定した SIM のセッション情報を取得できますが、IMSIで直接取得することはできません。事前に **GET /subscribers/{imsi}** で　SIMID を取得する必要があります。

https://users.soracom.io/ja-jp/tools/api/reference/#/Sim

## port_mappings
SORACOM Napter（オンデマンドリモートアクセス）を API で実現し、SIM を使用したデバイスにリモートアクセスが可能になります。今回は リモートアクセスを自動化するために利用しました。

**POST /port_mappings** でリモートアクセスを作成します。リモートアクセスは指定した時間を過ぎると自動で削除されます。

**GET /port_mappings/subscribers/{imsi}** でIMSIに該当するSIMのオンデマンドリモートアクセスのリストを返します。

**DELETE /port_mappings/{ipAddress}/{port}** でリモートアクセスを削除します。すでに同じ IMSI の SIM から同じポートに対してリモートアクセスが作成されている場合は、新たなリモートアクセスの作成ができないため、リモートアクセスリストに該当がある場合は削除します。

https://users.soracom.io/ja-jp/tools/api/reference/#/PortMapping

# 作成したじぶんコンソール
今回作成したじぶんコンソールについて解説します。

## 管理するSIM一覧画面
kintone アプリの一覧画面で、SIMの通信状況を一括で確認できるようにしました。
![SORACOM020.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/a56f85a7-132f-0c5b-4489-958a138d8d99.png)
「SIM通信チェック」ボタンで、表示している全 SIM の ping レスポンスとセッション履歴を確認し、セッション切断中の SIM を赤、Ping の応答がないSIMをピンクで表示します。（表示の色付けは kintone の「新条件書式プラグイン」を利用）

## SIMの詳細画面
kintone アプリの詳細画面で、SIMの通信状況を個別確認したり、Napter のリモート接続で設定画面を開くことができるようにしました。
![SORACOM100A.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/e68e93e1-9a20-64fa-335c-afc886824a71.png)

## SIMの登録
管理するSIMの登録は、kintone アプリで IMSI を入力し、レコードを追加するだけです。レコード追加後の詳細画面の「SIM情報取得」ボタンで SIM の詳細な情報を取得できるようにしました。
![SORACOM070B.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/fd008e82-2f5d-8216-9fbf-9edccb205dc4.png)
登録後の詳細画面で「SIM情報取得」ができます。
![SORACOM080B.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/ac8bf641-dd7a-138d-af3f-52aac713798b.png)
SIM情報を取得後はSIMの詳細情報や「SIM通信確認」「セッション確認」「Web設定画面表示」のボタンを表示します。
![SORACOM090B.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/e18c3b14-c242-b0e2-f055-95d8be608c50.png)

## SIMの通信チェック
SIM登録と「SIM情報取得」が完了すると、個別に SIM の通信やセッション、Web管理管理画面が表示できるようになります。最新の状況は「SIM通信確認」や「セッション確認」ボタンで詳細画面に反映され、「Web管理管理画面表示」ボタンで SIM 通信する機器のWeb設定画面を開けます。

「SIM通信確認」後に、問題がない場合は以下のようにソラコム API の Ping 結果を表示します。
![SORACOM101A.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/aec4b185-afa4-9a76-85d6-aed68916eb64.png)
Ping の応答が Time Out した場合は以下の表示になります。
![SORACOM101B.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/bd8e075a-bd33-11bc-31bd-174d3d031441.png)
Ping でセッションが切断している等のエラーがあった場合は以下の表示になります。
![SORACOM101C.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/5de6075f-b4c3-c66a-e09f-250de53e1668.png)

「セッション確認」で、以下のセッションの接続・切断・修正（modify）の履歴を表示します。過去32日の履歴で最新10明細まで表示します。
![SORACOM102A2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/ed71cced-fb61-729d-4bbb-c44396aa2702.png)

「Web管理管理画面表示」で、以下のように Napter で SIM 接続機器Web設定画面リモート接続用URLを取得します。
![SORACOM103A2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/c2af2384-543c-b7a9-7917-4d168fe6b6ef.png)
同時に、以下のような SIM 接続機器Web設定画面を新しいタブに表示します。
![SORACOM130.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/4d86a778-a632-f8c7-f77d-bbd0262466d9.png)

以上、じぶん SORACOM コンソールを kintone アプリで構築できました！

## kintoneアプリの項目
参考までに、kintone アプリの項目は以下です。

| フィールド名 | フィールドタイプ | 解説 |
|:-----------|:------------|:------------|
| IMSI | 文字列（1行） | 必須入力 |
| Napterポート | 数値 | 設定Web画面のポート、初期値「80」 |
| TLS | ラジオボタン | Napter接続時にTLSを利用するかどうか |
| 備考 | 文字列（複数行） |   |
| 名前 | 文字列（1行） | SRACOMコンソールSIMの情報 |
| サブスクリプション | 文字列（1行） | SRACOMコンソールSIMの情報 |
| 速度クラス | 文字列（1行） | SRACOMコンソールSIMの情報 |
| SIM ID | 文字列（1行） | SRACOMコンソールSIMの情報 |
| MSI SDN | 文字列（1行） | SRACOMコンソールSIMの詳細情報 |
| シリアルNo | 文字列（1行） |   |
| 現在（SIM情報取得時点）のIPアドレス | 文字列（1行） |   |
| グループID | 文字列（1行） | SRACOMコンソールSIMの詳細情報 |
| グループ名 | 文字列（1行） | SRACOMコンソールSIMの詳細情報 |
| 通信状況 | ラジオボタン |  |
| 通信試験日時 | 日時 |   |
| 通信試験結果 | 文字列（複数行） |   |
| セッション履歴収集日時 | 日時 |   |
| セッション履歴 | 文字列（複数行） |   |
| 設定画面URL更新日時 | 日時 |   |
| 設定画面URL | 文字列（複数行） |   |
| 通信エラー履歴 | 関連レコード一覧 | 通信エラー情報アプリの履歴表示 |

# kintone JavaScript カスタマイズで SORACOM API 利用する
無論 kintone そのものには SORACOM API と連携する機能はありません。kintone は JavaScript でカスタマイズができますので、そちらで SORACOM API と連携する機能を作ります。

## kintone 画面に処理トリガー用のボタンを追加する
kintone には一覧画面、明細画面を表示するタイミングのイベントで kintone をカスタマイズできます。「SIM通信チェック」や「SIM情報取得」のボタン表示とクリック時の処理はこのイベントで処理を実装します。

<details><summary>該当コード</summary>

```JavaScript
	// 詳細画面表示イベント処理
	kintone.events.on('app.record.detail.show', function(event) {

        // ボタン描画DIV
        const divButton = document.createElement('div');
        divButton.className = "gaia-argoui-app-edit-button";
        divButton.style = "margin-left:20px";

        // SIM情報の収集
        const simInfoButton = document.createElement('button');
        if(event.record.名前.value != ""){
            simInfoButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            simInfoButton.className = "gaia-ui-actionmenu-save";
        }
        simInfoButton.id = 'my_simInfoButton';
        simInfoButton.innerText = 'SIM情報取得';
        simInfoButton.onclick = async function () {
            if (!window.confirm('SIM情報を取得します。\nよろしいですか？')) return;
            if(await UpdateSimSubsc(event)){
                alert("SIM情報取得が完了しました。再表示します。");
                location.reload();
            }else{
                alert("SIM情報取得に失敗しました。再度実行してください！");
            }
        };

        // SIMの情報を収集済みでない場合は、SIM情報の収集以外の処理はできない
        if(event.record.SIMID.value == ""){
            divButton.appendChild(simInfoButton);
            kintone.app.record.getHeaderMenuSpaceElement().appendChild(divButton);
            return;
        }

        // SIMの通信チェック
        const checkButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            checkButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            checkButton.className = "gaia-ui-actionmenu-save";
        }
        checkButton.id = 'my_checkButton';
        checkButton.innerText = 'SIM通信確認';
        checkButton.onclick = async function () {
            if (!window.confirm('SIMの通信をチェックします。\nよろしいですか？')) return;
            if(await CheckSimStatus(event)){
                alert("通信チェックが完了しました。再表示します。");
                location.reload();
            }else{
                alert("通信チェックに失敗しました。再度実行してください！");
            }
        };

        // SIMのセッション履歴確認
        const sessionButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            sessionButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            sessionButton.className = "gaia-ui-actionmenu-save";
        }
        sessionButton.id = 'my_sessionButton';
        sessionButton.innerText = 'セッション確認';
        sessionButton.onclick = async function () {
            if (!window.confirm('SIMのセッション履歴を更新します。\nよろしいですか？')) return;
            if(await UpdateSimSession(event)){
                alert("セッション履歴更新が完了しました。再表示します。");
                location.reload();
            }else{
                alert("セッション履歴更新に失敗しました。再度実行してください！");
            }
        };

        // 設定画面表示
        const settingButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            settingButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            settingButton.className = "gaia-ui-actionmenu-save";
        }
        settingButton.id = 'my_sessionButton';
        settingButton.innerText = 'Web設定画面表示';
        settingButton.onclick = async function () {
            if (!window.confirm('Web設定画面を表示します。\nよろしいですか？')) return;
            if(await UpdateSettingWebUrl(event)){
                alert("Web設定画面の表示が完了しました。");
                location.reload();
            }else{
                alert("Web設定画面の表示に失敗しました。再度実行してください！");
            }
        };

        // ボタン描画の反映
        divButton.appendChild(simInfoButton);
        divButton.append(" ");
        divButton.appendChild(checkButton);
        divButton.append(" ");
        divButton.appendChild(sessionButton);
        divButton.append(" ");
        divButton.appendChild(settingButton);
        kintone.app.record.getHeaderMenuSpaceElement().appendChild(divButton);

		return event;
	});
```
</details>

## kintone カスタマイズで SORACOM API を利用する
そもそもですが SORACOM API の利用は以下の手順で行います。

**1.API キーと API トークンを発行**
**2.ヘッダー情報にAPI キーと API トークンをセットして、APIを実行**
**3.API キーと API トークンを無効化**

APIはキーとトークンが有効な間（デフォルト24時間）は何回でもAPIを実行できます。

:::note warn
API の URL は日本向けのSIMとグローバルSIMで異なります。
**日本向け：https://api.soracom.io/v1/**
**グローバル：https://g.api.soracom.io/v1/**
:::

kintone JavaScript カスタマイズで外部APIの SORACOM API をそのまま fetch() しても CORS の問題でエラーになります。kintoneではこの問題を回避するため kintone.proxy() が用意されていますので、こちらを利用します。
kintone カスタマイズや、fetch()、CORS、kintone.proxy() については説明を省略しますので、詳しく知りたい方は以下を参照ください。

**JavaScript を使用した kintone のカスタマイズ**
https://cybozu.dev/ja/kintone/getting-started/what-is-kintone-customize/
**フェッチ API の使用**
https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch
**オリジン間リソース共有 (CORS)**
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
**外部の API を実行する**
https://cybozu.dev/ja/kintone/docs/js-api/proxy/kintone-proxy/

### SORACOM APIのキーとトークンを発行（認証）
APIキーとトークンの発行は **POST /v1/auth** で行います。
POSTするJSONデータに認証情報をセットしますが、認証は以下の3つのタイプ（SORACOM API ドキュメントからの引用）があります。

> ルートユーザーで認証する場合: email と password
> ルートユーザーまたは SAM ユーザーの認証キーで認証する場合: authKeyId と authKey
> SAM ユーザー認証の場合: operatorId, userName, password

今回はSAM ユーザーの認証キーで認証を実装しました。

```JavaScript
    // 今回は試験実装でソースコード内にキーを記述しています
    // 通常運用ではプラグイン等で安全にキーを保管しましょう
    const SoracomAuthKeyId   = "SORACOMコンソールで準備したAPIキーID";
    const SoracomAuthKey     = "SORACOMコンソールで準備したAPIキー";

    const SoracomApiUrl  = "https://api.soracom.io/v1/";
    const SoracomGApiUrl = "https://g.api.soracom.io/v1/";
    const SoracomAuth    = "auth";
    const SoracomLogout  = "auth/logout";   

    // IMSIで国内かグルーバルかを判断しAPIのURLを返す
    function GetApiUrl(imsi){
        if(imsi.toString().substring(0, 1) == "2"){
            return SoracomGApiUrl;
        }else{
            return SoracomApiUrl;
        }
    }

    // SORACOM API鍵の取得
    async function AuthSoracomApi(apiUrl) 
    {
        const url = apiUrl + SoracomAuth;
        const headers = {
            "Content-type": "application/json"
        };
        const data = {
            "authKeyId": SoracomAuthKeyId, // 認証キーID
            "authKey"  : SoracomAuthKey    // 認証キー
        };
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            return JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            return {"apiKey":"", "token":""};
        }
    }

    // APIキーの取得
    const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
```

以降の SORACOM API 呼び出しは、発行したAPIキーとトークンを HTTPヘッダーにセットします。

```
{
     "X-Soracom-API-Key" : "APIキー",
     "X-Soracom-Token": "トークン"
}
```

APIキーとトークンはそのままにしておくと有効期間の間は利用可能になるため、セキュリティの観点からも利用が完了した後は **POST /v1/auth/logout** で無効化します。

```JavaScript
    // SORACOM API鍵の無効化
    async function LogoutSoracomApi(apiUrl, auth) 
    {
        const url = apiUrl + SoracomLogout;
        const headers = {
            'X-Soracom-API-Key': auth.apiKey,
            'X-Soracom-Token'  : auth.token
        };
        const data = {};
        try{
            await kintone.proxy(url, 'POST', headers, data);
        }catch(error) {
            console.log(error);
        }
    }

    await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
```

### SIM情報取得
SIM情報取得は **GET /subscribers/{imsi}** で行います。取得したSIM情報にはグループ名は含まれないため、SIM情報に含まれる groupId を利用して **GET /groups/{groupId}** で取得します。

<details><summary>該当コード</summary>

```JavaScript
    const SoracomSubsc  = "subscribers/<IMSI>";
    const SoracomGroups = "groups/<GROUPID>";
    
    // SIMの情報を取得
    async function UpdateSimSubsc(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでSIMの情報を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomSubsc.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIでグループの情報を取得
        const url2 = GetApiUrl(event.record.IMSI.value) + SoracomGroups.replace('<GROUPID>', apiRespons.groupId);
        let apiRespons2;
        try{
            const respons2 = await kintone.proxy(url2, 'GET', headers, data);
            apiRespons2 = await JSON.parse(respons2[0]);
        }catch(error) {
            console.log(error);
        }

        // アプリを更新
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "名前"              : { "value" : apiRespons.tags.name },
                "グループID"        : { "value" : apiRespons.groupId },
                "グループ名"        : { "value" : apiRespons2.tags.name },
                "サブスクリプション" : { "value" : apiRespons.subscription },
                "速度クラス"        : { "value" : apiRespons.speedClass },
                "IPアドレス"        : { "value" : apiRespons.ipAddress },
                "シリアルNo"        : { "value" : apiRespons.serialNumber },
                "SIMID"            : { "value" : apiRespons.simId },
                "MSISDN"           : { "value" : apiRespons.msisdn }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    await UpdateSimSubsc(event);
```
</details>

### SIM通信確認
SIM通信確認は **GET /subscribers/{imsi}/downlink/ping** で行います。
結果のJSONデータは SIM が無効化で使えない場合や、セッションが切断している場合でレイアウトが異なるため、その対応が必要です。

<details><summary>該当コード</summary>

```JavaScript
    const SoracomPing = "subscribers/<IMSI>/downlink/ping";

   // SIMの通信状況をAPI経由でチェック
    async function CheckSimStatus(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));

        // SORACOM APIでpingの結果を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomPing.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token,
            'Content-Type'      : 'application/json'
        };
        const data = {
            "numberOfPingRequests" : 3,
            "timeoutSeconds"       : 1
        };
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIの結果から通信状況を判定
        let judgement = "その他問題";
        let result = "";
        if ('success' in apiRespons) {
            if(apiRespons.success){
                judgement = "問題なし";
            }else{
                judgement = "Ping応答無し";
            }
            result += "success : " + apiRespons.success.toString() + "\n";
            result += "stat : "    + apiRespons.stat + "\n";
            result += "rtt : "     + apiRespons.rtt + "\n";
        }else if('code' in apiRespons) {
            if(apiRespons.code == 'SEM0001'){
                judgement = "SIMが使えない";
            }else if(apiRespons.code == 'SEM0245'){
                judgement = "セッション切断";
            }
            result += "code:" + apiRespons.code + "\n";
            result += "message:" + apiRespons.message + "\n";
        }

        // アプリを更新
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "通信状況"     : { "value" : judgement},
                "通信試験日時" : { "value" : nowUtcDateTime },
                "通信試験結果" : { "value" : result }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        // 通信エラーログを追加
        if(judgement != "問題なし"){
            const headers = {
                'Content-type': 'application/json',
                'X-Cybozu-API-Token': ErrorLogAppToken
            };
            const body = {
                app: ErrorLogAppId,
                record: {
                    "日時"  : { "value" : nowUtcDateTime },
                    "IMSI"  : { "value" : event.record.IMSI.value },
                    "名前"  : { "value" : event.record.名前.value },
                    "不具合": { "value" : judgement},
                    "ログ"  : { "value" : result }
                }
            };
            // kintone.api() はヘッダーを送信できないので fetch() を使う
            const response = await fetch(kintone.api.url('/k/v1/record.json', true), { 
                method: 'POST', 
                body: JSON.stringify(body), 
                headers: headers 
            });
        }

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    await CheckSimStatus(event);
```
</details>

### セッション確認
セッションの確認は **GET /sims/<SIMID>/events/sessions** で行います。

<details><summary>該当コード</summary>

```JavaScript
    const SoracomSession = "sims/<SIMID>/events/sessions";

    // SIMのセッション履歴を取得
    async function UpdateSimSession(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでセッション履歴を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomSession.replace('<SIMID>', event.record.SIMID.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        let sessionInfo = "";
        for(let i=0; i<apiRespons.length; i++){
            sessionInfo += "日時：" + GetDateTime(apiRespons[i].time);
            let sessionInfoJapanese = "";
            if(apiRespons[i].event == "Created"){
                sessionInfoJapanese = "（SIM通信接続）";
            }else if(apiRespons[i].event == "Deleted"){
                sessionInfoJapanese = "（SIM通信切断）";
            }else if(apiRespons[i].event == "Modified"){
                sessionInfoJapanese = "（SIM通信修正）";
            }
            sessionInfo += "　イベント：" + apiRespons[i].event + sessionInfoJapanese + "\n";
        }
        
        // アプリを更新
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "セッション履歴収集日時" : { "value" : nowUtcDateTime },
                "セッション履歴"        : { "value" : sessionInfo }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }
    
    await UpdateSimSession(event);
```
</details>

### Web管理管理画面表示（Napterでリモート接続）
Napterのリモート接続の設定は **POST /port_mappings** で行います。

ここで注意が必要なのは、POST する引数としてアクセスを許可する sorace ip address（Rangeでも良い）の設定です。ほとんどがルータやF/Wなどで NAT や IPマスカレードでインターネットに接続していますので、自身が使っているIPアドレスとは異なるため、インターネットGW のグローバルIPアドレスを知りません。

自身がインターネットに接続しているグローバルIPアドレスを知るためには、自作 API などが必要となります。インターネット上に立ち上げているサーバなどがあればそちで準備すると良いでしょう。以下は PHP で簡単に実装する例です。（以下、セキュリティ対策は省略。）

```php
<?php
    header('Content-Type: application/json');
	echo "{\"ip\" : \"" . $_SERVER["REMOTE_ADDR"] . "\"}";
?>
```

また、同じSIMの同じポートに対してすでに作成した設定がある場合、先ほどのアクセスを許可する sorace ip address と異なる場合は接続できません。
念のため **GET /port_mappings/subscribers/{imsi}** で同じ設定がないか確認し、設定がある場合は **DELETE /port_mappings/{ipAddress}/{port}** で設定を削除してから、新たにリモート接続を追加します。

:::note warn
SORACOM 経由で接続しているPCやスマホで kintone を利用している場合は、この機能は使えませんので、ご注意ください。
:::

<details><summary>該当コード</summary>

```JavaScript
    const SoracomNapter      = "port_mappings";
    const SoracomNapterCheck = "port_mappings/subscribers/<IMSI>"
    const SoracomNapterDelet = "port_mappings/<IP>/<PORT>"

    // 設定画面のURLをAPI経由で取得
    async function UpdateSettingWebUrl(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでNapterの接続状況を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomNapterCheck.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIでターゲットポートのNapter接続を切断
        for(let i=0; i<apiRespons.length; i++){
            if(apiRespons[i].destination.port == event.record.Napterポート.value){
                const url = GetApiUrl(event.record.IMSI.value) + SoracomNapterDelet.replace('<IP>', apiRespons[i].ipAddress).replace('<PORT>', apiRespons[i].port);
                const headers = {
                    'accept'            : 'application/json',
                    'X-Soracom-API-Key' : auth.apiKey,
                    'X-Soracom-Token'   : auth.token
                };
                const data = {};
                try{
                    await kintone.proxy(url, 'DELETE', headers, data);
                }catch(error) {
                    console.log(error);
                }   
            }
        }

        // SORACOM APIでターゲットのポートをNapter接続
        const url2 = GetApiUrl(event.record.IMSI.value) + SoracomNapter.replace('<IMSI>', event.record.IMSI.value);
        const headers2 = {
            'accept'           : 'application/json',
            'X-Soracom-API-Key': auth.apiKey,
            'X-Soracom-Token'  : auth.token,
            'Content-Type'     : 'application/json'
        };
        let tlsRequired = false;
        if(event.record.TLS.value == "使用する"){
            tlsRequired = true;
        }
        const ip = await GetMyIpAddress();
        const data2 = {
            "destination" : { "imsi": event.record.IMSI.value, "port": event.record.Napterポート.value },
            "duration"    : 60 * 30,
            "source"      : { "ipRanges" : [ ip + "/32" ] },
            "tlsRequired" : tlsRequired
        };
        let apiRespons2;
        try{
            const respons2 = await kintone.proxy(url2, 'POST', headers2, data2);
            apiRespons2 = await JSON.parse(respons2[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // アプリを更新
        const napterUrl = "https://" + apiRespons2.hostname + ":" + apiRespons2.port + "/";
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "設定画面URL更新日時" : { "value" : nowUtcDateTime },
                "設定画面URL"        : { "value" : napterUrl }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);
        window.open(napterUrl);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }
```
</details>

## kintone カスタマイズの全て
以下に kintone のじぶん SORACOMコンソール アプリの JavaScript カスタマイズソースコードを公開します。急ぎ書いたコードでリファクタリング要素満載ですが、興味のある方はご自由（自己責任）で活用ください。

:::note alert
以下の JavaScript カスタマイズではソースコードに APIキー情報をそのまま書き込んでいますが、一般ユーザも利用する通常運用ではプラグイン化するなどで、APIキーが覗かれないように必ずセキュリティ対策を行ってください！
:::

<details><summary>一覧表示「SIM通信チェック」JavaScript カスタマイズプログラム</summary>

```SimManagementIndex.js
(function() {
	"use strict";

    // SORACOM API
    // 今回は試験実装でソースコード内にキーを記述しています
    // 通常運用ではプラグイン等で安全にキーを保管しましょう
    const SoracomAuthKeyId   = "SORACOMコンソールで準備したAPIキーID";
    const SoracomAuthKey     = "SORACOMコンソールで準備したAPIキー";

    const SoracomApiUrl      = "https://api.soracom.io/v1/";
    const SoracomGApiUrl     = "https://g.api.soracom.io/v1/";
    const SoracomAuth        = "auth";
    const SoracomLogout      = "auth/logout";
    const SoracomPing        = "subscribers/<IMSI>/downlink/ping";
    const SoracomSession     = "sims/<SIMID>/events/sessions";

    // kintone
    const ErrorLogAppId      = エラーログを出力するkintoneアプリのID;
    const ErrorLogAppToken   = "エラーログを出力するkintoneアプリのトークン";

	// 一覧表示イベント処理
	kintone.events.on('app.record.index.show', function(event) {

        // ボタン描画DIV
        const divButton = document.createElement('div');
        divButton.className = "gaia-argoui-app-edit-button";
        divButton.style = "margin-left:20px";

        // SIMの通信チェック
        const checkButton = document.createElement('button');
        checkButton.className = "gaia-ui-actionmenu-save";
        checkButton.id = 'my_checkButton';
        checkButton.innerText = 'SIM通信チェック';
        checkButton.onclick = async function () {
            if (!window.confirm('SIMの通信をチェックします。\nよろしいですか？')) return;
            await CheckSimStatus(event);
            alert("通信チェックが完了しました。再表示します。");
            location.reload();
        };

        // ボタン描画の反映
        divButton.appendChild(checkButton);
        kintone.app.getHeaderMenuSpaceElement().appendChild(divButton);

		return event;
	});

    // SIMの通信状況をAPI経由でチェック
    async function CheckSimStatus(event) 
    {
        let updateRecords = [];
        let errorRecords  = [];

        // APIキーの取得
        const authJ = await AuthSoracomApi(GetApiUrl("4444"));
        const authG = await AuthSoracomApi(GetApiUrl("2222"));

		// 一覧の表示処理
		for (var i = 0; i < event.records.length; i++) {
			var record = event.records[i];

            let auth = authJ;
            if(GetApiUrl(record.IMSI.value) == SoracomGApiUrl){
                auth = authG;
            }

            console.log("SIM IMSI="+record.IMSI.value+" を処理中");
			
            // SIMのpingチェック
            const updateRecord = await CheckSimPingAndSession(auth, record.$id.value, record.IMSI.value, record.SIMID.value);
            updateRecords.push(updateRecord);

            // 通信エラーログを追加
            if(updateRecord.record.通信状況.value != "問題なし"){
                const errorRecord = {
                    "日時"  : { "value" : updateRecord.record.通信試験日時.value },
                    "IMSI"  : { "value" : record.IMSI.value },
                    "名前"  : { "value" : record.名前.value },
                    "不具合": { "value" : updateRecord.record.通信状況.value },
                    "ログ"  : { "value" : updateRecord.record.通信試験結果.value }
                };
                errorRecords.push(errorRecord);
            }
		}

        // 情報の更新
        const body = {
            app: kintone.app.getId(),
            records: updateRecords
        };
        await kintone.api(kintone.api.url('/k/v1/records.json', true), 'PUT', body);

        // エラーログの出力
        const headers = {
            'Content-type': 'application/json',
            'X-Cybozu-API-Token': ErrorLogAppToken
        };
        const body2 = {
            app: ErrorLogAppId,
            records: errorRecords
        };
        await fetch(kintone.api.url('/k/v1/records.json', true), { 
            method: 'POST', 
            body: JSON.stringify(body2), 
            headers: headers 
        });

        await LogoutSoracomApi(GetApiUrl("4444"), authJ);
        await LogoutSoracomApi(GetApiUrl("2222"), authG);
        return true;
    }

    // SIMのpingとセッションをチェック
    async function CheckSimPingAndSession(auth, id, imsi, simid) {

        const nowUtcDateTime = GetCurrentUTCDateTime();

        // SORACOM APIでpingの結果を取得
        const url = GetApiUrl(imsi) + SoracomPing.replace('<IMSI>', imsi);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token,
            'Content-Type'      : 'application/json'
        };
        const data = {
            "numberOfPingRequests" : 3,
            "timeoutSeconds"       : 1
        };
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            return {
                id: id,
                record: {
                    "通信状況"     : { "value" : "その他の問題"},
                    "通信試験日時" : { "value" : nowUtcDateTime },
                    "通信試験結果" : { "value" : error }
                }
            };
        }

        // Ping結果の判定
        let judgement = "その他問題";
        let result = "";
        if ('success' in apiRespons) {
            if(apiRespons.success){
                judgement = "問題なし";
            }else{
                judgement = "Ping応答無し";
            }
            result += "success : " + apiRespons.success.toString() + "\n";
            result += "stat : "    + apiRespons.stat + "\n";
            result += "rtt : "     + apiRespons.rtt + "\n";
        }else if('code' in apiRespons) {
            if(apiRespons.code == 'SEM0001'){
                judgement = "SIMが使えない";
            }else if(apiRespons.code == 'SEM0245'){
                judgement = "セッション切断";
            }
            result += "code:" + apiRespons.code + "\n";
            result += "message:" + apiRespons.message + "\n";
        }

       // SORACOM APIでセッション履歴を取得
       const url2 = GetApiUrl(simid) + SoracomSession.replace('<SIMID>', simid);
       const headers2 = {
           'accept'            : 'application/json',
           'X-Soracom-API-Key' : auth.apiKey,
           'X-Soracom-Token'   : auth.token
       };
       const data2 = {};
       let apiRespons2;
       try{
           const respons = await kintone.proxy(url2, 'GET', headers2, data2);
           apiRespons2 = await JSON.parse(respons[0]);
       }catch(error) {
           console.log(error);
           return {
            id: id,
            record: {
                "通信状況"              : { "value" : judgement},
                "通信試験日時"          : { "value" : nowUtcDateTime },
                "通信試験結果"          : { "value" : result },
                "セッション履歴収集日時" : { "value" : nowUtcDateTime },
                "セッション履歴"        : { "value" : error }
            }
        };
       }

       // セッションの表示
       let sessionInfo = "";
       for(let i=0; i<apiRespons2.length; i++){
           sessionInfo += "日時：" + GetDateTime(apiRespons2[i].time);
           let sessionInfoJapanese = "";
           if(apiRespons2[i].event == "Created"){
               sessionInfoJapanese = "（SIM通信接続）";
           }else if(apiRespons2[i].event == "Deleted"){
               sessionInfoJapanese = "（SIM通信切断）";
           }else if(apiRespons2[i].event == "Modified"){
               sessionInfoJapanese = "（SIM通信修正）";
           }
           sessionInfo += "　イベント：" + apiRespons2[i].event + sessionInfoJapanese + "\n";
       }

        // 更新データ作成
        return {
            id: id,
            record: {
                "通信状況"              : { "value" : judgement},
                "通信試験日時"          : { "value" : nowUtcDateTime },
                "通信試験結果"          : { "value" : result },
                "セッション履歴収集日時" : { "value" : nowUtcDateTime },
                "セッション履歴"        : { "value" : sessionInfo }
            }
        };
    }

    // IMSIで国内かグルーバルかを判断しAPIのURLを返す
    function GetApiUrl(imsi){
        if(imsi.toString().substring(0, 1) == "2"){
            return SoracomGApiUrl;
        }else{
            return SoracomApiUrl;
        }
    }

    // SORACOM API鍵の取得
    async function AuthSoracomApi(apiUrl) 
    {
        const url = apiUrl + SoracomAuth;
        const headers = {
            "Content-type": "application/json"
        };
        const data = {
            "authKeyId": SoracomAuthKeyId, 
            "authKey"  : SoracomAuthKey
        };
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            return JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            return {"apiKey":"", "token":""};
        }
    }

    // SORACOM API鍵の無効化
    async function LogoutSoracomApi(apiUrl, auth) 
    {
        const url = apiUrl + SoracomLogout;
        const headers = {
            'X-Soracom-API-Key': auth.apiKey,
            'X-Soracom-Token'  : auth.token
        };
        const data = {};
        try{
            await kintone.proxy(url, 'POST', headers, data);
        }catch(error) {
            console.log(error);
        }
    }

    // 世界標準時形式で現在日時を取得
    function GetCurrentUTCDateTime() {
        const now = new Date();
        const year = now.getUTCFullYear();
        const month = (now.getUTCMonth() + 1).toString().padStart(2, '0');
        const day = now.getUTCDate().toString().padStart(2, '0');
        const hours = now.getUTCHours().toString().padStart(2, '0');
        const minutes = now.getUTCMinutes().toString().padStart(2, '0');
        const seconds = now.getUTCSeconds().toString().padStart(2, '0');
        return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
    }

    // 日本形式で日時を設定
    function GetDateTime(dateTime) {
        const now = new Date(dateTime);
        const year = now.getFullYear();
        const month = (now.getMonth() + 1).toString().padStart(2, '0');
        const day = now.getDate().toString().padStart(2, '0');
        const hours = now.getHours().toString().padStart(2, '0');
        const minutes = now.getMinutes().toString().padStart(2, '0');
        const seconds = now.getSeconds().toString().padStart(2, '0');
        return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
    }
})();
```

</details>

<details><summary>詳細画面 JavaScript カスタマイズプログラム</summary>

```SimManagementDetails.js
(function() {
	"use strict";

    // SORACOM API
    const SoracomAuthKeyId   = "SORACOMコンソールで準備したAPIキーID";
    const SoracomAuthKey     = "SORACOMコンソールで準備したAPIキー";

    const SoracomApiUrl      = "https://api.soracom.io/v1/";
    const SoracomGApiUrl     = "https://g.api.soracom.io/v1/";
    const SoracomAuth        = "auth";
    const SoracomLogout      = "auth/logout";
    const SoracomPing        = "subscribers/<IMSI>/downlink/ping";
    const SoracomSubsc       = "subscribers/<IMSI>";
    const SoracomGroups      = "groups/<GROUPID>";
    const SoracomSession     = "sims/<SIMID>/events/sessions";
    const SoracomNapter      = "port_mappings";
    const SoracomNapterCheck = "port_mappings/subscribers/<IMSI>"
    const SoracomNapterDelet = "port_mappings/<IP>/<PORT>"

    // kintone
    const ErrorLogAppId      = エラーログを出力するkintoneアプリのID;
    const ErrorLogAppToken   = "エラーログを出力するkintoneアプリのトークン";

	// 詳細画面表示イベント処理
	kintone.events.on('app.record.detail.show', function(event) {

        // ボタン描画DIV
        const divButton = document.createElement('div');
        divButton.className = "gaia-argoui-app-edit-button";
        divButton.style = "margin-left:20px";

        // SIM情報の収集
        const simInfoButton = document.createElement('button');
        if(event.record.名前.value != ""){
            simInfoButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            simInfoButton.className = "gaia-ui-actionmenu-save";
        }
        simInfoButton.id = 'my_simInfoButton';
        simInfoButton.innerText = 'SIM情報取得';
        simInfoButton.onclick = async function () {
            if (!window.confirm('SIM情報を取得します。\nよろしいですか？')) return;
            if(await UpdateSimSubsc(event)){
                alert("SIM情報取得が完了しました。再表示します。");
                location.reload();
            }else{
                alert("SIM情報取得に失敗しました。再度実行してください！");
            }
        };

        // SIMの情報を収集済みでない場合は、SIM情報の収集以外の処理はできない
        if(event.record.SIMID.value == ""){
            divButton.appendChild(simInfoButton);
            kintone.app.record.getHeaderMenuSpaceElement().appendChild(divButton);
            return;
        }

        // SIMの通信チェック
        const checkButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            checkButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            checkButton.className = "gaia-ui-actionmenu-save";
        }
        checkButton.id = 'my_checkButton';
        checkButton.innerText = 'SIM通信確認';
        checkButton.onclick = async function () {
            if (!window.confirm('SIMの通信をチェックします。\nよろしいですか？')) return;
            if(await CheckSimStatus(event)){
                alert("通信チェックが完了しました。再表示します。");
                location.reload();
            }else{
                alert("通信チェックに失敗しました。再度実行してください！");
            }
        };

        // SIMのセッション履歴確認
        const sessionButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            sessionButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            sessionButton.className = "gaia-ui-actionmenu-save";
        }
        sessionButton.id = 'my_sessionButton';
        sessionButton.innerText = 'セッション確認';
        sessionButton.onclick = async function () {
            if (!window.confirm('SIMのセッション履歴を更新します。\nよろしいですか？')) return;
            if(await UpdateSimSession(event)){
                alert("セッション履歴更新が完了しました。再表示します。");
                location.reload();
            }else{
                alert("セッション履歴更新に失敗しました。再度実行してください！");
            }
        };

        // 設定画面表示
        const settingButton = document.createElement('button');
        if(event.record.通信状況.value == "問題なし"){
            settingButton.className = "gaia-ui-actionmenu-cancel";
        }else{
            settingButton.className = "gaia-ui-actionmenu-save";
        }
        settingButton.id = 'my_sessionButton';
        settingButton.innerText = 'Web設定画面表示';
        settingButton.onclick = async function () {
            if (!window.confirm('Web設定画面を表示します。\nよろしいですか？')) return;
            if(await UpdateSettingWebUrl(event)){
                alert("Web設定画面の表示が完了しました。");
                location.reload();
            }else{
                alert("Web設定画面の表示に失敗しました。再度実行してください！");
            }
        };

        // ボタン描画の反映
        divButton.appendChild(simInfoButton);
        divButton.append(" ");
        divButton.appendChild(checkButton);
        divButton.append(" ");
        divButton.appendChild(sessionButton);
        divButton.append(" ");
        divButton.appendChild(settingButton);
        kintone.app.record.getHeaderMenuSpaceElement().appendChild(divButton);

		return event;
	});

    // SIMの情報を取得
    async function UpdateSimSubsc(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでSIMの情報を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomSubsc.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIでグループの情報を取得
        const url2 = GetApiUrl(event.record.IMSI.value) + SoracomGroups.replace('<GROUPID>', apiRespons.groupId);
        let apiRespons2;
        try{
            const respons2 = await kintone.proxy(url2, 'GET', headers, data);
            apiRespons2 = await JSON.parse(respons2[0]);
        }catch(error) {
            console.log(error);
        }

        // アプリを更新
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "名前"              : { "value" : apiRespons.tags.name },
                "グループID"        : { "value" : apiRespons.groupId },
                "グループ名"        : { "value" : apiRespons2.tags.name },
                "サブスクリプション" : { "value" : apiRespons.subscription },
                "速度クラス"        : { "value" : apiRespons.speedClass },
                "IPアドレス"        : { "value" : apiRespons.ipAddress },
                "シリアルNo"        : { "value" : apiRespons.serialNumber },
                "SIMID"            : { "value" : apiRespons.simId },
                "MSISDN"           : { "value" : apiRespons.msisdn }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    // SIMの通信状況をAPI経由でチェック
    async function CheckSimStatus(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));

        // SORACOM APIでpingの結果を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomPing.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token,
            'Content-Type'      : 'application/json'
        };
        const data = {
            "numberOfPingRequests" : 3,
            "timeoutSeconds"       : 1
        };
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIの結果から通信状況を判定
        let judgement = "その他問題";
        let result = "";
        if ('success' in apiRespons) {
            if(apiRespons.success){
                judgement = "問題なし";
            }else{
                judgement = "Ping応答無し";
            }
            result += "success : " + apiRespons.success.toString() + "\n";
            result += "stat : "    + apiRespons.stat + "\n";
            result += "rtt : "     + apiRespons.rtt + "\n";
        }else if('code' in apiRespons) {
            if(apiRespons.code == 'SEM0001'){
                judgement = "SIMが使えない";
            }else if(apiRespons.code == 'SEM0245'){
                judgement = "セッション切断";
            }
            result += "code:" + apiRespons.code + "\n";
            result += "message:" + apiRespons.message + "\n";
        }

        // アプリを更新
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "通信状況"     : { "value" : judgement},
                "通信試験日時" : { "value" : nowUtcDateTime },
                "通信試験結果" : { "value" : result }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        // 通信エラーログを追加
        if(judgement != "問題なし"){
            const headers = {
                'Content-type': 'application/json',
                'X-Cybozu-API-Token': ErrorLogAppToken
            };
            const body = {
                app: ErrorLogAppId,
                record: {
                    "日時"  : { "value" : nowUtcDateTime },
                    "IMSI"  : { "value" : event.record.IMSI.value },
                    "名前"  : { "value" : event.record.名前.value },
                    "不具合": { "value" : judgement},
                    "ログ"  : { "value" : result }
                }
            };
            // kintone.api() はヘッダーを送信できないので fetch() を使う
            const response = await fetch(kintone.api.url('/k/v1/record.json', true), { 
                method: 'POST', 
                body: JSON.stringify(body), 
                headers: headers 
            });
        }

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    // SIMのセッション履歴を取得
    async function UpdateSimSession(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでセッション履歴を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomSession.replace('<SIMID>', event.record.SIMID.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        let sessionInfo = "";
        for(let i=0; i<apiRespons.length; i++){
            sessionInfo += "日時：" + GetDateTime(apiRespons[i].time);
            let sessionInfoJapanese = "";
            if(apiRespons[i].event == "Created"){
                sessionInfoJapanese = "（SIM通信接続）";
            }else if(apiRespons[i].event == "Deleted"){
                sessionInfoJapanese = "（SIM通信切断）";
            }else if(apiRespons[i].event == "Modified"){
                sessionInfoJapanese = "（SIM通信修正）";
            }
            sessionInfo += "　イベント：" + apiRespons[i].event + sessionInfoJapanese + "\n";
        }
        
        // アプリを更新
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "セッション履歴収集日時" : { "value" : nowUtcDateTime },
                "セッション履歴"        : { "value" : sessionInfo }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    // 設定画面のURLをAPI経由で取得
    async function UpdateSettingWebUrl(event) 
    {
        // APIキーの取得
        const auth = await AuthSoracomApi(GetApiUrl(event.record.IMSI.value));
        
        // SORACOM APIでNapterの接続状況を取得
        const url = GetApiUrl(event.record.IMSI.value) + SoracomNapterCheck.replace('<IMSI>', event.record.IMSI.value);
        const headers = {
            'accept'            : 'application/json',
            'X-Soracom-API-Key' : auth.apiKey,
            'X-Soracom-Token'   : auth.token
        };
        const data = {};
        let apiRespons;
        try{
            const respons = await kintone.proxy(url, 'GET', headers, data);
            apiRespons = await JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // SORACOM APIでターゲットポートのNapter接続を切断
        for(let i=0; i<apiRespons.length; i++){
            if(apiRespons[i].destination.port == event.record.Napterポート.value){
                const url = GetApiUrl(event.record.IMSI.value) + SoracomNapterDelet.replace('<IP>', apiRespons[i].ipAddress).replace('<PORT>', apiRespons[i].port);
                const headers = {
                    'accept'            : 'application/json',
                    'X-Soracom-API-Key' : auth.apiKey,
                    'X-Soracom-Token'   : auth.token
                };
                const data = {};
                try{
                    await kintone.proxy(url, 'DELETE', headers, data);
                }catch(error) {
                    console.log(error);
                }   
            }
        }

        // SORACOM APIでターゲットのポートをNapter接続
        const url2 = GetApiUrl(event.record.IMSI.value) + SoracomNapter.replace('<IMSI>', event.record.IMSI.value);
        const headers2 = {
            'accept'           : 'application/json',
            'X-Soracom-API-Key': auth.apiKey,
            'X-Soracom-Token'  : auth.token,
            'Content-Type'     : 'application/json'
        };
        let tlsRequired = false;
        if(event.record.TLS.value == "使用する"){
            tlsRequired = true;
        }
        const ip = await GetMyIpAddress();
        const data2 = {
            "destination" : { "imsi": event.record.IMSI.value, "port": event.record.Napterポート.value },
            "duration"    : 60 * 30,
            "source"      : { "ipRanges" : [ ip + "/32" ] },
            "tlsRequired" : tlsRequired
        };
        let apiRespons2;
        try{
            const respons2 = await kintone.proxy(url2, 'POST', headers2, data2);
            apiRespons2 = await JSON.parse(respons2[0]);
        }catch(error) {
            console.log(error);
            await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
            return false;
        }

        // アプリを更新
        const napterUrl = "https://" + apiRespons2.hostname + ":" + apiRespons2.port + "/";
        const nowUtcDateTime = GetCurrentUTCDateTime();
        const body = {
            app: kintone.app.getId(),
            id: event.record.$id.value,
            record: {
                "設定画面URL更新日時" : { "value" : nowUtcDateTime },
                "設定画面URL"        : { "value" : napterUrl }
            }
        };
        await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', body);
        window.open(napterUrl);

        await LogoutSoracomApi(GetApiUrl(event.record.IMSI.value), auth);
        return true;
    }

    // IMSIで国内かグルーバルかを判断しAPIのURLを返す
    function GetApiUrl(imsi){
        if(imsi.toString().substring(0, 1) == "2"){
            return SoracomGApiUrl;
        }else{
            return SoracomApiUrl;
        }
    }

    // SORACOM API鍵の取得
    async function AuthSoracomApi(apiUrl) 
    {
        const url = apiUrl + SoracomAuth;
        const headers = {
            "Content-type": "application/json"
        };
        const data = {
            "authKeyId": SoracomAuthKeyId, 
            "authKey"  : SoracomAuthKey
        };
        try{
            const respons = await kintone.proxy(url, 'POST', headers, data);
            return JSON.parse(respons[0]);
        }catch(error) {
            console.log(error);
            return {"apiKey":"", "token":""};
        }
    }

    // SORACOM API鍵の無効化
    async function LogoutSoracomApi(apiUrl, auth) 
    {
        const url = apiUrl + SoracomLogout;
        const headers = {
            'X-Soracom-API-Key': auth.apiKey,
            'X-Soracom-Token'  : auth.token
        };
        const data = {};
        try{
            await kintone.proxy(url, 'POST', headers, data);
        }catch(error) {
            console.log(error);
        }
    }

    // IPアドレスの取得
    async function GetMyIpAddress(){
        // ここはご自身のサーバ等でAPIを作成
    } 

    // 世界標準時形式で現在日時を取得
    function GetCurrentUTCDateTime() {
        const now = new Date();
        const year = now.getUTCFullYear();
        const month = (now.getUTCMonth() + 1).toString().padStart(2, '0');
        const day = now.getUTCDate().toString().padStart(2, '0');
        const hours = now.getUTCHours().toString().padStart(2, '0');
        const minutes = now.getUTCMinutes().toString().padStart(2, '0');
        const seconds = now.getUTCSeconds().toString().padStart(2, '0');
        return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
    }

    // 日本形式で日時を設定
    function GetDateTime(dateTime) {
        const now = new Date(dateTime);
        const year = now.getFullYear();
        const month = (now.getMonth() + 1).toString().padStart(2, '0');
        const day = now.getDate().toString().padStart(2, '0');
        const hours = now.getHours().toString().padStart(2, '0');
        const minutes = now.getMinutes().toString().padStart(2, '0');
        const seconds = now.getSeconds().toString().padStart(2, '0');
        return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
    }
})();
```

</details>

（参考）通信エラーログを残す kintone アプリ「SORACOM 通信エラー情報」のフィールド。
| フィールド名 | フィールドタイプ | 解説 |
|:-----------|:------------|:------------|
| 日時 | 日時 |  |
| IMSI | 文字列（１行） |  |
| 名前 | 文字列（１行） |  |
| 不具合 | ラジオボタン | Ping応答無し, セッション切断, SIMが使えない, その他の問題 |
| ログ | 文字列（複数行） |  |

# まとめ
今回 じぶんSORACOMコンソール を kintone で作ってみたまとめは以下です。

**・SORACOM API を活用してじぶんコンソールは kintone アプリにも作成可能**
**・SORACOM API はSIM通信の死活監視やセッション状況の把握にも活用できて便利**
**・Napter（リモート接続）の API 自動化では嵌まり所があるので注意**
**・SORACOM API は死活監視、リモート接続自動化なども可能で、活用価値が高い**

kintone でじぶんSORACOMコンソール 作りですが、実は2回目でした。すでに仕事では Amazon AWS の Lambda を利用して、SORACOM API の結果を kintone に反映する方法で、SIMの自動監視などの上記以外の機能も盛り込んで活用しています。

今回は kintone の JavaScriptカスタマイズのみで実現できるかの確認も兼ねてやってみましたが、個人的には開発しずらい面もありましたが、十分可能であることがわかりました。

SORACOM API は SIM の管理や Napter のリモート接続以外にも、ソラカメや HarvestData、イベントハンドラー、Beam などグループ詳細設定など、SORACOM コンソールと同等以上のことに対応できるので、他の管理の自動化や簡素化にも用途を広げるつもりです。

# 参考情報
**SORACOM API 利用ガイド**
https://users.soracom.io/ja-jp/tools/api/
**API キーと API トークンの取り扱いについて**
https://users.soracom.io/ja-jp/tools/api/key-and-token/
**SORACOM API リファレンス**
https://users.soracom.io/ja-jp/tools/api/reference/