Node.jsの仮想デバイスでAWS IoT Device Shadowを試してみた
こんにちは。人材育成室 育成メンバーチームで研修中の はすと です。
IoTデバイスは、通信環境や電源の状態によって一時的にオフラインになることがあります。その間にスマートライトへONを指示した場合、再接続後にどう反映すればよいでしょうか。
この記事ではAWS IoT Device Shadowを使い、この状況を物理デバイスの代わりにNode.jsで再現します。仮想ライトを停止した状態と、起動したままの状態で希望を変更し、現在状態が追従するまでを試します。
Device Shadowとは
Device Shadowは、Thing(モノ)に対応する状態をAWS IoT Core上に保持する仕組みです。デバイスがオフラインでも、アプリやサービスはShadowに保存された状態を確認したり、希望する状態を更新したりできます。
今回の状態同期で見るのは、desired、reported、deltaの3つです。desiredとreportedはDevice Shadowで決められている状態です。deltaは、両者の差分からAWS IoT Coreが計算する仮想的な状態です。
| 状態 | 更新する側 | 意味 |
|---|---|---|
desired |
アプリや操作側 | デバイスに「こうなってほしい」と指定する状態 |
reported |
デバイス側 | デバイスが報告した現在状態 |
delta |
AWS IoT Core | desiredとreportedの差分 |
今回は扱わない項目
シャドウドキュメントには、属性ごとの更新時刻を持つmetadata、メッセージ作成時刻のtimestamp、リクエストとレスポンスを対応付けるclientToken、更新ごとに増えるversionもあります。今回は状態同期の流れに絞るため、扱いません。
たとえば、ライトの現在状態がOFFで、操作側がONを希望している場合、Shadowドキュメントは次の状態になります。
{
"state": {
"desired": {
"power": "on"
},
"reported": {
"power": "off"
},
"delta": {
"power": "on"
}
}
}
powerは今回の検証用に私が決めた項目ですが、state、desired、reported、deltaはDevice Shadowの仕様で定められています。また、deltaはAWS IoT Coreが算出するため、デバイスや操作側から直接更新しません。
メッセージを溜める仕組みではない
Device Shadowが保持するのは、操作履歴ではなく最新の状態です。
たとえば、デバイスがオフライン中にON、OFF、ONの順で希望状態を変更しても、3件の操作が順番に溜まるわけではありません。最後に更新されたONがdesiredへ残ります。
違いは、デバイスがオフライン中に指定した状態の扱いです。
通常のMQTTメッセージ送受信
今回のように永続セッションを使わない場合、オフライン中に発行されたメッセージは、再接続後には受け取れません。
Device Shadow
Device Shadowでは、操作側が更新した最新のdesiredをAWS IoT Coreが保持します。
MQTTの永続セッションとの違い
MQTTには、切断中のメッセージを再接続後に受け取るための永続セッションもあります。ただし、これはDevice Shadowとは別の仕組みです。
今回は永続セッションを使わず、Node.jsが再接続したときに現在のShadowをGETし、最新状態へ合わせます。
今回確認する流れ
今回は、nodejs-thing-demoを使います。物理的なライトは用意せず、Node.jsの変数を仮想ライトの電源状態として扱います。モノをまだ作成していない場合は、前回書いたこちらの記事を参考にしてください。
Node.jsは再接続時にShadowの/getトピックへメッセージを発行します。取得したShadowにdesiredがあれば、その希望状態を仮想ライトへ反映し、reportedを更新します。
Shadow用のIoTポリシーを新しく作成する
Device ShadowもMQTTを使って操作しますが、通常のトピックではなく、$aws/things/から始まる予約済みトピックを使用します。
予約済みトピックを使う理由
$から始まるトピックはAWS IoT Core用に予約されています。利用者が新しい$で始まるトピックを作成することはできません。
Device Shadowは、Shadowを取得・更新・削除する操作と、その成功・失敗の通知をやり取りするために、この予約済みトピックを使います。$aws/things/配下を独自の用途には使いません。
AWS IoT CoreはShadow用トピックを追加することがあるため、$aws/things/.../shadow/#のようなワイルドカードでの購読は避け、必要なトピックだけを購読します。詳しくはAWS公式のDevice Shadow MQTTトピックを参照してください。
今回Node.jsが使用するトピックは次のとおりです。
| トピック | Node.jsの操作 | 用途 |
|---|---|---|
$aws/things/nodejs-thing-demo/shadow/get |
Publish | 現在のShadowを取得する |
$aws/things/nodejs-thing-demo/shadow/get/accepted |
Subscribe、Receive | 取得に成功したShadowを受け取る |
$aws/things/nodejs-thing-demo/shadow/get/rejected |
Subscribe、Receive | 取得エラーを受け取る |
$aws/things/nodejs-thing-demo/shadow/update |
Publish | reportedを更新する |
$aws/things/nodejs-thing-demo/shadow/update/delta |
Subscribe、Receive | desiredとreportedの差分を受け取る |
AWS IoT Coreの画面左にあるナビゲーションで、「管理」の中にある「セキュリティ」を展開し、「ポリシー」を選択します。ポリシー一覧で「ポリシーを作成」を選択します。
「ポリシー名」にnodejs-thing-demo-shadow-policyを入力し、「ポリシードキュメント」を「JSON」表示へ切り替えます。

