2022年 SPRESENSE™ 活用コンテスト 」の応募受付は終了しました。結果発表まで今しばらくお待ちください。

siroitori0413のアイコン画像
siroitori0413 2022年09月10日作成 (2022年09月18日更新) © CC BY-NC-SA 4+
製作品 製作品 閲覧数 187
siroitori0413 2022年09月10日作成 (2022年09月18日更新) © CC BY-NC-SA 4+ 製作品 製作品 閲覧数 187

Spresenseで災危通報の受信通知(メール・Discord・サーマルプリンタ)とカメラ画像の保存

Spresenseで災危通報の受信通知(メール・Discord・サーマルプリンタ)とカメラ画像の保存

災危通報を活用したい!

測位衛星「みちびき」から受信できる災危通報に興味があり、使ってみたいと思いました。
災危通報には「緊急地震速報」も含まれます。
そこで、緊急地震速報を受信した時にカメラを起動して画像を撮影/動画を録画できるものを作りたいと思いました。
→結論から書きますと1台のSpresenseで災危通報受信とカメラ撮影は同時に行うことができず(理由は後述)別々のシステムとして作りました。

システム概要

  1. 災危通報を受信して内容をメール・Discord送信、及びサーマルプリンタ出力
  2. カメラで撮影した画像を保管用に自宅サーバに送信する

前述の通り、この2つを連携させたかったのですが無理でしたのでやむを得ず別々のシステムとして構築しました。

材料

1. 災危通報を受信して内容をメール・Discord送信、及びサーマルプリンタ出力

災危通報を受信して内容をメール・Discord送信、及びサーマルプリンタ出力

  • Spresense側【Arduino/メインコア】
    災危通報を衛星から受信し、重複受信以外について都度サブコア1に投げ(マルチコアプログラミング)すぐにまた受信待機する
  • Spresense側【Arduino/サブコア1】
    メインコアから受け取った情報をWi-Fi経由で自宅サーバへHTTP POSTする
  • Windows側【Node.js/自宅サーバ】
    Spresenseから受け取った情報をメール・Discord送信・サーマルプリンタ出力する
    (サーマルプリンタへはUSB to Serial Converter を通して出力)

災危情報のレシート

Spresense側Arduinoプログラム

メインコア

#include <MP.h> #include <Arduino.h> #include <GNSS.h> #include <GNSSPositionData.h> #include <QZQSM.h> #include <RTC.h> struct GNSSReceivePacket { volatile bool resolved; char message[2048]; uint8_t sat_id; }; struct GNSSCoreStatusNotificationPacket { uint8_t status; volatile bool resolved; }; const int HISTORY_COUNT = 20; QZQSM s_history[HISTORY_COUNT]; int s_index = 0; SpGnss Gnss; void send_gnss_packet(uint8_t sat_id, const char *message) { MPLog("Message from %d...", sat_id); GNSSReceivePacket packet; memset(&packet, 0, sizeof(packet)); /* Create a message */ snprintf(packet.message, 2048, "%s", message); packet.sat_id = sat_id; /* Send */ int ret = MP.Send(0, &packet, 1); if (ret < 0) { MPLog("MP.Send error = %d\r\n", ret); } while (!packet.resolved) {} } void send_status_packet(uint8_t status) { GNSSCoreStatusNotificationPacket packet; memset(&packet, 0, sizeof(packet)); /* Create a message */ packet.status = status; /* Send */ int ret = MP.Send(1, &packet, 1); if (ret < 0) { MPLog("MP.Send error = %d\r\n", ret); } while (!packet.resolved) {} } void setup() { Serial.begin(115200); MP.begin(1); // Main handler MP.RecvTimeout(MP_RECV_BLOCKING); send_status_packet(0); if (Gnss.begin()) { MPLog("Failed to initialize gnss"); } Gnss.select(GPS); Gnss.select(QZ_L1CA); Gnss.select(QZ_L1S); /* set interval */ Gnss.setInterval(1); /* Start GNSS */ if (Gnss.start(COLD_START)) { MPLog("Failed to start gnss"); } send_status_packet(1); } void loop() { /* Check update. */ if (Gnss.waitUpdate(1000)) { RtcTime now; // LED Heartbeat static int toggle = 0; if (toggle++ % 2) { ledOn(LED0); } else { ledOff(LED0); } // Get time and position data with QZQSM SpNavData NavData; Gnss.getNavData(&NavData); // Check if UTC time is acquired SpGnssTime *time = &NavData.time; if (time->year >= 2000) { // Time fix ledOn(LED1); // Convert SpGnssTime to RtcTime RtcTime gps(time->year, time->month, time->day, time->hour, time->minute, time->sec, time->usec * 1000); // Convert UTC to JST time gps += 9 * 60 * 60; // Compare with the current time now = RTC.getTime(); int diff = now - gps; if (abs(diff) >= 1) { RTC.setTime(gps); } } // Get DC reoprt void *handle = Gnss.getDCReport(); if (handle) { // begin DC report ledOn(LED3); QZQSM report; now = RTC.getTime(); report.SetYear(now.year()); report.Decode(((struct cxd56_gnss_dcreport_data_s *)handle)->sf); int reported = 0; int i; for (i = 0; i < HISTORY_COUNT; i++) { if (s_history[i] == report) { reported = 1; break; } } if (!reported) { /* New report */ send_gnss_packet(((struct cxd56_gnss_dcreport_data_s *)handle)->svid, report.GetReport()); s_history[s_index] = report; s_index++; s_index %= HISTORY_COUNT; } // end DC report ledOff(LED3); } } }

