LTE-M CO2センサー RS-LTECO2 スターターキットをセットアップして各種アラートを飛ばしてみる

この記事は、[SORACOM Advent Calendar 2021](https://qiita.com/advent-calendar/2021/soracom) の18日目の記事です。

--- 

今回はだいぶ前に買ったんですが、色々とやっているうち、セットアップしたものの、放置しっぱなしになっていた(汗)、
LTE-M CO2センサー RS-LTECO2 スターターキットを使って、室内監視をしてみた話になります。

## LTE-M CO2センサー RS-LTECO2 スターターキットとは。
ラトックシステム株式会社が製造しているCO２センサーです。
愛用しているGPSマルチユニットとは違い、リファンレスデバイスではなく、
ラトックシステム株式会社の委託販売商品となっているようです。

[LTE-M CO2センサー RS-LTECO2 スターターキット](https://soracom.jp/store/12112/)

LTE-M版はSORACOMのIoTストアなどでしか買えない製品ですが、
Wifi版があり、そちらは、Amazon.comなどでも購入可能です。
それがこちらです。ちょっとだけお安い。
[RS-WFCO2](https://www.ratocsystems.com/products/subpage/wfco2.html)
あと、
PM2.5計測や気圧も計測できる上位機種もあるみたいですね。
こっちもLTE-M版出ないかなと思ったりしてます。
[RS-WFEVS1](https://www.ratocsystems.com/products/subpage/wfevs1.html)

搭載している各種センサー系は、
スイスに本社を置く、センシリオン社製のセンサーを搭載しているとのこと。
CO2に関しては、PA方式（光音響方式）で精度の高いCO2濃度計測が可能だそうです。
PA方式って何って方は、センサーを作っているセンシリオン社に、まとまっていたので、ご覧ください（読んでも理解できてない人です）
[PASens®技術](https://www.sensirion.com/jp/about-us/company/pasensr-technology/)

センサーの仕様については、こんな感じです。
![スクリーンショット 2021-12-18 14.04.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/0e19d8d1-8c3c-8fda-fb8c-c0a5ce5ffe8a.png)

## セットアップしてみる
実際の手順に関しては、こちらに詳細があるので、割愛します。

[SORACOM IoT レシピ：IoTで、CO2と温湿度を計測し換気促進](https://soracom.jp/recipes_index/9972/)
手順にも書いてありますが、
SIMを押し込むのがかなり難しかったですね。

SIMを入れて、電源を入れ、ユーザーコンソールで、SIMグループの設定（バイナリパーサー設定、Harverst設定）
あとはセンサーデータが蓄積されていきます。
あら簡単です。

## Hervest & Lagoonで可視化

まず送られるデータですが、

- CO2(co2)
- 温度(temp)
- 湿度(humid)
- 送信間隔(interval)

の４つです。

送信間隔については、設定値なので、それ以外の値を可視化することになります。

Harvest、Lagoonで可視化すると、こんな感じです。
### Harvestで可視化
2021/12/17の0:00〜23:59までのグラフになります。

- CO2
![スクリーンショット 2021-12-18 22.09.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/21b62d97-f170-162a-2d09-dedc5ed8ba55.png)

(これ活動時間がバレバレだな・・・。)

- 温度
![スクリーンショット 2021-12-18 22.09.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/05925ed3-d37e-66b8-6bc6-bca4286baf8c.png)

- 湿度
![スクリーンショット 2021-12-18 22.10.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/9c0a9c23-8fd0-0940-1908-ec4a013ba4ef.png)

まとめて出すこともできますが、CO2の値が他の値に比べて大きいので、分けています。
※大きすぎるため、温度と湿度がほぼ平行になってしまう。

## GPSマルチユニットとの比較

ここで、他製品と比べてみます。
同じように手軽に温湿度(あと位置情報)を可視化することができる便利なデバイスとして、
[GPSマルチユニット SORACOM Edition](https://soracom.jp/store/5235/)
があります。
![_20211218_222634.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/666aa961-6977-6ae0-b324-3060b872d3fc.jpeg)

上がGPSマルチユニット SORACOM Edition
その下にあるのが、CO2センサーです。

GPSマルチユニットのセンサー仕様ですが、以下のようになっています。(赤枠内)
※製造元の京セラさんの製品仕様書からの抜粋

![GPSマルチユニットセンサー仕様](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/065c0f6f-68f3-ae7e-1ab5-06c1f7a57344.png)

温度に関しては、最大値は同じですが、最低値は
GPSマルチユニットは-20℃、CO2センサーは5℃となっています。
自分が使う分には問題なさそう(マイナス値になることはそんなにない)ですが、
マイナス値もちゃんと取得したい場合は、
ただ、精度に関しては、GPSマルチユニットは、
±2.0℃、（バッテリー駆動中）、±3.5℃(USB給電中)、CO2センサーの方が±0.2℃なので、精度に関しては、
CO2センサーのほうが良さそうです。

湿度に関しては、測定レンジ、精度含めて同じですね。実は同じセンサーなのかな？

Lagoon上で２つのデータを比べてみます。
CO2センサーは、アンテナ上の部分にセンサーがついている(あれアンテナだと思ってた人)ので、
上の写真の通り、大体同じような位置になる（無理があるかな）ので、近しい値になるかと思いますが・・・如何に。

今回は、先ほどと同じく2021/12/17ですが、GPSマルチユニットの稼働時間が、10:00〜20:00(最終送信は20:03頃)なので、
10:00〜20:59までにしています。
- 温度

![スクリーンショット 2021-12-18 22.14.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/1f568fe8-edfc-2ea8-ab15-5a2e5344726f.png)

薄い青がGPSマルチユニット、濃い青がCO2センサーの値になります。
概ねGPSマルチユニットは１度ほど高い値を示しているようです。
精度を考えると、大体同じになるのかな？？？

- 湿度

![スクリーンショット 2021-12-18 22.14.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/e924cd3b-79c5-2e9e-d3a3-5fd76ac20f2a.png)

薄いオレンジがGPSマルチユニット、濃いオレンジがCO2センサーの値になります。
GPSマルチユニットが最大6%ぐらい高い値を示すという結果になりました。
無論、逆に低い時もあるので、この辺もセンサー精度の差かもしれませんね。

置き方を変えると数値が変わるとかあるのかなと思いますが、この辺は別途検証するかもしれません。

## 通知を飛ばす

今回ここがメインです。

このデバイスからのデータを使ってアラート発砲したい場合は、以下の３つになるかなと思います。

- 熱中症警告(夏季向け)
- 乾燥注意(冬季向け)
- CO2濃度警報

上の２つはGPSマルチユニットと同じですね。

ここで１つ問題が発生しました。
上記した通り、
このデバイスは、電源が入っている限りは、一定間隔でデータを送信します。
※電源落とせばいい話ですが、それは人力なので一応避けます。
そして、現状、CO2濃度が高まるのは、夜間寝ている時間なのです。

正直寝ている時に通知がこられても困ります。
以前、この[デバイスの勉強会](https://soracom.connpass.com/event/220033/)があった際に、時間制御できないものかという質問もしましたが、
残念ながらできないという回答をいただきました。

なので、今回は、SORACOM FunkからAWS Lambdaを呼び出して、
Lambda内で計算および通知(送信制御含む)を行いたいと思います。（前置き長い）

なお、GPSマルチユニットを使う際はSORACOM Orbitを使って、
Orbit内で、温湿度の値を元に計算して、Lagoonから通知を行うようにしています。
詳細は[GPSマルチユニットを使って、そろそろ冷房つけたらを通知する](https://qiita.com/keni_w/items/0d4dc98359d31f222b28)をご覧ください。


### 間隔を変えてみる
まず、現状５分の通知感覚を変えてみます（Lambdaの実行回数を減らそうかと・・・w）
方法は、SORACOM Airのメタデータサービスを有効にして、SIMのタグintervalというキーに、通知間隔値(分)をセットします。

- SORACOM AirのメタデータサービスはSIMグループにて設定します。
![メタデータサービス](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/0b43925d-68f8-ea5a-3144-533f9a1edfe7.png)

- SIMのタグの設定で、名前に `interval` に、値に間隔値を設定します。 例では `15` にしてます。

![タグ設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/05a681a5-8af3-078d-9070-32d4b1f3fb0b.png)


### AWS Lambdaの実装
SORACOM Funkから呼び出すAWS Lambda関数の実装自体はこんな感じです。(TypeScript)
突貫工事で作ったので、ちょっと微妙なところは直して、後日Githubにて公開します。
なお、最近多用しているAWS CDKのv2が最近出たので、それでデプロイしています。

処理として行っていることは、

- Line　Notify用のToken取得
- 通知時間チェック
- 熱中症警告チェック(WBGT値チェック)通知可否
- 乾燥チェック(絶対湿度チェック)通知可否
- WBGT値計算
- 絶対湿度計算
- LINEへのメッセージ生成
  - 通常メッセージ
  - CO2濃度警告通知
  - 熱中症警告通知
  - 乾燥警告通知
- LINEにメッセージ送信

です。


通知可否のチェックとして、
そもそもの通知可否は時間、
熱中症警告チェック(WBGT値チェック)と乾燥チェック(絶対湿度チェック)は、月でチェックするようにしています。
それら期間はLambdaの環境変数に持たせて、制御できるようにしてます。ああ、昔より便利になったわ・・・(トオイメ)
月だけ、年跨ぎするようになってたりしていますが、他にいい実装あるかも。
CO2濃度、熱中症警告チェック(WBGT値チェック)と乾燥チェック(絶対湿度チェック)は、
閾値を、同じくLambdaの環境変数に持たせて、その閾値と比較して、出す文字列を変えています。

LINEへの通知部と通知で使うLINE Notify用のToken取得部は、
JAWS PANKRATION用に開発したLambda関数で使っているものの流用です。
https://github.com/Kenichiro-Wada/amazon-location-service-geofence-system

 ```Typescript
const moment = require('moment-timezone');
moment.tz.setDefault('Asia/Tokyo');
const AWSXRay = require('aws-xray-sdk');
const AWS = AWSXRay.captureAWS(require('aws-sdk'));
AWS.config.update({ region: 'ap-northeast-1' });
const ssm = new AWS.SSM();
const axios = require('axios');
const qs = require('querystring');
AWS.config.logger = console;

/**
 * 定数
 */
const alertSendTimeRange: string = String(process.env.ALERT_TIME_RANGE);

const wbgtAlertSendMonthRange: string = String(
  process.env.WBGT_ALERT_MONTH_RANGE
);

const absHumidAlertSendMonthRange: string = String(
  process.env.ABS_HUMID_ALERT_MONTH_RANGE
);

const co2WarningLevel: number = Number(process.env.CO2_WARNING_LEVEL);
const co2AlertLevel: number = Number(process.env.CO2_ALERT_LEVEL);

const wbgtWarningLevel: number = Number(process.env.WBGT_WARNING_LEVEL);
const wbgtAlertLevel: number = Number(process.env.WBGT_ALERT_LEVEL);

const dryWarningLevel: number = Number(process.env.DRY_WARNING_LEVEL);
const dryAlertLevel: number = Number(process.env.DRY_ALERT_LEVEL);

exports.sendNotificationHandler = async function (event: any, context: any) {
  console.log('event:', JSON.stringify(event, null, 2));
  console.log('context:', JSON.stringify(context, null, 2));
  const lineNotifyToken = await getLineNotifyToken();

  const co2 = event.co2;
  const temp = event.temp;
  const humid = event.humid;

  const now: any = moment();

  // 通知可否チェック
  const isAlert = await checkAlertTime(now);
  const isAlertWbgt = await checkWbgtAlertMonth(now);
  const isAlertDry = await checkDryAlertMonth(now);

  let messageArray: string[] = [];
  messageArray.push(await createMessageSencorData(temp, humid, co2));
  if (isAlertWbgt) {
    const wbgt: number = await calcWbgt(temp, humid);
    console.log('wbgt:' + wbgt);
    messageArray.push(await createMessageWbgt(wbgt));
  }
  if (isAlertDry) {
    const absHumid: number = await calcAbsHumid(temp, humid);
    console.log('absHumid:' + absHumid);
    messageArray.push(await createMessageAbsHumid(absHumid));
  }
  messageArray.push(await createMessageCo2(co2));
  if (isAlert) {
    const message: string = messageArray.join('\n');
    const result: boolean = await sendNotifyMessage(lineNotifyToken, message);
    if (result) {
      return {
        statusCode: 200,
        body: JSON.stringify(
          {
            message: 'Line Notify Send Successful.',
          },
          null,
          2
        ),
      };
    } else {
      return {
        statusCode: 500,
        body: JSON.stringify(
          {
            message: 'Line Notify Send Error.',
          },
          null,
          2
        ),
      };
    }
  } else {
    return {
      statusCode: 200,
      body: JSON.stringify(
        {
          message: 'Nothing Alert.',
        },
        null,
        2
      ),
    };
  }
};
/**
 * SSMからLINE Notify用トークン取得
 * @returns トークン
 */
async function getLineNotifyToken(): Promise<string> {
  const ssmRequest = {
    Name: process.env.LINE_NOTIFY_TOKEN,
    WithDecryption: true,
  };
  const response: any = await ssm.getParameter(ssmRequest).promise();
  return String(response.Parameter.Value);
}
/**
 * WBGT計算
 * @param temp
 * @param humid
 * @returns wbgt値
 */
async function calcWbgt(temp: number, humid: number): Promise<number> {
  return Math.round(
    0.735 * temp + 0.0374 * humid + 0.00292 * temp * humid - 4.064
  );
}
/**
 * 絶対湿度計算
 * @param temp
 * @param humid
 * @returns 絶対湿度
 */
async function calcAbsHumid(temp: number, humid: number): Promise<number> {
  const ePow = (7.5 * temp) / (temp + 237.3);
  const e = 6.1078 * Math.pow(10, ePow); // 飽和水蒸気圧
  const a = (217 * e) / (temp + 273.15); // 飽和水蒸気量
  const rhP = humid / 100;
  const absHumid = a * rhP; // 絶対湿度
  return Math.round(absHumid);
}

/**
 * 通知期間チェック
 * @param now
 * @returns
 */
async function checkAlertTime(now: any): Promise<boolean> {
  const hour: number = now.hour();

  const startTime: number = Number(alertSendTimeRange.substring(0, 2)); // 通知開始時
  const endTime: number = Number(alertSendTimeRange.substring(2)); // 通知終了時
  console.log(
    `現在時刻:${hour} 通知開始時間:${startTime} 通知終了時間:${endTime}`
  );
  if (startTime <= hour && hour <= endTime) {
    return true;
  } else {
    return false;
  }
}
/**
 * WBGT値通知期間チェック(夏季)
 * @param now
 * @returns
 */
async function checkWbgtAlertMonth(now: any): Promise<boolean> {
  const month: number = now.month() + 1;

  const startMonth: number = Number(wbgtAlertSendMonthRange.substring(0, 2)); //通知開始月
  let endMonth: number = Number(wbgtAlertSendMonthRange.substring(2)); //通知終了月
  if (endMonth <= startMonth) { // 1003ってなった場合は１２足してますが、こっちはいらないかも
    endMonth = endMonth + 12;
  }
  console.log(
    `現在月:${month} WBGT通知開始月:${startMonth} WBGT通知終了月:${endMonth}`
  );
  if (startMonth <= month && month <= endMonth) {
    return true;
  } else {
    return false;
  }
}
/**
 * 絶対湿度通知期間チェック(冬季)
 * @param now
 * @returns
 */
async function checkDryAlertMonth(now: any): Promise<boolean> {
  const month: number = now.month() + 1;

  const startMonth: number = Number(
    absHumidAlertSendMonthRange.substring(0, 2)
  ); // 通知開始月
  let endMonth: number = Number(absHumidAlertSendMonthRange.substring(2)); // 通知終了月
  if (endMonth <= startMonth) { // こちらも1003ってなった場合は１２足してます
    endMonth = endMonth + 12;
  }
  console.log(
    `現在月:${month} 絶対温度通知開始月:${startMonth} 絶対温度通知終了月:${endMonth}`
  );
  if (startMonth <= month && month <= endMonth) {
    return true;
  } else {
    return false;
  }
}
/**
 * センサーデータ通知メッセージ
 * @param temp
 * @param humid
 * @param co2
 * @returns 通知メッセージ
 */
async function createMessageSencorData(
  temp: number,
  humid: number,
  co2: number
): Promise<string> {
  return `温度: ${temp} 湿度: ${humid} CO2濃度: ${co2}`;
}
/**
 * CO2濃度警告通知メッセージ
 * @param co2
 * @returns 
 */
async function createMessageCo2(co2: number): Promise<string> {
  if (co2WarningLevel <= co2) {
    if (co2AlertLevel <= co2) {
      return `CO2濃度: ${co2} \n CO2濃度が危険なレベルになっています。換気などをして、適切な温度にしましょう！`;
    } else {
      return `CO2濃度: ${co2} \n CO2濃度が高くなっています。換気などをして、適切な濃度にしましょう！`;
    }
  }

  return '';
}
/**
 * WBGT値通知メッセージ
 * @param wbgt
 * @returns 通知メッセージ
 */
async function createMessageWbgt(wbgt: number): Promise<string> {
  let levelStr: string = '適切な室温です。';
  if (wbgtWarningLevel < wbgt) {
    if (wbgtAlertLevel < wbgt) {
      levelStr =
        '熱中症の危険があります。冷房をつけるなどして、室温を適切に保ちましょう！';
    } else {
      levelStr =
        '熱中症に警戒しましょう。冷房をつけるなどして、室温を適切に保ちましょう！';
    }
  }

  return `WBGT値: ${wbgt} \n ${levelStr}`;
}
/**
 * 絶対湿度通知メッセージ
 * @param absHumid
 * @returns
 */
async function createMessageAbsHumid(absHumid: number): Promise<string> {
  let levelStr: string = '適切な温度と湿度です。';
  if (absHumid < dryAlertLevel) {
    levelStr =
      '空気が乾燥し、ウィルスが蔓延しやすくなっています。換気したり、加湿器を使用するなどでして、温度と湿度を適切に保ちましょう！';
  } else {
    if (absHumid < dryWarningLevel) {
      levelStr =
        '少し乾燥しているようです。換気したり、加湿器を使用するなどでして、温度と湿度を適切に保ちましょう！';
    }
  }
  return `乾燥指数: ${absHumid} ${levelStr}`;
}
/**
 * LINE Notify通知部
 * @param lineNotifyToken
 * @param message
 * @returns
 */
async function sendNotifyMessage(
  lineNotifyToken: String,
  message: string
): Promise<boolean> {
  const lineNotifyUrl = 'https://notify-api.line.me/api/notify';
  // リクエスト設定
  const payload: { [key: string]: string } = {
    message: message,
  };

  console.log('payload:', JSON.stringify(payload, null, 2));
  const config = {
    url: lineNotifyUrl,
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Bearer ${lineNotifyToken}`,
    },
    data: qs.stringify({
      message: message,
    }),
  };
  // メッセージ送信
  try {
    const result = await axios.request(config);
    console.log(result);
    if (result.data.message === 'ok') {
      return true;
    } else {
      return false;
    }
  } catch (error) {
    console.error(error);
    return false;
  }
}

```

実際動かしてみると、こんな感じで通知がきます。(めっちゃテストデータです。)
![_20211218_230750.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/3babe695-917a-b862-8fd8-05f9adc2eefb.jpeg)

うーん、もうちょっと綺麗にしたい。


## 終わり
CO2センサーを設定し、Lagoonで可視化、Funk経由でLine Notifyまで通知するところまでやってみました。
無論、計算をSORACOM Orbitでやり、Lagoonでアラート送信をすることもできますので、
そっちの方が手軽かなという気もしますが、
変な時間に警告くるのは避けられない気がします。（と思っているけど、手段あるかも）

ただ、今のままだと、休日だろう(現に来てる)と、15分起きにLINEから通知くるので、ここまで要らんかなといきなり思っているので、
本当にやばい時だけ通知するに改修しようかなと思います。
こちら、ちょっと来年早々にも改修して、再度アップデートします。

---
明日は、ソラコム片山先生の２年ぶりの新作「Sim City 2021」です。期待しかないです！