次の内容を入力し、「作成」を選択します。<ACCOUNT_ID>は、自分のAWSアカウントIDへ置き換えてください。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/things/nodejs-thing-demo/shadow/get",
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/things/nodejs-thing-demo/shadow/update"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topicfilter/$aws/things/nodejs-thing-demo/shadow/get/accepted",
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topicfilter/$aws/things/nodejs-thing-demo/shadow/get/rejected",
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topicfilter/$aws/things/nodejs-thing-demo/shadow/update/delta"
]
},
{
"Effect": "Allow",
"Action": "iot:Receive",
"Resource": [
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/things/nodejs-thing-demo/shadow/get/accepted",
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/things/nodejs-thing-demo/shadow/get/rejected",
"arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/things/nodejs-thing-demo/shadow/update/delta"
]
}
]
}
続いて、作成したポリシーをデバイス証明書へ追加します。「管理」→「すべてのデバイス」→「モノ」からnodejs-thing-demoを開き、「証明書」タブで証明書を選択します。「ポリシー」タブの「ポリシーをアタッチ」を選び、nodejs-thing-demo-shadow-policyへチェックを入れます。

「ポリシーをアタッチ」を選択します。接続用のnodejs-thing-demo-Policyは外さず、nodejs-thing-demo-shadow-policyを追加します。証明書に2つのポリシーが表示されれば完了です。