サブコア1

#define SUBCORE 1 #include <MP.h> #include <ESP8266.h> //https://github.com/MeemeeLab/WeeESP8266_Spresense // ESP8266 #define ESP8266_BAUDRATE 115200 #define ESP8266_SSID "**************" // TODO WiFi SSIDを設定 #define ESP8266_PASS "**************" // TODO WiFi パスワードを設定 #define ESP8266_SERVER_IP "xxx.xxx.xxx.xxx" // TODO POST先サーバーIPを設定 #define ESP8266_SERVER_PORT (5555) // TODO POST先サーバーポート番号を設定 #define ESP8266_BUFFER_SIZE (2048) // #define ESP8266_DATA_SEND_RAW // Enable this when using something like ncat for debugging // END ESP8266 struct GNSSReceivePacket { volatile bool resolved; char message[2048]; uint8_t sat_id; }; struct GNSSCoreStatusNotificationPacket { uint8_t status; volatile bool resolved; }; ESP8266 wifi; unsigned long packet_id = 0; void setup() { MP.begin(); MP.RecvTimeout(MP_RECV_POLLING); Serial.begin(115200); /* Initialize WiFi */ Serial.print("Initializing WiFi..."); ledOn(LED0); wifi.begin(Serial2, ESP8266_BAUDRATE); ledOn(LED1); while (!wifi.setOprToStationSoftAP()) { Serial.println("Failed to switch station / softap"); } ledOn(LED2); while (!wifi.joinAP(ESP8266_SSID, ESP8266_PASS)) { Serial.println("Failed to join AP"); } ledOn(LED3); while (!wifi.disableMUX()) { Serial.println("Failed to disable MUX"); } ledOff(LED0); ledOff(LED1); ledOff(LED2); ledOff(LED3); Serial.println("OK"); } void send_http(char* path, uint8_t* body, size_t body_size, char* mime_type, char* optional_headers) { if (!wifi.createTCP(ESP8266_SERVER_IP, ESP8266_SERVER_PORT)) { Serial.println("Error while opening TCP socket"); return; } #ifdef ESP8266_DATA_SEND_RAW for (; body_size > 0; body += body_size, body_size -= body_size) { uint16_t data_size = min(body_size, body_size); Serial.println("Sending chunk of " + String(data_size) + " byte(s)"); Serial.println(String(body_size) + "byte(s) remaining"); wifi.send((const uint8_t*)body, data_size); } wifi.send((const uint8_t*)"\r\n", 2); #else char header[256] = {0}; sprintf(header, "POST /%s HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\nContent-Type: %s\r\n%s\r\n", path, ESP8266_SERVER_IP, body_size, mime_type, optional_headers); wifi.send((const uint8_t*)header, strlen(header)); for (; body_size > 0; body += body_size, body_size -= body_size) { uint16_t data_size = min(body_size, body_size); Serial.println("Sending chunk of " + String(data_size) + " byte(s)"); Serial.println(String(body_size) + "byte(s) remaining"); wifi.send((const uint8_t*)body, data_size); } uint8_t response[1024] = {0}; uint32_t len = wifi.recv(response, sizeof(response), 10000); if (len > 0) { for(uint32_t i = 0; i < len; i++) { Serial.print((char)response[i]); } } #endif wifi.releaseTCP(); } void gnss_packet_receive(GNSSReceivePacket *packet) { GNSSReceivePacket packet_copy = *packet; packet->resolved = true; Serial.print("Below is print of gnss packet received from satellite id "); Serial.print(packet_copy.sat_id); Serial.println("."); Serial.println("=========================="); Serial.println(packet_copy.message); Serial.println("=========================="); char header[64]; packet_id++; sprintf(header, "X-Satellite-Id: %d\r\nX-Packet-Id: %d\r\n", packet_copy.sat_id, packet_id); send_http("QZQSM", (uint8_t *)packet_copy.message, strlen(packet_copy.message), "text/plain", header); // send_cam_img(header); } void gnss_status_packet_receive(GNSSCoreStatusNotificationPacket *packet) { switch (packet->status) { case 0: Serial.println("GNSS core reports: INITIALIZING"); break; case 1: Serial.println("GNSS core reports: INITIALIZED AND READY FOR RECEIVE"); break; default: Serial.print("GNSS core reported unknown status: "); Serial.println(packet->status); break; } packet->resolved = true; } void update_gnss() { int8_t msgid; void *packet; int ret = MP.Recv(&msgid, &packet); if (ret >= 0) { Serial.println("Core recv"); switch (msgid) { case 0: gnss_packet_receive((GNSSReceivePacket *)packet); break; case 1: gnss_status_packet_receive((GNSSCoreStatusNotificationPacket *)packet); break; default: break; } } } void loop() { update_gnss(); }

