ESP32を使っていると気軽にデータベースにIoTデータを上げたくなるのですが、準備することが多くて面倒なことが多いです。
Directusは、通常のMySQL/MariaDBやPostgreSQLなどのデータベースにかぶせて、REST APIやGraphQLを使ってCRUD(Create/Read/Update/Delete)を行ったり、WebSocketを使って変更を検知したりすることができます。
通常はデータベース側に直接アクセスできないので、中間にREST APIサーバを立ち上げる必要があるのですが、Directusを立ち上げれば、勝手にREST APIでアクセスできるようにしてくれます。
Directusはデータベースファーストなので、Directus用に特別にスキーマを定義する必要はありません。通常通りDBのスキーマを定義すると、自動的にアクセスできるようになり、スキーマを変更すると自動的に反映されるのはありがたいです。
今回は、Dockerを使ってDirectusをセットアップし、ESP32からJavascriptでCRUDします。
データベースは、PostgreSQLにします。それも今回Dockerで立ち上げます。
ESP32から見たDirectusのメリット
- 何もしなくてもデータベースをREST APIで操作できる
通常外部からDBから値を読んだり書き込んだりする場合は、中継するサーバを立ち上げて、アプリケーションに合わせてREST APIで受け取った内容をもとにSQL文を作成し、DBにアクセスしていました。
Directusを立ち上げると、DBのスキーマはそのままに、DirectusがREST APIとSQLを変換する処理を担い、別途サーバを立ち上げる必要がなくなります。
- データベースアクセス権限を柔軟に定義できる
通常は、DBへのアクセスは共通のDBアカウントで行い、中継サーバで利用者の認証およびアクセス制限を行っていました。
Directusになると、ユーザごとにアクセス権限を設定でき、テーブル単位、カラム単位、CRUDのアクセスごとなど、細かく権限を定義できます。
- 既存のデータベースはそのままに、見せたいデータだけをクライアントに返せる
通常は、見せたいアイテムのカラムを制限したSQLを発行し、クライアント側に返していました。
Directusでは、ユーザごとのアクセス権で許可されたカラムだけがクライアント側に返ります。クライアント側では意識することなく、サーバ側の権限設定やカスタム設定の変更により柔軟にレスポンスを変えることができます。
- ヘッドレスCMS
アイテムを入力する画面を自由に作れます。格納する内容に合わせてわかりやすいUIを作ることができます。リソースの貧弱なESP32視点を除けば、これがDirectusのメリットです。
PostgreSQLのセットアップ
docker-compose.yamlです。
管理しやすいように、pgAdminも入れましたが、必須ではありません(Directusがありますからね)
事前に、Guestと共有するためのホスト側に以下のフォルダを作っておきます。以下は例です。
/share/Container/postgresql/data
/share/Container/pgadmin
services:
pgsql_db:
image: postgres:14.22
container_name: postgress
ports:
- "5432:5432"
restart: always
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=XXXXXXXX
volumes:
- /share/Container/postgresql/data:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4
container_name: pgadmin
ports:
- 8081:80
volumes:
- /share/Container/pgadmin:/var/lib/pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: 'admin@test.or.jp'
PGADMIN_DEFAULT_PASSWORD: 'YYYYYYYY'
depends_on:
- pgsql_db
ブラウザから以下のURLを入力して、pgAdminを開きます。
http://【セットアップしたホスト名】:8081
ログイン・パスワードは、【PGADMIN_DEFAULT_EMAIL】【PGADMIN_DEFAULT_PASSWORD】を指定します。
データベースへの接続を追加します。
・ホスト名/アドレス:【セットアップしたホスト名】
・ユーザ名:【POSTGRES_USER】
・パスワード:【POSTGRES_PASSWORD】
念のため、パスワードの変更と、新しいユーザを登録しておきます。以降は、新しいユーザとして「pguser」が作られたとします。
Directus用のデータベースの作成
PostgreSQLに、Directusで管理するデータベースを作成します。
pgAdminを使うのがよいでしょう。
今回はデータベース名として「directusdb」としました。
所有者は、先ほど作成した「pguser」にします。
データベースを作るだけでテーブルは作成不要です。Directusが自動的に生成するため。
ちなみに、これは新たにテーブルを作る場合であって、既存のデータベースをDirectus対象にする場合は不要です。
Directusのセットアップ
事前に、Guestと共有するための以下のフォルダをホスト側に作っておきます。以下は例です。
/opt/directus/database
/opt/directus/uploads
/opt/directus/extensions
services:
directus:
image: directus/directus:latest
ports:
- 8055:8055
volumes:
- /opt/directus/database:/directus/database
- /opt/directus/uploads:/directus/uploads
- /opt/directus/extensions:/directus/extensions
environment:
SECRET: 【秘匿の値】
DB_CLIENT: 'postgres'
DB_DATABASE: 'directusdb'
DB_HOST: '【セットアップしたホスト名】'
DB_PORT: '5432'
DB_USER: 'pguser'
DB_PASSWORD: '【pguserのパスワード】'
TZ: 'Asia/Tokyo'
CORS_ENABLED: 'true'
CORS_ORIGIN: '*'
WEBSOCKETS_ENABLED: 'true'
ブラウザから、以下のURLを開きます。
http://【セットアップしたホスト名】:8055
また、新たにユーザを作成し、トークンを払い出しておきます。
クライアント側からDirectusを操作するためにはこのトークンが必要です。
テーブルの作成
Directusでテーブルを作成します。
Directusでは、テーブルをデータモデルと呼びます。
今回は以下のテーブルを作りましょう。
名前:env
フィールド
id(プライマリキー、整数を自動生成)
temperature(浮動小数点)
humidity(浮動小数点)
memo(文字列)
data_created(既定の作成日時、自動生成)
見ての通り、温湿度計センサからの値を格納する想定です。
pgAdminの方ものぞいてみると、上記のテーブルと列ができていることがわかります。
ESP32内のJavascriptからのCRUD
さっそくESP32からCRUDしてみます。
リスト取得(REST API)
アイテム一覧を取得します。
以降共通で、Authorizationヘッダーに、ユーザのトークンを指定します。与えられたユーザの権限の範囲で要求が成功します。
import * as http from "Http";
var headers = {
Authorization: "Bearer 【ユーザのトークン】"
};
var result = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/items/env",
headers: headers,
response_type: http.HTTP_RESP_JSON
});
console.log("list=" + JSON.stringify(result));
もしすべてのカラムは不要で一部のみでよければ、queryStringにfieldsとして欲しいカラムを指定します。
var result = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/items/env",
qs: {
fields: "temperature"
},
headers: headers,
response_type: http.HTTP_RESP_JSON
});
console.log("list=" + JSON.stringify(result));
返るアイテムをフィルタリングする場合は、queryStringに条件を指定します。
var result = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/items/env",
qs: {
"filter[temperature][_gte]": 3
},
headers: headers,
response_type: http.HTTP_RESP_JSON
});
console.log("list=" + JSON.stringify(result));
アイテム取得(REST API)
プライマリキーがわかるのであれば、直接アイテムを取得できます。
var result = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/items/env/" + id,
headers: headers,
response_type: http.HTTP_RESP_JSON
});
console.log("list=" + JSON.stringify(result));
アイテム追加(REST API)
アイテムを追加する場合は、BodyにJSONで値を指定するだけです。
var result = http.request({
method: "POST",
url: "http:// 【セットアップしたホスト名】:8055/items/env",
headers: headers,
content_type: "application/json",
body: {
temperature: 15.1,
humidity: 60.2
},
response_type: http.HTTP_RESP_JSON
});
console.log("insert=" + JSON.stringify(result));
アイテム更新(REST API)
プライマリーキーを指定した更新と条件に合致したアイテムの更新があります。
まずは、プライマリキーで更新対象のアイテムを指定して更新する方法です。
var result = http.request({
method: "PATCH",
url: "http://【セットアップしたホスト名】:8055/items/env/" + id,
headers: headers,
content_type: "application/json",
body: {
temperature: 23.4
},
response_type: http.HTTP_RESP_JSON
});
console.log("update=" + JSON.stringify(result));
条件に合致したアイテムを更新する場合は、queryStringのプライマリキー指定ではなく、代わりにBodyにquery.filterを指定します。
var result = http.request({
method: "PATCH",
url: "http://【セットアップしたホスト名】:8055/items/env",
headers: headers,
content_type: "application/json",
body: {
"query": {
"filter": {
"temperature": { "_gte": 3 }
}
},
"data": {
"memo": "gte 3"
}
},
response_type: http.HTTP_RESP_JSON
});
console.log("update=" + JSON.stringify(result));
アイテム削除(REST API)
こちらも、プライマリキーを指定した削除と条件に合致したアイテムの削除の方法があります。
こちらが、プライマリーキーを指定した削除です。
var result = http.request({
method: "DELETE",
url: "http://【セットアップしたホスト名】:8055/items/env/" + id,
headers: headers,
});
こちらが条件に合致したアイテムの削除です。
var result = http.request({
method: "DELETE",
url: "http://【セットアップしたホスト名】:8055/items/env",
headers: headers,
content_type: "application/json",
body:
{
"query": {
"filter": {
"temperature": { "_gte": 3 }
}
}
},
});
ファイルのアップロード・ダウンロード
Directusには、ファイルのアップロード・ダウンロード機能があります。
ファイルアップロード
Node.jsやJavascriptで記載するとこんな感じです。(ESP32の場合は後述)
const formData = new FormData();
formData.append('file', files[0]);
const response = await fetch('http://【セットアップしたホスト名】:8055/files', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
},
body: formData
});
console.log(response);
Content-Type multipart/form-data はESP32にとって面倒なので、単純なプロトコルでアップできるように、Directusのエンドポイント拡張を定義しました。
npx create-directus-extension@latest
? Choose the extension type: endpoint
? Choose a name for the extension: ext-fileupload
? Choose the language to use: javascript
? Auto install dependencies?: Yes
これで必要なファイルとフォルダが作られます。
import crypto from 'node:crypto';
export default (router, {
services,
exceptions
}) => {
const {
FilesService
} = services;
router.post('/', async (req, res, next) => {
try {
const filesService = new FilesService({
schema: req.schema,
accountability: req.accountability
});
const contentType = req.headers['content-type'] || 'application/octet-stream';
const fileName = req.headers['x-file-name'] ? decodeURIComponent(req.headers['x-file-name']) : crypto.randomUUID();
const folder = req.headers['x-folder'] || undefined;
const title = req.headers['x-title'] ? decodeURIComponent(req.headers['x-title']) : fileName;
const payload = {
title: title,
type: contentType,
filename_download: fileName,
folder: folder,
};
const primaryKey = await filesService.uploadOne(req, payload);
// const fileData = await filesService.readOne(primaryKey);
return res.json({
data: {
id: primaryKey
}
});
} catch (error) {
console.error(error);
return next(error);
}
});
};
npm run build
/share/Container/directus/extentionsフォルダに、ext-fileuploadというフォルダを作成し、出来上がったdistフォルダとindex.jsとpackage.jsonをコピーします。
Directusを再起動すると、このエンドポイント拡張が設定されます。
ESP32からはこんな感じでアップロードできます。
var result = http.request({
method: "POST",
url: "http://【セットアップしたホスト名】:8055/ext-fileupload",
headers: headers,
body: new Uint8Array([1,2,3,4]),
response_type: http.HTTP_RESP_JSON
});
console.log("upload=" + JSON.stringify(result));
ファイル一覧取得
ファイルの一覧取得は以下の通りです。
var list = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/files",
qs: {
fields: "id,folder"
},
headers: headers,
response_type: http.HTTP_RESP_JSON
});
console.log(JSON.stringify(list));
ファイルダウンロード
ファイルのダウンロードは以下の通りです。
var image = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/assets/" + id,
headers: headers,
response_type: http.HTTP_RESP_BINARY
});
また、便利な機能として、画像ファイルをダウンロードする際に、画像の縦横サイズを指定すると、リサイズして返してくれます。
var image = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/assets/" + id,
qs: {
width: width,
height: height,
fit: "contain"
},
headers: headers,
response_type: http.HTTP_RESP_BINARY
});
アクセス権の設定例
以下の3つの役割がある想定です。
・env_write:温湿度センサを有し、温湿度をInsertします。
・env_read:Insertされている温湿度データを取得します。また、LCDの背景画像をダウンロードします。
・photo_write:LCDの背景画像ファイルをアップロードします。
それぞれの役割ために、以下のアクセスポリシーを設定します。
特定のテーブルへのInsertのみ許可
・アクセスポリシ名「env_write」
Permission
・コレクション「env」:作成
すべてのアクセス
特定のテーブルのReadのみ許可
・アクセスポリシ名「env_read」
Permission
・コレクション「env」:読み取り
すべてのアクセス
特定のフォルダにファイルのアップロードのみ許可
・アクセスポリシ名「photo_write」
Permission
・directus_files:作成
フィールド権限:すべてのフィールド
フィールド検証:folder 次と等しい 【フォルダのUUID】
フィールドプリセット: { "folder": "【フォルダのUUID】" }
・directus_files:読み取り
アイテム権限:uploaded_by 次と等しい $CURRENT_USER
フィールド権限:取得したい項目
・directus_folders:読み取り
アイテム権限:id 次と等しい 【フォルダのUUID】
フィールドの権限:ID、Parent、Name
特定のフォルダのファイルの一覧取得とダウンロードのみ許可
・アクセスポリシ名「env_read」
Permission
・directus_files:読み取り
アイテム権限:folder 次と等しい 【フォルダのUUID】
フィールド権限:取得したい項目をOn
・directus_folders:読み取り
アイテム権限:id 次と等しい 【フォルダのUUID】
フィールドの権限:ID、Parent、Name
そして、ユーザを作成して各ユーザにこのアクセスポリシを割り当てます。
ちなみに、フィールドプリセットは、アップロード先のフィールド名のデフォルト値になります。これをセットしておくことで、このユーザがアップロードする際に、アップロード先のフォルダのUUIDを覚えておく必要がなくなります。
これで、アクセスポリシを割り当てたユーザごとにトークンを生成し、Directusリクエスト時のAuthorizationヘッダに設定すればよいです。
スマホからファイルのアップロード
Content-Type multipart/form-data でファイルを送れれば何でもよいのですが、手っ取り早く有志のAndroidアプリケーション「HTTP Shortcuts」を使いました。
ベーシックリクエスト設定
・HTTP METHOD:POST
・URL:https://【セットアップしたホスト名】:8055/files
リクエストボディ
・ボディタイプ:パラメーター(form-data)
・パラメータ名:file
・ファイルデータソース:ファイルピッカーを開く
認証
・認証方法:ベアラー(Bearer)認証
・認証Token:【ユーザのトークン】
レスポンスの取り扱い
・成功の例:カスタムメッセージを表示
・メッセージ:アップロード完了
・表示タイプ:トースト(Toast)ポップアップ
あとは、フォトなどの画像アプリケーションから画像ファイルを共有、共有先として HTTP Shortcuts 送信 を選ぶだけで、画像ファイルがアップロードされます。
ESP32サンプル
カメラ画像のアップロード
以下例として、ESP32のM5Cameraでの撮影画像を送ってみます。
import * as camera from "Camera";
import * as http from "Http";
camera.start(camera.MODEL_M5STACK_V2_PSRAM);
var image = camera.getPicture();
console.log("camera captured fsize=" + image.byteLength);
var headers = {
Authorization: "Bearer 【ユーザのトークン】"
};
var result = http.request({
method: "POST",
url: "http://【セットアップしたホスト名】:8055/ext-fileupload",
content_type: "image/jpeg",
headers: headers,
body: image,
response_type: http.HTTP_RESP_JSON
});
console.log(JSON.stringify(result));
温湿度のアップロード
先ほどの再掲です。
import * as env from "Env";
import * as http from "Http";
import * as wire from "Wire";
wire.begin(32, 33);
env.sht40_begin();
var value = env.sht40_get();
const headers = {
Authorization: "Bearer 【ユーザのトークン】"
};
var result = http.request({
method: "POST",
url: "http:// 【セットアップしたホスト名】:8055/items/env",
headers: headers,
content_type: "application/json",
body: {
temperature: value.temperature,
humidity: value.humidity,
},
response_type: http.HTTP_RESP_JSON
});
console.log("insert=" + JSON.stringify(result));
最新の温湿度の取得
import * as http from "Http";
const headers = {
Authorization: "Bearer 【ユーザのトークン】"
};
var qs = {
sort: "-date_created",
limit: 1,
fields: "temperature,humidity"
};
var values = http.request({
method: "GET",
url: "http://【セットアップしたホスト名】:8055/items/env",
headers: headers,
qs: qs,
response_type: http.HTTP_RESP_JSON
});
var value = values.data[0];
console.log(JSON.stringify(value));
WebSocketで変更監視
WebSocketでつないでいると、アイテムの変化があったときに教えてくれます。
import * as wsclient from "WebsocketClient";
wsclient.setCallback((type, payload) =>{
console.log(type, payload);
if( type == "connected" ){
wsclient.send(JSON.stringify({
type: "auth",
access_token: "【ユーザのトークン】"
}));
delay(1000);
wsclient.send(JSON.stringify({
type: "subscribe",
collection: "【監視したいコレクション名】",
}));
}else{
var payload = JSON.parse(payload);
console.log("type=" + payload.type);
if( payload.type == "ping" ){
wsclient.send(JSON.stringify({
type: "pong",
collection: "test",
}));
}
}
});
wsclient.connect("【セットアップしたホスト名】", 8055, "/websocket");
(参考) ESP32で動作するJavascript実行環境
ESP32で動作するJavascript実行環境を公開しています。
Javascriptのコードを書き換えるのに、いちいち再コンパイル不要なので、らくちんです。
「電子書籍:M5StackとJavascriptではじめるIoTデバイス制御」
サポートサイト
ぜひ、手に取ってみてください。
以上