Node.jsを仮想ライトとして動かす
前回の検証用リポジトリで、shadow-device.mjsを作成します。証明書や接続先を設定した.envは、そのまま利用できます。
.envをまだ作っていない場合
Node.js 24 LTSとpnpmを用意してから、リポジトリのルートで実行します。
リポジトリのルートで、まずテンプレートから.envを作成します。
pnpm install
cp .env.example .env
次に、「1 個のデバイスを接続」でダウンロードした接続キットをcertsへ展開し、AWS IoT Coreのサーバー証明書を検証するためのAmazon Root CAを保存します。ZIPファイル名はダウンロードした実際の名前に読み替えてください。
mkdir -p certs
unzip ~/Downloads/connect_device_package.zip -d certs
curl -o certs/AmazonRootCA1.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem
.envを開き、AWS_IOT_ENDPOINTを「接続」→「ドメイン設定」に表示されるiot:Data-ATSのドメイン名へ変更します。https://は付けません。接続キット内の証明書・秘密鍵の名前が異なる場合は、AWS_IOT_CERT_PATHとAWS_IOT_PRIVATE_KEY_PATHも実際のパスに合わせます。
AWS_IOT_ENDPOINT=your-endpoint-ats.iot.ap-northeast-1.amazonaws.com
AWS_IOT_CLIENT_ID=nodejs-thing-demo
AWS_IOT_CERT_PATH=./certs/nodejs-thing-demo.cert.pem
AWS_IOT_PRIVATE_KEY_PATH=./certs/nodejs-thing-demo.private.key
AWS_IOT_CA_PATH=./certs/AmazonRootCA1.pem
.envとcertsには接続先や秘密鍵が含まれるため、公開リポジトリへ追加しません。このリポジトリでは.gitignoreで除外されています。
スクリプト全体は、検証用リポジトリのshadow-device.mjsを参照してください。ここでは、Device Shadowの動きを理解するために必要な処理だけを見ます。
ここからは、Node.jsが起動したときの処理を順に確認します。最初に現在のShadowを取得し、保存されているdesired.powerを確認します。/getの応答を受け取れるように、先に/get/acceptedと/get/rejectedを購読します。
この処理では、Shadowとの送受信に使うMQTTトピック名をtopicsにまとめ、配信保証レベルにはqos: 0を指定します。
QoSとは
QoS(Quality of Service)は、MQTTメッセージの配信保証レベルです。AWS IoT Coreでは0と1を使えます。
qos: 0: 確認応答や再送を行いません。通信中に失われても問題ないメッセージ向けです。qos: 1: 受信確認(PUBACK)が返るまで再送します。同じメッセージを複数回受け取る可能性があります。
この検証では、状態そのものをDevice Shadowに保持するため、シンプルなqos: 0を使います。通信が切れた場合でも、再接続後にdesiredを取得し直せます。QoS 2はAWS IoT Coreではサポートされていません。詳しくはAWS公式ドキュメントを参照してください。
client.on("connect", () => {
// Shadowの取得結果と、希望状態の変更を受け取るトピック
const responseTopics = [topics.getAccepted, topics.getRejected, topics.delta];
// QoS 0で購読してから、現在のShadowを取得する
client.subscribe(responseTopics, { qos: 0 }, () => {
// 空の本文で/getを発行する
client.publish(topics.get, "", { qos: 0 });
});
});
次に、desired.powerを受け取ったら仮想ライトの状態を変更し、実際に反映した値をreported.powerとして/updateへ送ります。
function applyDesiredState(desiredState) {
if (!desiredState || !["on", "off"].includes(desiredState.power)) {
return;
}
// 希望状態を仮想ライトへ反映する
power = desiredState.power;
// 反映した現在状態をShadowへ返す
publishReportedState();
}
function publishReportedState() {
const payload = JSON.stringify({
state: { reported: { power } },
});
// reportedを/updateトピックへ送信する
client.publish(topics.update, payload, { qos: 0 });
}
/get/acceptedで取得したdesiredをapplyDesiredState()へ渡します。Shadowがまだない場合の/get/rejectedでは、初期状態のreported.power=offを送信してClassic Shadowを作成します。
Node.jsが接続中にdesiredが変わった場合は、/update/deltaを受信します。この差分にも同じapplyDesiredState()を使い、仮想ライトを更新してreportedを返します。
if (topic === topics.delta) {
console.log("希望状態の変更を受信しました");
applyDesiredState(payload.state);
}
このコードで確認できる範囲
現在状態を送信しましたは、Node.jsから/updateトピックへ送信したことを示すログです。Shadowに保存されたことは、後のコンソール画面で確認します。
このコードは、再接続時には/get、接続中は/update/deltaで状態を同期します。/update/accepted・/update/rejectedによる応答確認は扱いません。
get、update、accepted、rejected、deltaの意味
Device ShadowはMQTTのpublish/subscribeを使って、リクエストとレスポンスに近い流れを作っています。
/get: 現在のShadowを取得します。/update:desiredまたはreportedを更新します。/accepted: リクエストがAWS IoT Coreに受け付けられた場合に返ります。/rejected: 権限不足やJSONの誤りなどで、リクエストが拒否された場合に返ります。/delta:desiredとreportedに差がある場合に、デバイスへ差分を通知します。
最初の状態を登録する
プロジェクトのルートで、次のコマンドを実行します。
同じクライアントIDのNode.jsを複数起動しない
この検証ではMQTTクライアントIDにnodejs-thing-demoを使います。同じIDでNode.jsを2つ起動すると、後から接続した方が既存の接続を切断するため、再接続を繰り返します。
起動前に、以前起動したNode.jsプロセスをCtrl+Cで停止してください。
pnpm start:shadow
Classic Shadowがまだ存在しない場合、/get/rejectedを受信します。今回のコードは、その場合に現在状態であるpower=offをreportedへ送信し、Classic Shadowを作成します。
成功すると、ターミナルには次のログが表示されました。
AWS IoT Coreへ接続しました: nodejs-thing-demo
現在のShadowを取得します
Shadowがまだないため、現在状態を登録します
現在状態を送信しました: power=off
AWS IoT Coreの画面左にあるナビゲーションで、「管理」の中にある「すべてのデバイス」を展開し、「モノ」を開きます。nodejs-thing-demoを選択して「Device Shadow」タブを開き、一覧の「Classic Shadow」を選択します。
Shadowドキュメントのreported.powerがoffになっていることを確認します。