Windows側Node.jsプログラム

index.js

import express from 'express'; import bodyParser from 'body-parser'; import sendMail from './lib/mail.js'; import fetch from 'node-fetch'; import child_process from 'child_process' async function print(str, feed=0) { for (const chunk of str.split('\n')) { const proc = child_process.spawn('python', [ 'C:\\Users\\xxxxxx\\Documents\\ThermalPrinter\\text.py', // サーマルプリンタ用自作プログラム(Python) '--port', 'COM8', '--baudrate', '115200', '--text', chunk, '--justify', 'L', '--size', 'M', '--chinese', '--chinese_format', 'UTF_8', '--feed', feed.toString() ]); await new Promise( (resolve) => { proc.on('close', resolve); }); } } function mail(name, body) { sendMail('xxxxx@xxxxxx', name + ' zzzzz@zzzzzz', body); //TODO xxxx:送信先メアド zzzz:送信元メアド を設定 fetch('https://canary.discord.com/api/webhooks/XXXXXXXXXXXXXXXXXX', { //TODO Discord送信用webhook URL を指定 method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: body }) }); } const app = express(); app.use(bodyParser.raw({ type: '*/*', limit: '50mb' })); app.post('/QZQSM', (req, res) => { res.status(204).send(); console.log(`== 衛星番号: ${req.headers['x-satellite-id'] || '不明'} ===`); console.log(req.body.toString('utf-8')); mail('QZQSM', req.body.toString('utf-8')); // print(req.body.toString('utf-8')); }); app.listen(5555, '0.0.0.0', () => { // TODO 5555:POSTを受けるサーバーポート番号を設定 console.log('listening'); // print('listening', 2); });

lib/mail.js

import nodemailer from 'nodemailer'; var smtpTransport = nodemailer.createTransport({ host: 'smtp.gmail.com', //TODO SMTPサーバー(左記設定はGMailの場合) port: 465, secure: true, // use SSL auth: { type: 'LOGIN', user: 'zzzzz@zzzzzz', //TODO zzzz:送信元メアド を設定 pass: 'pppppp' //TODO GMailの場合はGoogleアカウントのアプリパスワードを設定 cSpell:disable-line } }); function sendMail(to, subject, body) { smtpTransport.sendMail({ from: 'QZQSM zzzz@zzzz', //TODO zzzz:送信元メアド を設定 to: to, subject: subject, text: body }, function(error){ if (error) { throw error; } }); } export default sendMail;

動作の様子

結局天気が気になるので(もし雨に濡れて壊れてしまったら悲しすぎる)基本的には家の中のベランダの窓近くに置いて稼働させました。

窓を閉めてしまうとやはりほとんど受信できない状態になってしまいますがそれでも稀に受信することがありました。

窓を開けて網戸の状態にしておくと日に40通程度受信できました。ただ、その日によって差はあると思います。

観測初日はベランダに出して100通くらい受信できました。
ただこの日は台風が近づいてきておりいつもより多く災危通報が発信されていたと思うのでどれくらい外に出したことによる効果があるかはっきりはわかりません。


苦労・工夫したところ

Spresense側のプログラムはArduinoで作成しました。Arduinoの言語(主にC言語のポインタ)に不慣れなため、マルチコア間の受け渡し部分とWi-Fiに繋いでPOSTするところの実装に時間がかかりました。

特にWiFiによるデータ通信は、既存のESP8266のライブラリでうまく動かないところがあり少し修正しました。
https://github.com/MeemeeLab/WeeESP8266_Spresense

Windows側のプログラムでは、メール送信にNodemailer ライブラリを使用しています。
https://nodemailer.com/

サーマルプリンタ(レシートプリンタ)に出力するプログラムはPythonで作成しており、Node.jsからコールする形にしています。

2.カメラで撮影した画像を保管用に自宅サーバに送信する

カメラで撮影した画像を保管用に自宅サーバに送信する

  • Spresense側【Arduino】
    カメラ撮影を行い、撮影した画像をWi-Fi経由で自宅サーバへHTTP POSTする
  • Windows側【Node.js/自宅サーバ】
    Spresenseから受け取った画像をサーバ内に保存する

Spresense側Arduinoプログラム

Arduinoプログラムはサンプルスケッチのcamera_apitest.inoを一部修正して利用しました。修正した部分を抜粋します。

CamerOverHTTP(抜粋)

// -----include define に以下を追加----- #include "ESP8266.h" // https://github.com/itead/ITEADLIB_Arduino_WeeESP8266 #define BAUDRATE 115200 #define SSID "**************" // TODO WiFi SSIDを設定 #define PASS "**************" // TODO WiFi パスワードを設定 #define SERVER_IP "xxx.xxx.xxx.xxx" // TODO POST先サーバーIPを設定 #define SERVER_PORT (5555) // TODO POST先サーバーポート番号を設定 #define BUFFER_SIZE (2048) ESP8266 wifi; // -----関数追加----- void failure(String message) { Serial.println(message); abort(); } // -----void setup() 関数内に以下追加----- /* Initialize WiFi */ Serial.print("Initializing WiFi..."); wifi.begin(Serial2, BAUDRATE); if (!wifi.setOprToStationSoftAP()) { failure("Failed to switch station / softap"); } if (!wifi.joinAP(SSID, PASS)) { failure("Failed to join AP"); } if (!wifi.disableMUX()) { failure("Failed to disable MUX"); } Serial.println("OK"); // -----takePicture()関数を以下に置き換え----- void takePicture() { char header[256] = {0}; Serial.println(); Serial.println("--- Take Picture ---"); CamImage img = theCamera.takePicture(); if (img.isAvailable()) { int image_size = img.getImgSize(); uint8_t* image_buffer = img.getImgBuff(); sprintf(header, "POST /data HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\nContent-Type: %s\r\n\r\n", SERVER_IP, image_size, (img.getPixFormat() == CAM_IMAGE_PIX_FMT_RGB565) ? "image/x-rgb565" : (img.getPixFormat() == CAM_IMAGE_PIX_FMT_YUV422) ? "image/x-yuv422" : (img.getPixFormat() == CAM_IMAGE_PIX_FMT_JPG) ? "image/jpeg" : (img.getPixFormat() == CAM_IMAGE_PIX_FMT_GRAY) ? "image/x-gray" : "application/octet-stream"); if (!wifi.createTCP(SERVER_IP, SERVER_PORT)) { Serial.println("Error while opening TCP socket"); return; } wifi.send((const uint8_t*)header, strlen(header)); for (; image_size > 0; image_buffer += BUFFER_SIZE, image_size -= BUFFER_SIZE) { uint16_t data_size = min(image_size, BUFFER_SIZE); Serial.println("Sending chunk of " + String(data_size) + " byte(s)"); Serial.println(String(image_size) + "byte(s) remaining"); wifi.send((const uint8_t*)image_buffer, data_size); } uint8_t response[1024] = {0}; uint32_t len = wifi.recv(response, sizeof(response), 10000); if (len > 0) { for(uint32_t i = 0; i < len; i++) { Serial.print((char)response[i]); } } wifi.releaseTCP(); Serial.printf("Saved to remote\n"); Serial.printf("Resolution: %dx%d\n", img.getWidth(), img.getHeight()); Serial.printf("MemorySize: %.2f / %.2f [KB]\n", img.getImgSize() / 1024.0, img.getImgBuffSize() / 1024.0); } else { Serial.println("Error: Failed to take picture."); Serial.println("Increase the size of memory allocated."); Serial.println("or Decrease the JPEG Quality."); } }

Windows側Node.jsプログラム

index.js

import express from 'express'; import bodyParser from 'body-parser'; import fs from 'fs'; import path from 'path'; const fileFormatMap = { 'image/x-rgb565': 'rgb565', 'image/x-yuv422': 'yuv422', 'image/jpeg': 'jpeg', 'image/x-gray': 'gray', 'application/octet-stream': 'bin' } const app = express(); app.use(bodyParser.raw({ type: '*/*', limit: '50mb' })); app.post('/data', (req, res) => { console.log('Data request from', req.socket.remoteAddress.toString()); fs.writeFileSync(path.join('./files', Date.now().toString() + '.' + fileFormatMap[req.headers['content-type']]), req.body); res.status(200).send(); }); app.listen(5555, '0.0.0.0', () => { console.log('listening'); });

動作の様子


↑※Twitterにより「センシティブな内容」判定されていますが、センシティブな内容は含んでいません。
写真を撮影するたびにサーバーとなるWindowsマシンのフォルダ内に画像が溜まっていくのが楽しいです。

苦労・工夫したところ

時系列としては災危通報のメール通知の実装よりも先にこちらのカメラ画像を自宅サーバに保管する仕組みを作成していました。

今回初めてSpresenseを使ってみたのですが不慣れなため最初SDカードが認識できず(上記プログラムではSDカードは登場していないのですが)、嵌め込みの甘さが原因だろうと抜き差しを何度も行なって結局1台ダメにしてしまいSpresenseを再度購入しました。。
(しかも原因はプログラムとSDカードにありました)

そういった経緯がありここまで動かせた時には感無量でした。

まとめ

しかし今回これらのプログラムを同時に動かせなかった

本当は上記のプログラムを全て詰め込んで動かすシステムにするつもりでしたが、最初にも書いた通りできませんでした。
理由としては、以下

  • 危災通報を受信できるのはメインコアのみ
  • カメラ撮影が行えるのはメインコアのみ

という点でした。

危災通報はいつ送られてくるかわからないのでずっと待機していなければなりません。
メインコア内では複数スレッド処理はできないため、
危災通報を受けて→カメラ撮影 を行なっていたらカメラ撮影処理中に次の危災情報が受けられなくなります。

どちらかサブコアで処理できればよかったのですが。

今後の展望

というわけで今回これらを1つのシステムにすることができませんでしたが、どうしてもやろうと思ったらSpresenseをもう一台用意した上で色々考えないといけないのかなと思います。

実現できた暁にはさらには部品も追加して揺れの情報も取得できるようにできたら良いなと思っています。
そうするとSpresenseを持つユーザが各地で本システムを動作することにより簡易的な震度観測が行えるのでは、などと考えました。

全体の感想

しかしここまで動かすことができたので大変満足です。
今回は中学生の息子がメインに開発をし、私はアイデア相談やドキュメント整理などお手伝いをしました。

楽しい開発経験ができました。ありがとうございました。

1
siroitori0413のアイコン画像
https://siroitori.hatenablog.com/
ログインしてコメントを投稿する