オフライン中に希望状態を変更する
ターミナルでCtrl+Cを入力し、Node.jsを停止します。これで仮想ライトがオフラインの状態になります。
AWS IoT CoreのClassic Shadow画面で「編集」を選択し、desired.powerだけをonへ変更します。reported.powerは、既存のoffを残します。これはNode.jsが実際の状態を報告する値です。
{
"state": {
"desired": {
"power": "on"
}
}
}
「Device Shadow の状態」へ上記のJSONを入力し、「更新」を選択します。

Node.jsは停止しているため、仮想ライトの状態はまだ変わりません。Shadowにはdesired.power=onとreported.power=offが残り、その差としてdelta.power=onが確認できる状態になります。

Node.jsを再接続する
もう一度Node.jsを起動します。
pnpm start:shadow
Node.jsはShadowを取得し、desired.power=onを仮想ライトへ反映します。その後、reported.power=onをAWS IoT Coreへ送信します。
AWS IoT Coreへ接続しました: nodejs-thing-demo
現在のShadowを取得します
Shadowを取得しました
仮想ライト: ON
現在状態を送信しました: power=on
Classic Shadowを確認し、desired.powerとreported.powerがどちらもonになっていることを確認します。両者が一致すると差分がなくなるため、delta.powerは表示されなくなります。

Node.jsを起動したまま状態を変更する
ここではNode.jsを停止せずに、Classic Shadowの「編集」からdesired.powerをoffへ変更します。reportedはデバイス側が更新する値なので、コンソールからは変更しません。
{
"state": {
"desired": {
"power": "off"
}
}
}
更新すると、Node.jsは/update/deltaで差分を受け取り、仮想ライトをOFFへ変更します。その後、reported.power=offを送信します。
希望状態の変更を受信しました
仮想ライト: OFF
現在状態を送信しました: power=off
Classic Shadowでdesired.powerとreported.powerがどちらもoffになり、deltaがなくなることを確認します。

問題なさそうです。画面を見ると、Node.jsが差分を受け取ってreported.powerを更新し、希望状態とのずれが解消されたことが分かります。
検証結果
Node.jsを停止している間も、コンソールから更新したdesired.power=onがClassic Shadowに保持されました。この時点ではreported.power=offのままで、AWS IoT Coreがdelta.power=onを算出しています。
Node.jsを再起動すると、保存されていた希望状態を取得して仮想ライトをONへ変更し、reported.power=onを送信できました。desiredとreportedが一致した後は、Shadowドキュメントからdeltaがなくなることも確認できました。
Node.jsが接続中にdesired.powerを変更した場合も、/update/deltaを受信して仮想ライトへ反映し、reported.powerを更新できることを確認しました。
まとめ
Node.jsを仮想ライトとして使い、専用のハードがなくても、オフライン中に指定した状態を再接続後に反映できるかやってみました。desired、reported、deltaを実際に動かしたことで、Device Shadowの動きを理解できました。
次は実際のハードウェアをつないで遊んでみたいと思います。




