<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Chiwai Chan Blog</title>
        <link>https://www.chiwaichan.co.nz/blog</link>
        <description>Chiwai Chan Blog</description>
        <lastBuildDate>Sun, 05 Jul 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[I Got Me One of These - reBot Arm B601-RS with Gripper]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/07/05/rebot-arm-b601-rs-gripper</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/07/05/rebot-arm-b601-rs-gripper</guid>
            <pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Unboxing my new Seeed Studio reBot Arm B601-RS 6-axis robotic arm with gripper]]></description>
            <content:encoded><![CDATA[<p>Can't wait to hook this up to my Thor</p>
<p><img decoding="async" loading="lazy" alt="reBot Arm B601-RS Assembled Kit with Gripper" src="https://www.chiwaichan.co.nz/assets/images/rebot-arm-b601-rs-e54107aa8578d9c4b68c4921a0744d5a.jpeg" width="2000" height="1500" class="img_ev3q"></p>]]></content:encoded>
            <category>Seeed Studio</category>
            <category>robotic-arm</category>
            <category>Robotics</category>
            <category>embodied-ai</category>
            <category>lerobot</category>
            <category>ros2</category>
            <category>ai</category>
            <category>Edge Computing</category>
        </item>
        <item>
            <title><![CDATA[Voice to Robotics: Driving a Sphero RVR — AWS User Group Wellington]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Slides and notes from my AWS User Group Wellington meet-up talk on driving a Sphero RVR with your voice — Amazon Nova 2 Sonic, AWS IoT Core, AppSync, Amplify Gen 2, and a XIAO ESP32-S3 bridge.]]></description>
            <content:encoded><![CDATA[<p>These are the slides from my talk at the AWS User Group Wellington meet-up on <strong>26 May 2026</strong>, walking through how I built a real-time pipeline that listens to spoken commands in the browser and drives a Sphero RVR — exact distances, exact angles — with live telemetry streaming back into the UI.</p>
<iframe src="/slides/2026-05-26-voice-to-robotics-sphero-rvr-aws-user-group/slides.html" width="100%" height="540" style="border:1px solid #ccc;border-radius:8px" title="Voice to Robotics: Driving a Sphero RVR — slides" allowfullscreen=""></iframe>
<blockquote>
<p>Tip: click into the slides and use the arrow keys to navigate, or hit the fullscreen button for the best experience.</p>
</blockquote>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-the-talk-covers">What the talk covers<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group#what-the-talk-covers" class="hash-link" aria-label="Direct link to What the talk covers" title="Direct link to What the talk covers">​</a></h2>
<ul>
<li><strong>The problem</strong> — turning a spoken sentence into a precise, exact-distance robot motion in real time</li>
<li><strong>Voice in</strong> — Amazon Nova 2 Sonic bidirectional streaming with forced tool use as a "robot controller"</li>
<li><strong>Commands down</strong> — browser → AWS IoT Core MQTT publish via temporary Cognito credentials, no backend</li>
<li><strong>The edge</strong> — a Seeed Studio XIAO ESP32-S3 bridging WiFi/MQTT to the Sphero RVR's UART SDK</li>
<li><strong>Maneuver framework</strong> — locator-based distance and IMU-based turn, both running on the device, so "1 metre" actually means 1 metre</li>
<li><strong>Telemetry up</strong> — an AWS CDK stack tying IoT Rule → Lambda → AppSync → DynamoDB → live GraphQL subscription back into the React UI</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-three-repos-behind-the-demo">The three repos behind the demo<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group#the-three-repos-behind-the-demo" class="hash-link" aria-label="Direct link to The three repos behind the demo" title="Direct link to The three repos behind the demo">​</a></h2>
<ul>
<li><strong><code>arduino-aws-iot</code></strong> — ESP32-S3 firmware. The talk focuses on the <code>sphero_rvr</code> device group, including the new maneuver executor (<code>forward</code>, <code>reverse</code>, <code>turn</code>, <code>cancel</code>) layered on top of the existing <code>drive</code> D-pad.</li>
<li><strong><code>cdk-iot-sphero-rvr-streaming</code></strong> — AWS CDK stack that filters the RVR telemetry MQTT topic, flattens the nested payload in Lambda, and forwards it as a GraphQL mutation to an Amplify-managed AppSync API.</li>
<li><strong><code>amplify-react-nova-sonic-voice-chat-sphero-rvr</code></strong> — React + AWS Amplify Gen 2 frontend. The same Cognito identity is used for Bedrock streaming, IoT publish, and the AppSync subscription that surfaces live telemetry.</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="related-deep-dive-posts">Related deep-dive posts<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group#related-deep-dive-posts" class="hash-link" aria-label="Direct link to Related deep-dive posts" title="Direct link to Related deep-dive posts">​</a></h2>
<p>If you want background on the building blocks before the talk:</p>
<ol>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3">Sphero RVR with AWS IoT Core on a Seeed Studio XIAO ESP32-S3</a></li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify">Real-Time Voice Chat with Amazon Nova Sonic, React and AWS Amplify Gen 2</a></li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="thanks">Thanks<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group#thanks" class="hash-link" aria-label="Direct link to Thanks" title="Direct link to Thanks">​</a></h2>
<p>Big thanks to the AWS User Group Wellington organisers and everyone who came along — happy to chat about any of the code, the maneuver framework, or where the project goes next.</p>]]></content:encoded>
            <category>AWS</category>
            <category>Bedrock</category>
            <category>Nova Sonic</category>
            <category>Amazon Nova</category>
            <category>IoT</category>
            <category>MQTT</category>
            <category>AWS IoT Core</category>
            <category>AppSync</category>
            <category>Amplify</category>
            <category>cdk</category>
            <category>sphero</category>
            <category>rvr</category>
            <category>esp32s3</category>
            <category>xiao</category>
            <category>Seeed Studio</category>
            <category>Robotics</category>
            <category>talk</category>
            <category>meetup</category>
        </item>
        <item>
            <title><![CDATA[Voice to Robotics: Driving a Sphero RVR]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AWS User Group Wellington meet-up talk — 26 May 2026]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="bg right:40%" src="https://www.chiwaichan.co.nz/assets/images/rvr-2450c2399f73bbcdc2b367e17caf940b.jpg" width="4000" height="3000" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="driving-a-sphero-rvr-with-your-voice">Driving a Sphero RVR with your voice<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#driving-a-sphero-rvr-with-your-voice" class="hash-link" aria-label="Direct link to Driving a Sphero RVR with your voice" title="Direct link to Driving a Sphero RVR with your voice">​</a></h2>
<p><em>Nova 2 Sonic · Bedrock · IoT Core · AppSync · Amplify Gen 2 · CDK</em></p>
<p><strong>Chiwai Chan</strong> — AWS User Group Wellington meet-up
26 May 2026</p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="who-am-i">Who am I?<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#who-am-i" class="hash-link" aria-label="Direct link to Who am I?" title="Direct link to Who am I?">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="tinkerer--cloud--iot--robotics--generative-ai">Tinkerer · Cloud · IoT · Robotics · Generative AI<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#tinkerer--cloud--iot--robotics--generative-ai" class="hash-link" aria-label="Direct link to Tinkerer · Cloud · IoT · Robotics · Generative AI" title="Direct link to Tinkerer · Cloud · IoT · Robotics · Generative AI">​</a></h3>
<p>&nbsp;</p>
<p><a href="https://chiwaichan.co.nz/" target="_blank" rel="noopener noreferrer">https://chiwaichan.co.nz</a>
<a href="https://x.com/chiwaichanconz" target="_blank" rel="noopener noreferrer">https://x.com/chiwaichanconz</a>
<a href="https://github.com/chiwaichan" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan</a></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-are-we-building">What are we building?<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#what-are-we-building" class="hash-link" aria-label="Direct link to What are we building?" title="Direct link to What are we building?">​</a></h2>
<ul>
<li>Speak into a browser microphone</li>
<li>Amazon Nova 2 Sonic turns speech into a structured tool call</li>
<li>Tool call is published to AWS IoT Core as an MQTT command</li>
<li>A Sphero RVR receives it and drives</li>
<li>Live telemetry streams back into the browser in real time</li>
</ul>
<blockquote>
<p>Voice → Cloud → Robot → Telemetry → Voice loop</p>
</blockquote>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-a-sphero-rvr">Why a Sphero RVR?<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#why-a-sphero-rvr" class="hash-link" aria-label="Direct link to Why a Sphero RVR?" title="Direct link to Why a Sphero RVR?">​</a></h2>
<ul>
<li>Off-the-shelf programmable robot with a well-documented UART protocol</li>
<li>I ported the Sphero RVR SDK into the ESP32-S3 firmware myself</li>
<li>Onboard locator (X/Y in metres) and IMU (yaw in degrees)</li>
</ul>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/01-overall-architecture-ded224ee711b0966bbd4ff60ba62ee97.png" width="1120" height="1880" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-three-repos-behind-the-demo">The three repos behind the demo<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#the-three-repos-behind-the-demo" class="hash-link" aria-label="Direct link to The three repos behind the demo" title="Direct link to The three repos behind the demo">​</a></h2>
<ul>
<li><strong>ESP32-S3 firmware</strong> — the <code>sphero_rvr</code> device group, extracted into a dedicated public repo:
<a href="https://github.com/chiwaichan/platformio-aws-iot-seeed-studio-esp32s3-sphero-rvr" target="_blank" rel="noopener noreferrer"><code>platformio-aws-iot-seeed-studio-esp32s3-sphero-rvr</code></a></li>
<li><strong><code>cdk-iot-sphero-rvr-streaming</code></strong> — CDK stack: IoT Rule → Lambda → AppSync</li>
<li><strong><code>amplify-react-nova-sonic-voice-chat-sphero-rvr</code></strong> — React + Amplify Gen 2 frontend</li>
</ul>
<blockquote>
<p>One device group. One streaming pipeline. One serverless web app.</p>
</blockquote>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="voice-in-nova-2-sonic-as-a-robot-controller">Voice in: Nova 2 Sonic as a <em>robot controller</em><a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#voice-in-nova-2-sonic-as-a-robot-controller" class="hash-link" aria-label="Direct link to voice-in-nova-2-sonic-as-a-robot-controller" title="Direct link to voice-in-nova-2-sonic-as-a-robot-controller">​</a></h2>
<p><em>Amazon Nova 2 Sonic on Amazon Bedrock — bidirectional stream straight from the browser.</em></p>
<ul>
<li>Not a chatbot — a tool dispatcher</li>
<li><code>toolChoice: { any: {} }</code> forces a tool call on every utterance</li>
<li>5 tools, one per active firmware verb</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-5-tools-mirror-the-firmware-mqtt-commands">The 5 tools (mirror the firmware MQTT commands)<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#the-5-tools-mirror-the-firmware-mqtt-commands" class="hash-link" aria-label="Direct link to The 5 tools (mirror the firmware MQTT commands)" title="Direct link to The 5 tools (mirror the firmware MQTT commands)">​</a></h2>
<table><thead><tr><th>Tool</th><th>What it does</th></tr></thead><tbody><tr><td><code>drive</code></td><td>D-pad tank — immediate, runs until told otherwise</td></tr><tr><td><code>forward</code></td><td>Drive forward a given distance, then auto-stop</td></tr><tr><td><code>reverse</code></td><td>Drive backward a given distance, then auto-stop</td></tr><tr><td><code>turn</code></td><td>Rotate in place by a given angle, then auto-stop</td></tr><tr><td><code>cancel</code></td><td>Abort the running maneuver</td></tr></tbody></table>
<blockquote>
<p>"go forward 1 metre" → <code>forward(distance_m: 1.0)</code> → MQTT publish.</p>
</blockquote>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/03-bidirectional-streaming-72b1b745541dd196d9bb780a460722c7.png" width="2230" height="3537" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="browser--bedrock-direct-no-backend">Browser → Bedrock direct (no backend)<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#browser--bedrock-direct-no-backend" class="hash-link" aria-label="Direct link to Browser → Bedrock direct (no backend)" title="Direct link to Browser → Bedrock direct (no backend)">​</a></h2>
<ul>
<li>React + AWS Amplify Gen 2 in the browser</li>
<li>Amazon Cognito User Pool + Identity Pool gives temporary AWS credentials</li>
<li>IAM policy scoped to <code>amazon.nova-2-sonic-v1:0</code> and the RVR MQTT topic</li>
<li><code>InvokeModelWithBidirectionalStreamCommand</code> over HTTPS / TLS 1.2+</li>
<li>No EC2, no containers, no Lambda in the voice path</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="browser--aws-iot-core-commands-down">Browser → AWS IoT Core (commands down)<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#browser--aws-iot-core-commands-down" class="hash-link" aria-label="Direct link to Browser → AWS IoT Core (commands down)" title="Direct link to Browser → AWS IoT Core (commands down)">​</a></h2>
<ul>
<li>Same temporary creds are also scoped for <code>iot:Publish</code></li>
<li>Frontend publishes via <code>IoTDataPlaneClient</code> to:
<code>the-project/sphero-rvr/XIAOSpheroRVR/action</code></li>
<li>Cognito identity gets an AWS IoT Core policy attached at first publish</li>
<li>Decouples the browser from the robot — robot can be anywhere on the internet</li>
</ul>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/02-mqtt-message-flow-60d9e28bd0b79807dd09db8ce0ac3d72.png" width="1273" height="2989" class="img_ev3q"></p>
<hr>
<p><img decoding="async" loading="lazy" alt="bg right:42%" src="https://www.chiwaichan.co.nz/assets/images/seeed-studio-0a90fa56471bc3ede853221c1a523982.jpg" width="4000" height="2252" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-edge-xiao-esp32-s3">The edge: XIAO ESP32-S3<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#the-edge-xiao-esp32-s3" class="hash-link" aria-label="Direct link to The edge: XIAO ESP32-S3" title="Direct link to The edge: XIAO ESP32-S3">​</a></h2>
<ul>
<li>Seeed Studio <strong>XIAO ESP32-S3</strong> on the Expansion Board</li>
<li>UART bridge between WiFi/MQTT and the RVR's SDK protocol</li>
<li>Subscribes to the RVR's command topic over MQTT/TLS</li>
<li>Publishes telemetry to <code>the-project/sphero-rvr/XIAOSpheroRVR/state</code></li>
<li>OLED display for "is it alive?" at-a-glance status</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-sphero-rvr--whats-on-board">The Sphero RVR — what's on board<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#the-sphero-rvr--whats-on-board" class="hash-link" aria-label="Direct link to The Sphero RVR — what's on board" title="Direct link to The Sphero RVR — what's on board">​</a></h2>
<ul>
<li>Two internal processors (Nordic + ST) talking over UART</li>
<li>IMU (pitch / roll / yaw), accelerometer, gyroscope</li>
<li>Wheel encoders, <strong>locator</strong> (X / Y in metres), velocity, speed</li>
<li>Color sensor, ambient light, compass / magnetometer</li>
<li>Motor thermal protection, stall detection, gyro-max collision events</li>
<li>Battery state with change notifications</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="forward--reverse--drive-an-exact-distance"><code>forward</code> / <code>reverse</code> — drive an exact distance<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#forward--reverse--drive-an-exact-distance" class="hash-link" aria-label="Direct link to forward--reverse--drive-an-exact-distance" title="Direct link to forward--reverse--drive-an-exact-distance">​</a></h2>
<div class="language-cpp codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cpp codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token comment" style="color:rgb(106, 153, 85)">// On start: snapshot start_xy + lock current heading</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">LocatorData loc </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> rvr</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">getLocator</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">_start_x </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> loc</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">x</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain">  _start_y </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> loc</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">y</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">rvr</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">driveWithHeading</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">_speed</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> heading</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> reverseFlag</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token comment" style="color:rgb(106, 153, 85)">// Every tick: stop when traveled distance &gt;= target</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">float</span><span class="token plain"> dx </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> loc</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">x </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> _start_x</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> dy </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> loc</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">y </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> _start_y</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">_traveled </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">sqrtf</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">dx</span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain">dx </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> dy</span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain">dy</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">_traveled </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;=</span><span class="token plain"> _distance_m</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> rvr</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">driveStop</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> DONE</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<blockquote>
<p>"Drive forward 1 metre" actually means <strong>1 metre</strong>, not "1 second-ish".</p>
</blockquote>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="turn--rotate-an-exact-number-of-degrees"><code>turn</code> — rotate an exact number of degrees<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#turn--rotate-an-exact-number-of-degrees" class="hash-link" aria-label="Direct link to turn--rotate-an-exact-number-of-degrees" title="Direct link to turn--rotate-an-exact-number-of-degrees">​</a></h2>
<div class="language-cpp codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cpp codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token comment" style="color:rgb(106, 153, 85)">// Accumulate signed yaw delta, normalised across the ±180° wrap</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">float</span><span class="token plain"> delta </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> yaw </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> _lastYaw</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">while</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">delta </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain">  </span><span class="token number" style="color:rgb(181, 206, 168)">180.0f</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> delta </span><span class="token operator" style="color:rgb(212, 212, 212)">-=</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">360.0f</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">while</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">delta </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">180.0f</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> delta </span><span class="token operator" style="color:rgb(212, 212, 212)">+=</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">360.0f</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">_accumulated </span><span class="token operator" style="color:rgb(212, 212, 212)">+=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">_degrees </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">?</span><span class="token plain"> delta </span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain">delta</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">_accumulated </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;=</span><span class="token plain"> _target</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">driveStop</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> DONE</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<ul>
<li>Signed degrees: positive = CW, negative = CCW</li>
<li>Multi-revolution allowed (<code>degrees: 720</code> = two spins right)</li>
<li>"u-turn" / "spin around" handled by the model, not the firmware</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="telemetry-up-iot-rule--lambda--appsync">Telemetry up: IoT Rule → Lambda → AppSync<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#telemetry-up-iot-rule--lambda--appsync" class="hash-link" aria-label="Direct link to Telemetry up: IoT Rule → Lambda → AppSync" title="Direct link to Telemetry up: IoT Rule → Lambda → AppSync">​</a></h2>
<ul>
<li>RVR publishes nested JSON to <code>the-project/sphero-rvr/+/state</code></li>
<li>AWS IoT Rule SQL: <code>SELECT *, topic(3) AS device_name FROM ...</code></li>
<li>AWS Lambda flattens it for GraphQL:<!-- -->
<ul>
<li><code>imu.pitch</code> → <code>imuPitch</code></li>
<li><code>motors.left_temp_c</code> → <code>motorLeftTempC</code></li>
</ul>
</li>
<li>AWS AppSync runs the <code>createRVRState</code> mutation</li>
<li>AppSync auto-persists to Amazon DynamoDB and pushes via subscription</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="live-telemetry-in-the-browser">Live telemetry in the browser<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#live-telemetry-in-the-browser" class="hash-link" aria-label="Direct link to Live telemetry in the browser" title="Direct link to Live telemetry in the browser">​</a></h2>
<ul>
<li>Same React app uses AWS Amplify <code>generateClient()</code></li>
<li>Subscribes to <code>onCreateRVRState</code> GraphQL subscription</li>
<li>Every MQTT telemetry frame from the RVR appears in the UI within ~hundreds of ms</li>
<li>Battery · IMU · motors · position · compass · light · color — all live</li>
</ul>
<blockquote>
<p>Voice in, MQTT out, GraphQL back — all on one Cognito identity.</p>
</blockquote>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/05-sensor-data-pipeline-147b429f830aecf92b85da5498139bd3.png" width="2164" height="3073" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="live-demo">Live demo<a href="https://www.chiwaichan.co.nz/blog/2026/05/26/voice-to-robotics-sphero-rvr-aws-user-group/slides#live-demo" class="hash-link" aria-label="Direct link to Live demo" title="Direct link to Live demo">​</a></h2>
<p>🎤 → ☁️ → 🤖 → 📊</p>
<p><em>(Fingers crossed.)</em></p>
<hr>
<h1>Thank you! — Questions?</h1>
<p><strong>Related write-ups:</strong>
<a href="https://chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3" target="_blank" rel="noopener noreferrer">Sphero RVR + AWS IoT Core on XIAO ESP32-S3</a> ·
<a href="https://chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify" target="_blank" rel="noopener noreferrer">Real-time voice chat with Nova Sonic + Amplify</a></p>
<p><strong><a href="https://chiwaichan.co.nz/" target="_blank" rel="noopener noreferrer">https://chiwaichan.co.nz</a></strong></p>
<p><strong><a href="https://x.com/chiwaichanconz" target="_blank" rel="noopener noreferrer">https://x.com/chiwaichanconz</a></strong></p>
<p><strong><a href="https://github.com/chiwaichan" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan</a></strong></p>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/qr-meetup-d5449cbde3735ea74cbbf2e244cf34a3.jpg" width="340" height="400" class="img_ev3q"></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Voice to Robotics: Fingerspelling American Sign Language — AWS User Group Wellington]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group</guid>
            <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Slides and notes from my AWS User Group Wellington meet-up talk on building a real-time voice-to-ASL fingerspelling system using Amazon Nova 2 Sonic, AWS IoT Core, Strands Agents on NVIDIA Jetson, and the Pollen Robotics Amazing Hand]]></description>
            <content:encoded><![CDATA[<p>These are the slides from my talk at the AWS User Group Wellington meet-up on <strong>29 April 2026</strong>, walking through how I built a real-time pipeline that listens to speech in the browser and drives a robotic hand to fingerspell the words in American Sign Language (ASL).</p>
<iframe src="/slides/2026-04-29-voice-to-robotics-fingerspell-asl-aws-user-group/slides.html" width="100%" height="540" style="border:1px solid #ccc;border-radius:8px" title="Voice to Robotics: Fingerspelling ASL — slides" allowfullscreen=""></iframe>
<blockquote>
<p>Tip: click into the slides and use the arrow keys to navigate, or hit the fullscreen button for the best experience.</p>
</blockquote>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-the-talk-covers">What the talk covers<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group#what-the-talk-covers" class="hash-link" aria-label="Direct link to What the talk covers" title="Direct link to What the talk covers">​</a></h2>
<ul>
<li><strong>The problem</strong> — bridging spoken language and ASL fingerspelling in real time, end-to-end</li>
<li><strong>Voice in</strong> — Amazon Nova 2 Sonic bidirectional streaming as a "dumb" speech-to-text relay with forced tool use</li>
<li><strong>Cloud glue</strong> — AWS IoT Core MQTT, AppSync subscriptions, and an AWS CDK stack tying it all together</li>
<li><strong>Edge AI agent</strong> — a Strands Agent on an NVIDIA Jetson translating sentences into servo commands</li>
<li><strong>The hand</strong> — driving the <a href="https://huggingface.co/blog/pollen-robotics/amazing-hand" target="_blank" rel="noopener noreferrer">Pollen Robotics Amazing Hand</a> for ASL fingerspelling</li>
<li><strong>Lessons learned</strong> — latency, reliability, and what I'd do differently</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="related-deep-dive-posts">Related deep-dive posts<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group#related-deep-dive-posts" class="hash-link" aria-label="Direct link to Related deep-dive posts" title="Direct link to Related deep-dive posts">​</a></h2>
<p>If you want to go deeper than the slides allow, the full three-part write-up lives here:</p>
<ol>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand">Part 1 — Frontend and Voice Processing</a></li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk">Part 2 — Cloud Infrastructure (IoT, AppSync, CDK)</a></li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand">Part 3 — Edge AI Agent (Strands, NVIDIA Jetson, Amazing Hand)</a></li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="thanks">Thanks<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group#thanks" class="hash-link" aria-label="Direct link to Thanks" title="Direct link to Thanks">​</a></h2>
<p>Big thanks to the AWS User Group organisers and everyone who came along — happy to chat about any of this, the code, or where it goes next.</p>]]></content:encoded>
            <category>AWS</category>
            <category>Bedrock</category>
            <category>Nova Sonic</category>
            <category>Amazon Nova</category>
            <category>Sign Language</category>
            <category>Accessibility</category>
            <category>IoT</category>
            <category>MQTT</category>
            <category>AWS IoT Core</category>
            <category>Strands Agents</category>
            <category>NVIDIA</category>
            <category>Jetson</category>
            <category>Robotics</category>
            <category>talk</category>
            <category>meetup</category>
        </item>
        <item>
            <title><![CDATA[Voice to Robotics: Fingerspelling ASL]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides</guid>
            <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AWS User Group Wellington meet-up talk — 29 April 2026]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="bg right:40%" src="https://www.chiwaichan.co.nz/assets/images/amazing-hand-32f49bc117f85fda191a8e3f1cc0282e.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="fingerspelling-american-sign-language">Fingerspelling American Sign Language<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#fingerspelling-american-sign-language" class="hash-link" aria-label="Direct link to Fingerspelling American Sign Language" title="Direct link to Fingerspelling American Sign Language">​</a></h2>
<p><em>Nova 2 Sonic · Bedrock · IoT Core · AppSync · Strands</em></p>
<p><strong>Chiwai Chan</strong> — AWS User Group Wellington meet-up
29 April 2026</p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="who-am-i">Who am I?<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#who-am-i" class="hash-link" aria-label="Direct link to Who am I?" title="Direct link to Who am I?">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="tinkerer--cloud--iot--robotics--generative-ai">Tinkerer · Cloud · IoT · Robotics · Generative AI<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#tinkerer--cloud--iot--robotics--generative-ai" class="hash-link" aria-label="Direct link to Tinkerer · Cloud · IoT · Robotics · Generative AI" title="Direct link to Tinkerer · Cloud · IoT · Robotics · Generative AI">​</a></h3>
<p>&nbsp;</p>
<p><a href="https://chiwaichan.co.nz/" target="_blank" rel="noopener noreferrer">https://chiwaichan.co.nz</a>
<a href="https://x.com/chiwaichanconz" target="_blank" rel="noopener noreferrer">https://x.com/chiwaichanconz</a>
<a href="https://github.com/chiwaichan" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan</a></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-are-we-building">What are we building?<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#what-are-we-building" class="hash-link" aria-label="Direct link to What are we building?" title="Direct link to What are we building?">​</a></h2>
<ul>
<li>Speak into a browser microphone</li>
<li>Cloud cleans + relays the text</li>
<li>An edge agent on an NVIDIA Jetson translates it</li>
<li>A robotic hand fingerspells it in ASL — in real time</li>
</ul>
<blockquote>
<p>Voice → Cloud → Edge AI → Robot hand</p>
</blockquote>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/ui-581e37bbaad847c73d7769ce7fc1bb59.png" width="1871" height="939" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-fingerspelling">Why fingerspelling?<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#why-fingerspelling" class="hash-link" aria-label="Direct link to Why fingerspelling?" title="Direct link to Why fingerspelling?">​</a></h2>
<ul>
<li>ASL fingerspelling is a constrained, well-defined target</li>
<li>One letter ≈ one hand pose — perfect for a 4-finger robotic hand</li>
<li>Builds a foundation for richer signs later</li>
<li>Real accessibility use cases</li>
</ul>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/01-overall-system-architecture-d475232d4626ab0d176906d7fd5497db.png" width="2714" height="2369" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="voice-in-nova-2-sonic-as-a-dumb-relay">Voice in: Nova 2 Sonic as a "dumb relay"<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#voice-in-nova-2-sonic-as-a-dumb-relay" class="hash-link" aria-label="Direct link to Voice in: Nova 2 Sonic as a &quot;dumb relay&quot;" title="Direct link to Voice in: Nova 2 Sonic as a &quot;dumb relay&quot;">​</a></h2>
<p><em>Amazon Nova 2 Sonic on Amazon Bedrock.</em></p>
<ul>
<li>Not used as a chatbot</li>
<li>Forced tool use via Bedrock's tool API: <code>toolChoice: { any: {} }</code></li>
<li>Every utterance fires <code>send_text</code> with cleaned-up text</li>
<li>Removes filler ("um", "uh"), fixes grammar</li>
<li>Translates non-English speech to English</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-mqtt-in-the-middle">Why MQTT in the middle?<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#why-mqtt-in-the-middle" class="hash-link" aria-label="Direct link to Why MQTT in the middle?" title="Direct link to Why MQTT in the middle?">​</a></h2>
<ul>
<li>Decouples the browser from the robot</li>
<li><strong>AWS IoT Core</strong> handles auth, fan-out, retained messages</li>
<li>Fan-out to the browser via <strong>AWS Lambda → AWS AppSync</strong> subscriptions</li>
<li>Edge device can be anywhere on the internet</li>
<li>Same pattern scales to many hands / many speakers</li>
</ul>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/07-mqtt-command-flow-0e94235338094807c0421bcd77247fce.png" width="2268" height="2041" class="img_ev3q"></p>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/jetson-thor-2575e6d4b1e626d2f5c10b10ac6ac58c.jpeg" width="3648" height="2052" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-edge-agent-nvidia-jetson">The edge agent (NVIDIA Jetson)<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#the-edge-agent-nvidia-jetson" class="hash-link" aria-label="Direct link to The edge agent (NVIDIA Jetson)" title="Direct link to The edge agent (NVIDIA Jetson)">​</a></h2>
<ul>
<li>Strands Agent powered by <strong>Amazon Nova 2 Lite</strong> (Amazon Bedrock)</li>
<li>Subscribes to the sentence MQTT topic</li>
<li>Tools: split sentence → letters → servo poses</li>
<li>Streams video + state back via <strong>AWS IoT Core</strong></li>
</ul>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/02-agent-architecture-bf6e4197643b084a205a44913a8e3a9c.png" width="1780" height="3268" class="img_ev3q"></p>
<hr>
<p><img decoding="async" loading="lazy" alt="bg right:40%" src="https://www.chiwaichan.co.nz/assets/images/amazing-hand-32f49bc117f85fda191a8e3f1cc0282e.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-robot-hand">The robot hand<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#the-robot-hand" class="hash-link" aria-label="Direct link to The robot hand" title="Direct link to The robot hand">​</a></h2>
<ul>
<li><a href="https://huggingface.co/blog/pollen-robotics/amazing-hand" target="_blank" rel="noopener noreferrer">Pollen Robotics Amazing Hand</a></li>
<li>Open-source, manufactured by Seeed Studio</li>
<li>4 fingers, multiple servos per finger</li>
<li>Custom calibration per ASL letter</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="live-demo">Live demo<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#live-demo" class="hash-link" aria-label="Direct link to Live demo" title="Direct link to Live demo">​</a></h2>
<p>🎤 → ☁️ → 🤖 ✋</p>
<p><em>(Fingers crossed.)</em></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="latency-where-the-time-goes">Latency: where the time goes<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#latency-where-the-time-goes" class="hash-link" aria-label="Direct link to Latency: where the time goes" title="Direct link to Latency: where the time goes">​</a></h2>
<p><em>Measured from New Zealand → AWS <code>us-east-1</code>.</em></p>
<ul>
<li>Browser → Nova 2 Sonic <em>(Bedrock)</em>: sub-second to first token</li>
<li>Nova → MQTT publish: ~tens of ms</li>
<li>MQTT → Jetson: ~tens of ms</li>
<li>Letter pose → next letter: paced for legibility, not speed</li>
</ul>
<blockquote>
<p>Bottleneck is <em>physical</em> movement, not the cloud.</p>
</blockquote>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/model-timings-c33b5f8b205fa4f593c99cb05130ea15.png" width="2934" height="380" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-surprised-me">What surprised me<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#what-surprised-me" class="hash-link" aria-label="Direct link to What surprised me" title="Direct link to What surprised me">​</a></h2>
<ul>
<li>Bedrock's forced tool use is a great "shape the output" trick</li>
<li><strong>AWS IoT Core + AppSync</strong> gives you real-time UI almost for free</li>
<li>Servo calibration is the hardest part 😅</li>
<li>Nova 2 Sonic handles accents and code-switching well</li>
</ul>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whats-next">What's next<a href="https://www.chiwaichan.co.nz/blog/2026/04/29/voice-to-robotics-fingerspell-asl-aws-user-group/slides#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next">​</a></h2>
<ul>
<li>More signs beyond fingerspelling</li>
<li>Two-handed signing</li>
<li>On-device speech for offline mode</li>
<li>Attach the Amazing Hand to my LeRobot SO-101 arms</li>
<li>Vision-Language-Action (VLA) models on the Jetson Thor</li>
</ul>
<hr>
<h1>Thank you! — Questions?</h1>
<p><strong>Blog series:</strong>
<a href="https://chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand" target="_blank" rel="noopener noreferrer">Part 1 — Frontend &amp; Voice</a> ·
<a href="https://chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk" target="_blank" rel="noopener noreferrer">Part 2 — Cloud Infrastructure</a> ·
<a href="https://chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand" target="_blank" rel="noopener noreferrer">Part 3 — Edge AI Agent</a></p>
<p><strong><a href="https://chiwaichan.co.nz/" target="_blank" rel="noopener noreferrer">https://chiwaichan.co.nz</a></strong></p>
<p><strong><a href="https://x.com/chiwaichanconz" target="_blank" rel="noopener noreferrer">https://x.com/chiwaichanconz</a></strong></p>
<p><strong><a href="https://github.com/chiwaichan" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan</a></strong></p>
<hr>
<p><img decoding="async" loading="lazy" alt="bg contain" src="https://www.chiwaichan.co.nz/assets/images/meetup-qr-cbc9b2c4c3d83d9d841df44c6ae54ad9.png" width="340" height="400" class="img_ev3q"></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Real-Time Voice to Sign Language Translation - Part 3: Edge AI Agent with Strands Agents on NVIDIA Jetson]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand</guid>
            <pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 3 of 3: Building a Strands Agent on NVIDIA Jetson that receives MQTT commands, uses Amazon Nova 2 Lite to drive a Pollen Robotics Amazing Hand for ASL fingerspelling, records video, and publishes state back via IoT Core]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/voice-to-sign-language"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Voice to Sign Language Translation</span><span class="description_NSta">Real-time voice-to-sign-language translation using Amazon Nova 2 Sonic, AWS IoT Core, and a Pollen Robotics Amazing Hand</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<iframe width="560" height="315" src="https://www.youtube.com/embed/MYzz_dtUJJs" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>
<p>This is Part 3 of a 3-part series covering a real-time voice-to-sign-language translation system. In <a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand">Part 1</a>, I covered the React frontend that captures speech, processes it with Amazon Nova 2 Sonic, and publishes cleaned sentence text via MQTT. In <a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk">Part 2</a>, I covered the AWS CDK stack that routes IoT Core messages through Lambda to AppSync for real-time GraphQL subscriptions.</p>
<p><img decoding="async" loading="lazy" alt="NVIDIA Jetson AGX Thor Developer Kit" src="https://www.chiwaichan.co.nz/assets/images/jetson-thor-2575e6d4b1e626d2f5c10b10ac6ac58c.jpeg" width="3648" height="2052" class="img_ev3q"></p>
<p>This post covers the final piece — the edge AI agent that actually makes the physical hand move. It is a Strands Agent running on an NVIDIA Jetson that subscribes to MQTT commands from the frontend, uses Amazon Nova 2 Lite to invoke the <code>fingerspell</code> tool, drives the Pollen Robotics Amazing Hand's Feetech SCS0009 servos for ASL fingerspelling letter by letter, records video of the hand in action, uploads it to S3, and publishes hand state back to IoT Core — which Part 2's infrastructure routes through to the frontend via AppSync.</p>
<p>The three repositories in the series:</p>
<ol>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand"><strong>Part 1 - Frontend and Voice Processing</strong></a> (<a href="https://github.com/chiwaichan/amplify-react-nova-sonic-voice-chat-amazing-hand" target="_blank" rel="noopener noreferrer"><code>amplify-react-nova-sonic-voice-chat-amazing-hand</code></a>) — React web app that captures speech, streams to Nova 2 Sonic, publishes cleaned sentence text via MQTT</li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk"><strong>Part 2 - Cloud Infrastructure</strong></a> (<a href="https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming" target="_blank" rel="noopener noreferrer"><code>cdk-iot-amazing-hand-streaming</code></a>) — AWS CDK stack that routes IoT Core messages through Lambda to AppSync</li>
<li><strong>This post (Part 3) - Edge AI Agent</strong> (<a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer"><code>strands-agents-amazing-hands</code></a>) — Strands Agent powered by Amazon Nova 2 Lite on NVIDIA Jetson that translates sentence text to ASL servo commands, drives the Amazing Hand, and publishes state back</li>
</ol>
<h1>Goals</h1>
<ul>
<li>Receive MQTT commands from the React frontend (plain text or JSON with <code>sentence</code> field) and drive the Amazing Hand servos for ASL fingerspelling</li>
<li>Use the Strands Agents framework with Amazon Nova 2 Lite (<code>us.amazon.nova-2-lite-v1:0</code>) to invoke the <code>fingerspell</code> tool — the LLM passes the incoming text verbatim to the tool for letter-by-letter ASL spelling</li>
<li>Fingerspell text using the 26-letter ASL alphabet (A-Z), with each letter held for 0.8 seconds and spaces adding a 0.4-second pause</li>
<li>Control 8 Feetech SCS0009 servos (4 fingers x 2 joints) on the Pollen Robotics Amazing Hand via serial bus at 1M baud using the <code>rustypot</code> library</li>
<li>Record video of the hand via OpenCV during each fingerspelling sequence, encode to H.264 MP4 via imageio-ffmpeg, upload to S3, and include a presigned URL in the state message</li>
<li>Publish real-time hand state (servo angles, letter, video URL) to IoT Core over MQTT — which Part 2's CDK stack routes to AppSync for the frontend to consume</li>
<li>Authenticate to AWS IoT Core using mTLS with X.509 device certificates</li>
<li>Create a fresh agent instance per MQTT message to prevent conversation history accumulation and unbounded token growth</li>
<li>Handle graceful shutdown with servo torque disable on SIGINT/SIGTERM</li>
</ul>
<h1>The Overall System</h1>
<p>This diagram shows the complete end-to-end system. Part 3 is the edge device highlighted on the right — the NVIDIA Jetson running the Strands Agent that controls the Amazing Hand.</p>
<p><img decoding="async" loading="lazy" alt="Overall System with Part 3 Highlighted" src="https://www.chiwaichan.co.nz/assets/images/01-overall-system-part3-highlighted-5fdfdf2834e862a4cbea9fa62c82f5f4.png" width="1845" height="3572" class="img_ev3q"></p>
<p><strong>How Part 3 fits in:</strong></p>
<ul>
<li><strong>Part 1 (Frontend)</strong> publishes cleaned sentence text to <code>the-project/robotic-hand/{deviceName}/action</code> via MQTT</li>
<li><strong>Part 3 (This agent)</strong> subscribes to the <code>/action</code> topic, processes the command through the Strands Agent, drives the servos, records video, and publishes state back to <code>/state</code></li>
<li><strong>Part 2 (Infrastructure)</strong> picks up the <code>/state</code> messages and routes them through Lambda to AppSync, where the frontend receives them via GraphQL subscriptions</li>
</ul>
<h1>Architecture</h1>
<p>The agent is a Python application built on the Strands Agents framework. It runs as a long-lived MQTT listener on the NVIDIA Jetson, creating a fresh agent instance for each incoming message to keep memory bounded.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="agent-architecture">Agent Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#agent-architecture" class="hash-link" aria-label="Direct link to Agent Architecture" title="Direct link to Agent Architecture">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Agent Architecture" src="https://www.chiwaichan.co.nz/assets/images/02-agent-architecture-bf6e4197643b084a205a44913a8e3a9c.png" width="1780" height="3268" class="img_ev3q"></p>
<p><strong>Components:</strong></p>
<ul>
<li><strong>MQTT Listener</strong> (<code>agent.py</code>) — Subscribes to the action topic, parses incoming messages (plain text or JSON), and submits each action to a single-threaded agent executor to keep the AWS CRT MQTT event loop free</li>
<li><strong>Strands Agent</strong> — A fresh <code>Agent</code> instance created per message with Amazon Nova 2 Lite as the model, the <code>fingerspell</code> tool as the available action, and a <code>MaxToolCallsHook</code> (limit 3) to prevent runaway tool-call loops</li>
<li><strong>Fingerspell Tool</strong> (<code>hand_control.py</code>) — A <code>@tool</code> decorated function that the LLM invokes to spell text letter-by-letter using the 26-letter ASL alphabet</li>
<li><strong>Servo Controller</strong> — Uses <code>rustypot.Scs0009PyController</code> to communicate with 8 Feetech SCS0009 servos over serial at 1M baud. Each finger has two servos controlled by dedicated move functions (<code>Move_Index</code>, <code>Move_Middle</code>, <code>Move_Ring</code>, <code>Move_Thumb</code>)</li>
<li><strong>Video Recorder</strong> (<code>video_recorder.py</code>) — Background daemon thread captures frames via OpenCV, encodes to H.264 MP4 via imageio-ffmpeg, uploads to S3, and returns a presigned URL (1-hour expiry)</li>
<li><strong>State Publisher</strong> — Non-blocking MQTT publisher on a separate thread that sends hand state (finger angles, letter, video URL) to the <code>/state</code> topic with QoS 1</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="data-flow">Data Flow<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#data-flow" class="hash-link" aria-label="Direct link to Data Flow" title="Direct link to Data Flow">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#interactive-sequence-diagram" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">Edge Agent: MQTT Command to Servo Control Flow</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From MQTT command to ASL fingerspelling with video capture</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="agent-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->13</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="785" viewBox="0 0 900 785" style="display:block"><g><rect x="18.000000000000007" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="63.00000000000001" y="30" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">IoT Core</text><text x="63.00000000000001" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="63.00000000000001" y1="60" x2="63.00000000000001" y2="765" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="172.79999999999998" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="217.79999999999998" y="30" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600">Listener</text><text x="217.79999999999998" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">MQTT Listener</text><line x1="217.79999999999998" y1="60" x2="217.79999999999998" y2="765" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="327.59999999999997" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="372.59999999999997" y="30" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">Agent</text><text x="372.59999999999997" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Strands Agent</text><line x1="372.59999999999997" y1="60" x2="372.59999999999997" y2="765" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="482.4" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="527.4" y="30" text-anchor="middle" fill="#ec4899" font-size="10" font-weight="600">Nova</text><text x="527.4" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Nova 2 Lite</text><line x1="527.4" y1="60" x2="527.4" y2="765" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="637.2" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="682.2" y="30" text-anchor="middle" fill="#8b5cf6" font-size="10" font-weight="600">Servos</text><text x="682.2" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Servo Controller</text><line x1="682.2" y1="60" x2="682.2" y2="765" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="792" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="837" y="30" text-anchor="middle" fill="#06b6d4" font-size="10" font-weight="600">S3</text><text x="837" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">S3 + Video</text><line x1="837" y1="60" x2="837" y2="765" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="10" y="144" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0ms</text><line x1="68" y1="140" x2="212.79999999999998" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="205.79999999999998,137 205.79999999999998,143 212.79999999999998,140" fill="#64748b"></polygon><text x="140.39999999999998" y="135" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">MQTT message: { "sentence": "hello world" }</text><text x="140.39999999999998" y="150" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">QoS 1</text></g><g opacity="0.2"><text x="10" y="189" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">1ms</text><path d="M 217.79999999999998 185
                        L 245.79999999999998 185
                        L 245.79999999999998 201
                        L 224.79999999999998 201" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="224.79999999999998,197 224.79999999999998,205 217.79999999999998,201" fill="#64748b"></polygon><text x="251.79999999999998" y="190" fill="#94a3b8" font-size="7" font-weight="400">Parse JSON, extract sentence field</text></g><g opacity="0.2"><text x="10" y="234" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2ms</text><line x1="222.79999999999998" y1="230" x2="832" y2="230" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="825,227 825,233 832,230" fill="#64748b"></polygon><text x="527.4" y="225" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">start_recording() — launch camera daemon thread</text></g><g opacity="0.2"><text x="10" y="279" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">3ms</text><line x1="222.79999999999998" y1="275" x2="367.59999999999997" y2="275" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="360.59999999999997,272 360.59999999999997,278 367.59999999999997,275" fill="#64748b"></polygon><text x="295.2" y="270" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Create fresh Agent instance + submit to executor</text><text x="295.2" y="285" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">No history from prior messages</text></g><g opacity="0.2"><text x="10" y="324" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">5ms</text><line x1="377.59999999999997" y1="320" x2="522.4" y2="320" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="515.4,317 515.4,323 522.4,320" fill="#64748b"></polygon><text x="450" y="315" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Converse API: system prompt + action text + fingersp...</text><circle cx="510.4" cy="320" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="369" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">200ms</text><line x1="522.4" y1="365" x2="377.59999999999997" y2="365" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="384.59999999999997,362 384.59999999999997,368 377.59999999999997,365" fill="#64748b"></polygon><text x="450" y="360" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Tool selection: fingerspell(text="hello world")</text></g><g opacity="0.2"><text x="10" y="414" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">210ms</text><line x1="377.59999999999997" y1="410" x2="677.2" y2="410" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="670.2,407 670.2,413 677.2,410" fill="#64748b"></polygon><text x="527.4" y="405" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">fingerspell: Move H-E-L-L-O (0.8s per letter)</text><text x="527.4" y="420" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Serial bus @ 1M baud</text></g><g opacity="0.2"><text x="10" y="459" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">300ms</text><line x1="677.2" y1="455" x2="68" y2="455" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="75,452 75,458 68,455" fill="#64748b"></polygon><text x="372.6" y="450" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Publish state per letter: { letter: "H", fingers: {....</text><text x="372.6" y="465" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Non-blocking thread</text></g><g opacity="0.2"><text x="10" y="504" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4500ms</text><line x1="377.59999999999997" y1="500" x2="677.2" y2="500" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="670.2,497 670.2,503 677.2,500" fill="#64748b"></polygon><text x="527.4" y="495" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">fingerspell: Move W-O-R-L-D (0.8s per letter)</text></g><g opacity="0.2"><text x="10" y="549" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4600ms</text><line x1="677.2" y1="545" x2="68" y2="545" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="75,542 75,548 68,545" fill="#64748b"></polygon><text x="372.6" y="540" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Publish state per letter: { letter: "W", fingers: {....</text></g><g opacity="0.2"><text x="10" y="594" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">8800ms</text><line x1="222.79999999999998" y1="590" x2="832" y2="590" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="825,587 825,593 832,590" fill="#64748b"></polygon><text x="527.4" y="585" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">stop_recording_and_upload() — encode H.264 + upload ...</text><circle cx="820" cy="590" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="639" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">9000ms</text><line x1="832" y1="635" x2="222.79999999999998" y2="635" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="229.79999999999998,632 229.79999999999998,638 222.79999999999998,635" fill="#64748b"></polygon><text x="527.4" y="630" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Presigned URL (1hr expiry)</text></g><g opacity="0.2"><text x="10" y="684" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">9001ms</text><line x1="212.79999999999998" y1="680" x2="68" y2="680" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="75,677 75,683 68,680" fill="#64748b"></polygon><text x="140.39999999999998" y="675" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Re-publish last state with video_url appended</text><circle cx="80" cy="680" r="3" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;font-size:9px"><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Listener</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Agent</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Nova</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">Servos</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">S3</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#22c55e"></div><span style="color:#94a3b8">Complete</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(124, 58, 237, 0.15));border:1px solid rgba(139, 92, 246, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#c4b5fd;margin-bottom:4px">Total: <!-- -->13<!-- --> steps across <!-- -->6<!-- --> components</div><div style="font-size:14px;font-weight:700;color:#a78bfa">MQTT command → ASL fingerspelling + video in ~9 seconds</div></div></div>
<h1>How it works</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="mqtt-command-reception">MQTT Command Reception<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#mqtt-command-reception" class="hash-link" aria-label="Direct link to MQTT Command Reception" title="Direct link to MQTT Command Reception">​</a></h2>
<p>The agent subscribes to an MQTT action topic (e.g. <code>the-project/robotic-hand/XIAOAmazingHandRight/action</code>) using mTLS authentication with X.509 device certificates. The first connection uses <code>clean_session=True</code> to flush any stale session state, then reconnects with <code>clean_session=False</code> for normal operation.</p>
<p>When a message arrives, the handler tries to parse it as JSON and extract the <code>sentence</code> field. If JSON parsing fails, it treats the entire payload as plain text. The action is then submitted to a single-threaded executor (<code>agent_executor</code>) to keep the AWS CRT MQTT event loop free:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">on_message</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">topic</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> payload</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> dup</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> qos</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> retain</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">**</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    payload_str </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> payload</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">decode</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"utf-8"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">try</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">        data </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> json</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">payload_str</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">        action </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> data</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"sentence"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> payload_str</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">except</span><span class="token plain"> json</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">JSONDecodeError</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">        action </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> payload_str</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    agent_executor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">submit</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">_process_action</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> action</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="strands-agent-and-amazon-nova-2-lite">Strands Agent and Amazon Nova 2 Lite<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#strands-agent-and-amazon-nova-2-lite" class="hash-link" aria-label="Direct link to Strands Agent and Amazon Nova 2 Lite" title="Direct link to Strands Agent and Amazon Nova 2 Lite">​</a></h2>
<p>The Strands Agents framework provides the core AI reasoning loop. A <strong>fresh agent instance is created for every MQTT message</strong> — this is deliberate to prevent conversation history from accumulating across messages, which would cause unbounded token growth over time.</p>
<p>The agent uses Amazon Nova 2 Lite (<code>us.amazon.nova-2-lite-v1:0</code>) via the Bedrock Converse API. Nova 2 Lite was chosen for its low-latency tool-use responses, which is critical for real-time servo control. The agent is configured with a <code>MaxToolCallsHook</code> that cancels tool calls beyond 3 to prevent infinite LLM tool-call loops.</p>
<p>The agent runs in fingerspell-only mode — only the <code>fingerspell</code> tool is available. The system prompt instructs the LLM to pass the entire message verbatim to the fingerspell tool without shortening or modifying it. State messages include a <code>letter</code> field identifying the current ASL letter being signed.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="servo-hardware-and-control">Servo Hardware and Control<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#servo-hardware-and-control" class="hash-link" aria-label="Direct link to Servo Hardware and Control" title="Direct link to Servo Hardware and Control">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Pollen Robotics Amazing Hand" src="https://www.chiwaichan.co.nz/assets/images/amazing-hand-32f49bc117f85fda191a8e3f1cc0282e.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<p>The <a href="https://www.seeedstudio.com/Amazing-Hand-Right-Hand-The-Open-Source-Robotic-Hand-Developer-Kit.html" target="_blank" rel="noopener noreferrer">Amazing Hand</a> — an open-source robotic hand <a href="https://huggingface.co/blog/pollen-robotics/amazing-hand" target="_blank" rel="noopener noreferrer">designed by Pollen Robotics</a> and manufactured by Seeed Studio — has <strong>4 fingers</strong> (index, middle, ring, thumb — no pinky) with <strong>2 Feetech SCS0009 servos per finger</strong> (8 servos total) connected via a Waveshare driver board over serial USB at 1,000,000 baud.</p>
<p>Each servo has an angle range of -90 to +90 degrees. Per-servo calibration offsets (<code>MiddlePos</code>) are applied during move operations to account for physical alignment:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token plain">MiddlePos </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">17</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">8</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">16</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">4</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">12</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">10</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">9</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">9</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><br></span></code></pre></div></div>
<p>The control sequence for each finger:</p>
<ol>
<li>Set goal speed for both servos (<code>write_goal_speed</code>) with a 0.2ms sleep between each speed write for serial bus timing</li>
<li>Convert angle to radians with calibration offset: <code>np.deg2rad(MiddlePos[i] + angle)</code></li>
<li>Set goal position for both servos (<code>write_goal_position</code>)</li>
<li>5ms sleep after positions are set before the next finger's commands</li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="asl-fingerspelling-tool">ASL Fingerspelling Tool<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#asl-fingerspelling-tool" class="hash-link" aria-label="Direct link to ASL Fingerspelling Tool" title="Direct link to ASL Fingerspelling Tool">​</a></h2>
<p>The <code>fingerspell(text)</code> tool is decorated with <code>@tool</code> from the Strands framework, making it callable by the LLM during inference. It spells text letter-by-letter using the ASL alphabet. Each of the 26 letters (A-Z) is mapped to servo angle tuples for all 4 fingers. Each letter is held for 0.8 seconds, spaces add a 0.4-second pause, and non-letter characters are skipped. A state message with the current <code>letter</code> field is published after each letter.</p>
<p>Since the Amazing Hand has no pinky finger, ASL letters that require a pinky use the ring finger instead.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="video-recording-pipeline">Video Recording Pipeline<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#video-recording-pipeline" class="hash-link" aria-label="Direct link to Video Recording Pipeline" title="Direct link to Video Recording Pipeline">​</a></h2>
<p>Video is recorded concurrently with each fingerspelling sequence:</p>
<ol>
<li><strong>Start recording</strong> — Before the agent is invoked, <code>start_recording()</code> launches a background daemon thread (<code>video-capture</code>) that captures frames from OpenCV <code>VideoCapture(0)</code> at the camera's native FPS (typically 30)</li>
<li><strong>Stop and encode</strong> — After the agent completes, <code>stop_recording_and_upload()</code> stops the capture thread, converts frames from BGR (OpenCV) to RGB, and encodes to H.264 MP4 using <code>imageio.v3</code> with the libx264 codec. The temp file is named <code>hand_YYYYMMDD_HHMMSS_</code></li>
<li><strong>Upload to S3</strong> — The MP4 is uploaded to the configured S3 bucket (default: <code>cc-amazing-video</code>) with key <code>videos/hand_YYYYMMDD_HHMMSS.mp4</code></li>
<li><strong>Presigned URL</strong> — A presigned URL is generated with 1-hour expiry and appended to the last state message, which is re-published to the <code>/state</code> topic</li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="state-publishing">State Publishing<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#state-publishing" class="hash-link" aria-label="Direct link to State Publishing" title="Direct link to State Publishing">​</a></h2>
<p>After each servo movement, the tool publishes a state message to the MQTT <code>/state</code> topic (e.g. <code>the-project/robotic-hand/XIAOAmazingHandRight/state</code>) with QoS 1. Publishing is non-blocking — it submits to a dedicated <code>_publish_executor</code> thread to avoid blocking the servo tool.</p>
<p>The state payload:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"gesture"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"fingerspell"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"letter"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"E"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"ts"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1770550850</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"fingers"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"index"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"middle"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"ring"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain">   </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"thumb"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">60</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-60</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"video_url"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"https://cc-amazing-video.s3.amazonaws.com/videos/hand_20260228.mp4?..."</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<p>The last published state is cached so that <code>publish_state_with_video_url()</code> can re-publish it with the presigned URL appended after video upload completes — without needing to re-read servo angles.</p>
<p>This state payload is what Part 2's CDK stack picks up via the IoT Rule, flattens in Lambda, and pushes into AppSync for the frontend to consume.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="threading-model">Threading Model<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#threading-model" class="hash-link" aria-label="Direct link to Threading Model" title="Direct link to Threading Model">​</a></h2>
<p>The agent uses two thread pools and a daemon thread to keep operations non-blocking:</p>
<table><thead><tr><th>Thread</th><th>Type</th><th>Workers</th><th>Purpose</th></tr></thead><tbody><tr><td><code>agent_executor</code></td><td><code>ThreadPoolExecutor</code></td><td>1</td><td>Runs Strands agent off the AWS CRT MQTT event loop</td></tr><tr><td><code>_publish_executor</code></td><td><code>ThreadPoolExecutor</code></td><td>1</td><td>Publishes state messages non-blocking</td></tr><tr><td><code>video-capture</code></td><td>Daemon <code>Thread</code></td><td>1</td><td>Background camera frame capture</td></tr></tbody></table>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="graceful-shutdown">Graceful Shutdown<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#graceful-shutdown" class="hash-link" aria-label="Direct link to Graceful Shutdown" title="Direct link to Graceful Shutdown">​</a></h2>
<p>On SIGINT or SIGTERM, the agent:</p>
<ol>
<li>Sets a stop event to exit the main loop</li>
<li>Disables servo torque (<code>write_torque_enable(1, 2)</code>) to release the servos and prevent power draw</li>
<li>Disconnects from MQTT</li>
<li>Logs completion</li>
</ol>
<h1>Technical Challenges &amp; Solutions</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-1-conversation-history-bloat">Challenge 1: Conversation History Bloat<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#challenge-1-conversation-history-bloat" class="hash-link" aria-label="Direct link to Challenge 1: Conversation History Bloat" title="Direct link to Challenge 1: Conversation History Bloat">​</a></h2>
<p><strong>Problem:</strong> Strands Agents maintain conversation history by default. Over time, as hundreds of MQTT messages are processed, the token count grows unboundedly, increasing latency and cost.</p>
<p><strong>Solution:</strong> A fresh <code>Agent</code> instance is created for every MQTT message. This discards all prior conversation history, keeping each invocation lightweight. Token usage (input, output, total) is logged after each invocation for monitoring.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-2-runaway-tool-call-loops">Challenge 2: Runaway Tool-Call Loops<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#challenge-2-runaway-tool-call-loops" class="hash-link" aria-label="Direct link to Challenge 2: Runaway Tool-Call Loops" title="Direct link to Challenge 2: Runaway Tool-Call Loops">​</a></h2>
<p><strong>Problem:</strong> The LLM might enter a loop of calling tools repeatedly — for example, calling <code>fingerspell</code> then deciding to call it again with modified text, then again.</p>
<p><strong>Solution:</strong> A custom <code>MaxToolCallsHook</code> implementing the Strands <code>HookProvider</code> interface. It counts tool calls per agent invocation and cancels any tool call beyond the limit of 3. This is injected into the agent via <code>hooks=[MaxToolCallsHook()]</code>.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-3-no-pinky-finger-on-the-amazing-hand">Challenge 3: No Pinky Finger on the Amazing Hand<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#challenge-3-no-pinky-finger-on-the-amazing-hand" class="hash-link" aria-label="Direct link to Challenge 3: No Pinky Finger on the Amazing Hand" title="Direct link to Challenge 3: No Pinky Finger on the Amazing Hand">​</a></h2>
<p><strong>Problem:</strong> The Pollen Robotics Amazing Hand has only 4 fingers (index, middle, ring, thumb) — no pinky. Several ASL letters require specific pinky positions (e.g. I, J, Y).</p>
<p><strong>Solution:</strong> ASL letters that require a pinky use the ring finger instead. The 26-letter ASL alphabet is manually mapped to 4-finger servo angle tuples, approximating the correct hand shape with the available fingers.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-4-serial-bus-timing">Challenge 4: Serial Bus Timing<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#challenge-4-serial-bus-timing" class="hash-link" aria-label="Direct link to Challenge 4: Serial Bus Timing" title="Direct link to Challenge 4: Serial Bus Timing">​</a></h2>
<p><strong>Problem:</strong> Sending servo commands too quickly over the serial bus causes missed commands or erratic movement. The Feetech SCS0009 protocol requires time between operations.</p>
<p><strong>Solution:</strong> A 0.2ms sleep is inserted between speed writes, and a 5ms sleep is added after both goal positions are set, giving the serial bus time to process each command before the next finger's sequence begins.</p>
<h1>Getting Started</h1>
<p><strong>GitHub Repository:</strong> <a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/strands-agents-amazing-hands</a></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="prerequisites">Prerequisites<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites">​</a></h2>
<ul>
<li>NVIDIA Jetson (AGX Thor or Orin Nano Super) with Python 3.10+</li>
<li>Pollen Robotics Amazing Hand connected via USB serial (Waveshare driver board)</li>
<li>AWS IoT Core device certificates (certificate, private key, root CA)</li>
<li>Amazon Bedrock access enabled for Nova 2 Lite in us-east-1</li>
<li>USB camera connected to the Jetson</li>
<li>S3 bucket for video storage (default: <code>cc-amazing-video</code>)</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="installation">Installation<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#installation" class="hash-link" aria-label="Direct link to Installation" title="Direct link to Installation">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">git</span><span class="token plain"> clone https://github.com/chiwaichan/strands-agents-amazing-hands.git</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> strands-agents-amazing-hands</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">pip </span><span class="token function" style="color:rgb(220, 220, 170)">install</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-e</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">.</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="running-the-agent">Running the Agent<a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand#running-the-agent" class="hash-link" aria-label="Direct link to Running the Agent" title="Direct link to Running the Agent">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token plain">amazing-hand-agent </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--endpoint</span><span class="token plain"> your-iot-endpoint.iot.us-east-1.amazonaws.com </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--cert</span><span class="token plain"> certs/device.pem.crt </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--key</span><span class="token plain"> certs/device.pem.key </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--ca</span><span class="token plain"> certs/AmazonRootCA1.pem </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--topic</span><span class="token plain"> the-project/robotic-hand/XIAOAmazingHandRight/action </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  --serial-port /dev/amazing-hand-right </span><span class="token punctuation" style="color:rgb(212, 212, 212)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  --s3-bucket cc-amazing-video</span><br></span></code></pre></div></div>
<p>The agent will connect to IoT Core, subscribe to the action topic, and wait for commands. When a message arrives, it will process it through the Strands Agent, drive the servos, record video, and publish state back.</p>
<h1>Summary</h1>
<p>This post covered the edge AI agent — the final piece of the voice-to-sign-language translation system:</p>
<ul>
<li><strong>Strands Agents framework</strong> with Amazon Nova 2 Lite for tool-use — a fresh agent per MQTT message prevents history bloat, with <code>MaxToolCallsHook</code> limiting calls to 3</li>
<li><strong>ASL fingerspelling</strong> with the 26-letter alphabet (A-Z), each letter held for 0.8 seconds — the <code>fingerspell</code> tool is decorated with <code>@tool</code> for LLM invocation</li>
<li><strong>8 Feetech SCS0009 servos</strong> on 4 fingers controlled via <code>rustypot</code> over serial at 1M baud, with per-servo calibration offsets</li>
<li><strong>Video pipeline</strong> captures via OpenCV in a background daemon thread, encodes to H.264 MP4 via imageio-ffmpeg, uploads to S3, and includes a 1-hour presigned URL in the final state message</li>
<li><strong>Non-blocking threading</strong> with 2 thread pools (agent executor off MQTT event loop, state publisher) and a daemon thread for video capture</li>
<li><strong>Real-time state publishing</strong> to IoT Core after every servo movement — which Part 2's CDK stack routes through Lambda to AppSync, completing the feedback loop to the React frontend in Part 1</li>
<li><strong>Graceful shutdown</strong> disables servo torque on SIGINT/SIGTERM to release the servos and prevent power draw</li>
</ul>]]></content:encoded>
            <category>AWS</category>
            <category>Bedrock</category>
            <category>Amazon Nova</category>
            <category>Strands Agents</category>
            <category>IoT</category>
            <category>MQTT</category>
            <category>AWS IoT Core</category>
            <category>NVIDIA</category>
            <category>Jetson</category>
            <category>Edge Computing</category>
            <category>Sign Language</category>
            <category>Accessibility</category>
            <category>Robotics</category>
            <category>Python</category>
            <category>OpenCV</category>
            <category>Serverless</category>
        </item>
        <item>
            <title><![CDATA[Real-Time Voice to Sign Language Translation - Part 2: Cloud Infrastructure with AWS CDK, IoT Core, and AppSync]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk</guid>
            <pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 2 of 3: Building the AWS CDK infrastructure that routes IoT Core MQTT hand state messages through Lambda to AppSync, enabling real-time GraphQL subscriptions between the edge device and the React frontend]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/voice-to-sign-language"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Voice to Sign Language Translation</span><span class="description_NSta">Real-time voice-to-sign-language translation using Amazon Nova 2 Sonic, AWS IoT Core, and a Pollen Robotics Amazing Hand</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<iframe width="560" height="315" src="https://www.youtube.com/embed/MYzz_dtUJJs" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>
<p>This is Part 2 of a 3-part series covering a real-time voice-to-sign-language translation system. In <a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand">Part 1</a>, I covered the React frontend that captures speech, processes it with Amazon Nova 2 Sonic, and publishes cleaned sentence text via MQTT. But there is a missing piece — how does the frontend know what the physical hand is actually doing?</p>
<p>The answer is this repository: a small but critical AWS CDK stack that acts as the <strong>bridge</strong> between the edge device and the React frontend. It routes real-time hand state data from IoT Core to AppSync, enabling the frontend to receive live updates via GraphQL subscriptions — so the 3D hand animation stays synchronised with the physical <a href="https://www.seeedstudio.com/Amazing-Hand-Right-Hand-The-Open-Source-Robotic-Hand-Developer-Kit.html" target="_blank" rel="noopener noreferrer">Amazing Hand</a> — an open-source robotic hand <a href="https://huggingface.co/blog/pollen-robotics/amazing-hand" target="_blank" rel="noopener noreferrer">designed by Pollen Robotics</a> and manufactured by Seeed Studio.</p>
<p>The three repositories in the series:</p>
<ol>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand"><strong>Part 1 - Frontend and Voice Processing</strong></a> (<a href="https://github.com/chiwaichan/amplify-react-nova-sonic-voice-chat-amazing-hand" target="_blank" rel="noopener noreferrer"><code>amplify-react-nova-sonic-voice-chat-amazing-hand</code></a>) — React web app that captures speech, streams to Nova 2 Sonic, publishes cleaned sentence text via MQTT</li>
<li><strong>This post (Part 2) - Cloud Infrastructure</strong> (<a href="https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming" target="_blank" rel="noopener noreferrer"><code>cdk-iot-amazing-hand-streaming</code></a>) — AWS CDK stack that routes IoT Core messages through Lambda to AppSync for real-time GraphQL subscriptions</li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand"><strong>Part 3 - Edge AI Agent</strong></a> (<a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer"><code>strands-agents-amazing-hands</code></a>) — Strands Agent powered by Amazon Nova 2 Lite on NVIDIA Jetson that translates sentence text to ASL servo commands, drives the Amazing Hand, and publishes state back</li>
</ol>
<h1>Goals</h1>
<ul>
<li>Route real-time hand state data from IoT Core MQTT to AppSync using an IoT Rules Engine SQL query and Lambda</li>
<li>Flatten nested MQTT finger angle payloads into a flat GraphQL schema for the <code>createHandState</code> mutation</li>
<li>Enable the React frontend to receive live hand state updates via AppSync <code>onCreateHandState</code> GraphQL subscriptions</li>
<li>Extract the device name dynamically from the MQTT topic path using <code>topic(3)</code> in the IoT Rule SQL</li>
<li>Define all infrastructure as code using AWS CDK in TypeScript</li>
<li>Integrate with the existing Amplify Gen 2 managed AppSync API and DynamoDB table from Part 1</li>
</ul>
<h1>The Overall System</h1>
<p>This diagram shows the complete end-to-end system. Part 2 is the infrastructure highlighted in the middle — the IoT Rule, Lambda, and AppSync connection that enables real-time state feedback from the edge device back to the frontend.</p>
<p><img decoding="async" loading="lazy" alt="Overall System with Part 2 Highlighted" src="https://www.chiwaichan.co.nz/assets/images/01-overall-system-part2-highlighted-6463fd3e3a7d6222cc0020760232b0ee.png" width="2891" height="1979" class="img_ev3q"></p>
<p><strong>How Part 2 fits in:</strong></p>
<ul>
<li><strong>Part 1 (Frontend)</strong> publishes cleaned sentence text to <code>the-project/robotic-hand/{deviceName}/action</code> and subscribes to AppSync <code>onCreateHandState</code> for live updates</li>
<li><strong>Part 3 (Edge Device)</strong> receives sentence text, translates it to ASL servo commands via the Strands Agent powered by Amazon Nova 2 Lite, drives the Amazing Hand, and publishes state back to <code>the-project/robotic-hand/{deviceName}/state</code></li>
<li><strong>Part 2 (This stack)</strong> listens on the <code>/state</code> topic, transforms the payload, and pushes it into AppSync — completing the real-time feedback loop</li>
</ul>
<h1>Architecture</h1>
<p>The stack is intentionally small — a single IoT Rule, a single Lambda function, and the IAM glue to connect them. The AppSync API and DynamoDB table are managed by the Amplify Gen 2 backend in Part 1, so this stack only needs to call the existing <code>createHandState</code> mutation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="infrastructure-overview">Infrastructure Overview<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#infrastructure-overview" class="hash-link" aria-label="Direct link to Infrastructure Overview" title="Direct link to Infrastructure Overview">​</a></h2>
<p><img decoding="async" loading="lazy" alt="CDK Stack Architecture" src="https://www.chiwaichan.co.nz/assets/images/02-cdk-stack-architecture-3f2a1ede0d1ce8a95ebf4a84cb2b0176.png" width="3513" height="1108" class="img_ev3q"></p>
<p><strong>Resources created by this CDK stack:</strong></p>
<ul>
<li><strong>IoT Topic Rule</strong> (<code>AmazingHandStateStreamingRule</code>) — Matches MQTT messages on <code>the-project/robotic-hand/+/state</code> using SQL <code>SELECT gesture, letter, ts, fingers, video_url, topic(3) AS device_name</code>, then invokes the Lambda function</li>
<li><strong>Lambda Function</strong> (<code>AmazingHandToAppSyncFunction</code>) — Node.js 18 function that receives the IoT event, flattens the nested <code>fingers</code> object into individual angle fields, and calls the AppSync <code>createHandState</code> GraphQL mutation using the Amplify v6 SDK with API Key authentication</li>
<li><strong>Lambda IAM Role</strong> — Service role with <code>AWSLambdaBasicExecutionRole</code> for CloudWatch Logs and an inline policy granting <code>appsync:GraphQL</code> on the AppSync API</li>
<li><strong>Lambda Permission</strong> — Allows the IoT service (<code>iot.amazonaws.com</code>) to invoke the Lambda function</li>
</ul>
<p><strong>Resources managed externally (by Amplify Gen 2 in Part 1):</strong></p>
<ul>
<li><strong>AppSync API</strong> — GraphQL API with <code>HandState</code> model, <code>createHandState</code> mutation, and <code>onCreateHandState</code> subscription</li>
<li><strong>DynamoDB Table</strong> — <code>HandState</code> table with auto-generated resolvers from the <code>@model</code> directive</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="data-flow">Data Flow<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#data-flow" class="hash-link" aria-label="Direct link to Data Flow" title="Direct link to Data Flow">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#interactive-sequence-diagram" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">IoT Core to AppSync Data Flow</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From edge device MQTT publish to React real-time subscription update</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="iot-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->10</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="650" viewBox="0 0 900 650" style="display:block"><g><rect x="18.000000000000007" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="63.00000000000001" y="30" text-anchor="middle" fill="#8b5cf6" font-size="10" font-weight="600">Jetson</text><text x="63.00000000000001" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">NVIDIA Jetson</text><line x1="63.00000000000001" y1="60" x2="63.00000000000001" y2="630" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="172.79999999999998" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="217.79999999999998" y="30" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">IoT Core</text><text x="217.79999999999998" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="217.79999999999998" y1="60" x2="217.79999999999998" y2="630" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="327.59999999999997" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="372.59999999999997" y="30" text-anchor="middle" fill="#ec4899" font-size="10" font-weight="600">Rules</text><text x="372.59999999999997" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">IoT Rules Engine</text><line x1="372.59999999999997" y1="60" x2="372.59999999999997" y2="630" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="482.4" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="527.4" y="30" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">Lambda</text><text x="527.4" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="527.4" y1="60" x2="527.4" y2="630" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="637.2" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="682.2" y="30" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600">AppSync</text><text x="682.2" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="682.2" y1="60" x2="682.2" y2="630" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="792" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="837" y="30" text-anchor="middle" fill="#06b6d4" font-size="10" font-weight="600">React</text><text x="837" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">React App</text><line x1="837" y1="60" x2="837" y2="630" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="10" y="144" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0ms</text><line x1="68" y1="140" x2="212.79999999999998" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="205.79999999999998,137 205.79999999999998,143 212.79999999999998,140" fill="#64748b"></polygon><text x="140.39999999999998" y="135" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">MQTT publish: the-project/robotic-hand/XIAOAmazingHandRig...</text><text x="140.39999999999998" y="150" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Nested fingers payload</text></g><g opacity="0.2"><text x="10" y="189" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">1ms</text><line x1="222.79999999999998" y1="185" x2="367.59999999999997" y2="185" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="360.59999999999997,182 360.59999999999997,188 367.59999999999997,185" fill="#64748b"></polygon><text x="295.2" y="180" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Topic matches: the-project/robotic-hand/+/state</text></g><g opacity="0.2"><text x="10" y="234" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2ms</text><path d="M 372.59999999999997 230
                        L 400.59999999999997 230
                        L 400.59999999999997 246
                        L 379.59999999999997 246" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="379.59999999999997,242 379.59999999999997,250 372.59999999999997,246" fill="#64748b"></polygon><text x="406.59999999999997" y="235" fill="#94a3b8" font-size="7" font-weight="400">SQL: SELECT gesture, letter, ts, fingers, video_url, topi...</text><text x="406.59999999999997" y="245" fill="#64748b" font-size="6" font-style="italic">Extract device_name from topic</text></g><g opacity="0.2"><text x="10" y="279" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">5ms</text><line x1="377.59999999999997" y1="275" x2="522.4" y2="275" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="515.4,272 515.4,278 522.4,275" fill="#64748b"></polygon><text x="450" y="270" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Invoke with enriched event (device_name added)</text><circle cx="510.4" cy="275" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="324" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">6ms</text><path d="M 527.4 320
                        L 555.4 320
                        L 555.4 336
                        L 534.4 336" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="534.4,332 534.4,340 527.4,336" fill="#64748b"></polygon><text x="561.4" y="325" fill="#94a3b8" font-size="7" font-weight="400">Validate device_name exists</text></g><g opacity="0.2"><text x="10" y="369" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">7ms</text><path d="M 527.4 365
                        L 555.4 365
                        L 555.4 381
                        L 534.4 381" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="534.4,377 534.4,385 527.4,381" fill="#64748b"></polygon><text x="561.4" y="370" fill="#94a3b8" font-size="7" font-weight="400">Flatten: fingers.index.angle_1 → indexAngle1 (x8 fields)</text><text x="561.4" y="380" fill="#64748b" font-size="6" font-style="italic">Defaults: 0 for angles, null for optional</text></g><g opacity="0.2"><text x="10" y="414" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">10ms</text><line x1="532.4" y1="410" x2="677.2" y2="410" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="670.2,407 670.2,413 677.2,410" fill="#64748b"></polygon><text x="604.8" y="405" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">createHandState mutation (API Key auth)</text><text x="604.8" y="420" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Amplify v6 SDK</text></g><g opacity="0.2"><text x="10" y="459" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">15ms</text><path d="M 682.2 455
                        L 710.2 455
                        L 710.2 471
                        L 689.2 471" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="689.2,467 689.2,475 682.2,471" fill="#64748b"></polygon><text x="716.2" y="460" fill="#94a3b8" font-size="7" font-weight="400">Persist to DynamoDB (HandState table)</text><circle cx="916.2" cy="457" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="504" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">20ms</text><line x1="687.2" y1="500" x2="832" y2="500" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="825,497 825,503 832,500" fill="#64748b"></polygon><text x="759.6" y="495" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">onCreateHandState subscription push</text><text x="759.6" y="510" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Real-time WebSocket</text></g><g opacity="0.2"><text x="10" y="549" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">21ms</text><path d="M 837 545
                        L 865 545
                        L 865 561
                        L 844 561" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="844,557 844,565 837,561" fill="#64748b"></polygon><text x="871" y="550" fill="#94a3b8" font-size="7" font-weight="400">Update 3D hand animation + letter history + video feed</text><circle cx="1071" cy="547" r="3" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;font-size:9px"><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">Jetson</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Rules</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Lambda</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">AppSync</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">React</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#22c55e"></div><span style="color:#94a3b8">Complete</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.15));border:1px solid rgba(59, 130, 246, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#93c5fd;margin-bottom:4px">Total: <!-- -->10<!-- --> steps across <!-- -->6<!-- --> components</div><div style="font-size:14px;font-weight:700;color:#60a5fa">Edge device state → React UI update in ~20ms</div></div></div>
<h1>How it works</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="iot-rules-engine-sql-query">IoT Rules Engine SQL Query<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#iot-rules-engine-sql-query" class="hash-link" aria-label="Direct link to IoT Rules Engine SQL Query" title="Direct link to IoT Rules Engine SQL Query">​</a></h2>
<p>The IoT Rule is the entry point. It listens on the MQTT topic pattern <code>the-project/robotic-hand/+/state</code> where <code>+</code> is a single-level wildcard matching any device name (e.g. <code>XIAOAmazingHandRight</code>).</p>
<p>The SQL query (using AWS IoT SQL version <code>2016-03-23</code>) selects specific fields from the MQTT payload and enriches them with metadata extracted from the topic path:</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">SELECT</span><span class="token plain"> gesture</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> letter</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> ts</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> fingers</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> video_url</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> topic</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token number" style="color:rgb(181, 206, 168)">3</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">AS</span><span class="token plain"> device_name</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">FROM</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'the-project/robotic-hand/+/state'</span><br></span></code></pre></div></div>
<ul>
<li><code>gesture</code> — The type of sign being performed (e.g. "fingerspell")</li>
<li><code>letter</code> — The current letter being signed (e.g. "E")</li>
<li><code>ts</code> — Unix timestamp in seconds</li>
<li><code>fingers</code> — Nested JSON object containing servo angles for all four fingers, each with two joint angles</li>
<li><code>video_url</code> — Optional pre-signed S3 URL for video of the hand in action</li>
<li><code>topic(3) AS device_name</code> — Extracts the 3rd segment of the MQTT topic path as the device name, so the Lambda does not need to parse the topic itself</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="mqtt-payload-format">MQTT Payload Format<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#mqtt-payload-format" class="hash-link" aria-label="Direct link to MQTT Payload Format" title="Direct link to MQTT Payload Format">​</a></h2>
<p>The edge device publishes hand state messages in this format:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"gesture"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"fingerspell"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"letter"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"E"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"ts"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1770550850</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"fingers"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"index"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"middle"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"ring"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">45</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-45</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"thumb"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"angle_1"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">60</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"angle_2"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">-60</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"video_url"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"https://cc-amazing-video.s3.amazonaws.com/videos/hand_20260228.mp4?..."</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<p>The <code>fingers</code> object uses a nested structure with <code>angle_1</code> and <code>angle_2</code> per finger — representing the two joints of each finger on the Amazing Hand. This nested format is natural for the edge device to produce but needs to be flattened for the GraphQL schema.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="lambda-function--payload-transformation">Lambda Function — Payload Transformation<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#lambda-function--payload-transformation" class="hash-link" aria-label="Direct link to Lambda Function — Payload Transformation" title="Direct link to Lambda Function — Payload Transformation">​</a></h2>
<p>The Lambda function (<code>AmazingHandToAppSyncFunction</code>) receives the IoT event with the enriched fields from the SQL query. Its job is to:</p>
<ol>
<li><strong>Validate</strong> that the <code>device_name</code> field exists (required for the GraphQL mutation)</li>
<li><strong>Flatten</strong> the nested <code>fingers</code> object into individual angle fields:</li>
</ol>
<table><thead><tr><th>MQTT Payload</th><th>GraphQL Field</th></tr></thead><tbody><tr><td><code>fingers.index.angle_1</code></td><td><code>indexAngle1</code></td></tr><tr><td><code>fingers.index.angle_2</code></td><td><code>indexAngle2</code></td></tr><tr><td><code>fingers.middle.angle_1</code></td><td><code>middleAngle1</code></td></tr><tr><td><code>fingers.middle.angle_2</code></td><td><code>middleAngle2</code></td></tr><tr><td><code>fingers.ring.angle_1</code></td><td><code>ringAngle1</code></td></tr><tr><td><code>fingers.ring.angle_2</code></td><td><code>ringAngle2</code></td></tr><tr><td><code>fingers.thumb.angle_1</code></td><td><code>thumbAngle1</code></td></tr><tr><td><code>fingers.thumb.angle_2</code></td><td><code>thumbAngle2</code></td></tr></tbody></table>
<ol start="3">
<li><strong>Default</strong> missing angle values to <code>0</code>, missing gesture/letter/videoUrl to <code>null</code>, and missing timestamp to <code>Math.floor(Date.now() / 1000)</code></li>
<li><strong>Call AppSync</strong> <code>createHandState</code> mutation using the Amplify v6 SDK configured with API Key authentication</li>
</ol>
<p>The Lambda uses the Amplify v6 SDK (<code>aws-amplify@^6.0.0</code>) to call AppSync, configured via environment variables:</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token maybe-class-name">Amplify</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token method function property-access" style="color:rgb(220, 220, 170)">configure</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token constant" style="color:rgb(100, 102, 149)">API</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token literal-property property">GraphQL</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token literal-property property">endpoint</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> process</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token property-access">env</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token constant" style="color:rgb(100, 102, 149)">APPSYNC_API_URL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token literal-property property">region</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> process</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token property-access">env</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token constant" style="color:rgb(100, 102, 149)">AWS_REGION</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token literal-property property">defaultAuthMode</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'apiKey'</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token literal-property property">apiKey</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> process</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token property-access">env</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token constant" style="color:rgb(100, 102, 149)">APPSYNC_API_KEY</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="graphql-mutation">GraphQL Mutation<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#graphql-mutation" class="hash-link" aria-label="Direct link to GraphQL Mutation" title="Direct link to GraphQL Mutation">​</a></h2>
<p>The Lambda calls this mutation to persist the hand state and trigger the real-time subscription:</p>
<div class="language-graphql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-graphql codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">mutation</span><span class="token plain"> </span><span class="token definition-mutation function" style="color:rgb(220, 220, 170)">CreateHandState</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token variable variable-input" style="color:rgb(156, 220, 254)">$input</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token atom-input class-name" style="color:rgb(78, 201, 176)">CreateHandStateInput</span><span class="token operator" style="color:rgb(212, 212, 212)">!</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property-query property-mutation">createHandState</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token attr-name" style="color:rgb(156, 220, 254)">input</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token variable variable-input" style="color:rgb(156, 220, 254)">$input</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">id</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">deviceName</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">gesture</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">letter</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">indexAngle1</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">indexAngle2</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">middleAngle1</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">middleAngle2</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">ringAngle1</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">ringAngle2</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">thumbAngle1</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">thumbAngle2</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">timestamp</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">videoUrl</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">createdAt</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<p>When AppSync receives this mutation, two things happen:</p>
<ol>
<li>The hand state record is <strong>persisted to DynamoDB</strong> via the auto-generated <code>@model</code> resolver</li>
<li>The <code>onCreateHandState</code> <strong>subscription is triggered</strong>, pushing the new record to all subscribed clients — including the React frontend from Part 1, which uses this data to update the 3D hand animation, signed letter history, and video feed in real-time</li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="cdk-stack-definition">CDK Stack Definition<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#cdk-stack-definition" class="hash-link" aria-label="Direct link to CDK Stack Definition" title="Direct link to CDK Stack Definition">​</a></h2>
<p>The entire stack is defined in approximately 74 lines of TypeScript. The stack accepts the AppSync API URL, API key, and API ID as props, which are injected via environment variables during deployment:</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">interface</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">IoTStreamingStackProps</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">extends</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">cdk</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">StackProps </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  appSyncApiUrl</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">string</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  appSyncApiKey</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">string</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  appSyncApiId</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">string</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<p>The stack creates the Lambda function with the AppSync connection details as environment variables, grants it <code>appsync:GraphQL</code> permissions scoped to the specific API, creates the IoT Topic Rule with the SQL query, and grants IoT permission to invoke the Lambda.</p>
<p>Two stack outputs are exported for reference:</p>
<ul>
<li><code>AmazingHandIoTRuleArn</code> — The IoT Rule ARN</li>
<li><code>AmazingHandLambdaFunctionArn</code> — The Lambda function ARN</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="cicd-pipeline">CI/CD Pipeline<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#cicd-pipeline" class="hash-link" aria-label="Direct link to CI/CD Pipeline" title="Direct link to CI/CD Pipeline">​</a></h2>
<p>The project includes a GitHub Actions workflow (<code>.github/workflows/aws-cdk-deploy.yml</code>) that automates deployment:</p>
<ol>
<li>Triggers on pushes to <code>main</code> and <code>dev</code> branches</li>
<li>Authenticates using <strong>OIDC</strong> (no static AWS credentials stored in GitHub)</li>
<li>Automatically discovers the AppSync configuration by:<!-- -->
<ul>
<li>Reading the Amplify App ID from SSM Parameter Store (<code>/iot/amplify/amazinghand</code>)</li>
<li>Finding the Amplify data CloudFormation stack</li>
<li>Extracting the AppSync API ID from CloudFormation stack resources, then querying the AppSync API directly for the URL and API key</li>
</ul>
</li>
<li>Runs <code>cdk deploy</code> with the discovered values</li>
</ol>
<p>This means the stack automatically stays connected to the correct AppSync API without manual configuration.</p>
<h1>Technical Challenges &amp; Solutions</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-1-flattening-nested-iot-payloads-for-graphql">Challenge 1: Flattening Nested IoT Payloads for GraphQL<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#challenge-1-flattening-nested-iot-payloads-for-graphql" class="hash-link" aria-label="Direct link to Challenge 1: Flattening Nested IoT Payloads for GraphQL" title="Direct link to Challenge 1: Flattening Nested IoT Payloads for GraphQL">​</a></h2>
<p><strong>Problem:</strong> The edge device publishes finger angles in a nested JSON structure (<code>fingers.index.angle_1</code>), but the AppSync GraphQL schema uses flat fields (<code>indexAngle1</code>). The IoT Rules Engine SQL can select nested objects but cannot rename nested fields into flat ones.</p>
<p><strong>Solution:</strong> The Lambda function handles the transformation. It receives the nested <code>fingers</code> object from the IoT Rule and manually flattens each field with safe defaults (<code>0</code> for missing angles, <code>null</code> for optional fields). This keeps the IoT Rule SQL simple and the edge device payload natural.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-2-connecting-to-amplify-managed-appsync">Challenge 2: Connecting to Amplify-Managed AppSync<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#challenge-2-connecting-to-amplify-managed-appsync" class="hash-link" aria-label="Direct link to Challenge 2: Connecting to Amplify-Managed AppSync" title="Direct link to Challenge 2: Connecting to Amplify-Managed AppSync">​</a></h2>
<p><strong>Problem:</strong> The AppSync API is managed by Amplify Gen 2 in Part 1's repository, not by this CDK stack. The API URL, API key, and API ID change between environments and deployments.</p>
<p><strong>Solution:</strong> The CI/CD pipeline automatically discovers the AppSync configuration at deploy time by reading from SSM Parameter Store and CloudFormation stack outputs. For local development, the values are passed via environment variables in <code>deploy.sh</code>. The CDK stack accepts them as typed props, keeping the infrastructure code clean.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-3-extracting-device-name-from-mqtt-topic">Challenge 3: Extracting Device Name from MQTT Topic<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#challenge-3-extracting-device-name-from-mqtt-topic" class="hash-link" aria-label="Direct link to Challenge 3: Extracting Device Name from MQTT Topic" title="Direct link to Challenge 3: Extracting Device Name from MQTT Topic">​</a></h2>
<p><strong>Problem:</strong> The device name is part of the MQTT topic path (<code>the-project/robotic-hand/XIAOAmazingHandRight/state</code>), not the message payload. The Lambda needs it to set the <code>deviceName</code> field in the GraphQL mutation.</p>
<p><strong>Solution:</strong> The IoT Rules Engine SQL function <code>topic(3)</code> extracts the 3rd segment of the topic path and aliases it as <code>device_name</code>. This is passed to the Lambda as part of the event, so the Lambda does not need to parse the topic itself. The wildcard <code>+</code> in the topic filter means this works for any device name without configuration changes.</p>
<h1>Getting Started</h1>
<p><strong>GitHub Repository:</strong> <a href="https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming</a></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="prerequisites">Prerequisites<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites">​</a></h2>
<ul>
<li>Node.js 18+</li>
<li>AWS CDK CLI installed (<code>npm install -g aws-cdk</code>)</li>
<li>AWS CLI configured with credentials</li>
<li>An existing AppSync API with the <code>HandState</code> schema (deployed via Part 1's Amplify Gen 2 backend)</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="deployment-steps">Deployment Steps<a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk#deployment-steps" class="hash-link" aria-label="Direct link to Deployment Steps" title="Direct link to Deployment Steps">​</a></h2>
<ol>
<li><strong>Get AppSync configuration</strong> from Part 1's Amplify deployment:</li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token builtin class-name" style="color:rgb(78, 201, 176)">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:rgb(156, 220, 254)">APPSYNC_API_ID</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain">your_api_id</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:rgb(156, 220, 254)">APPSYNC_API_URL</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain">https://your-api-id.appsync-api.us-east-1.amazonaws.com/graphql</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:rgb(156, 220, 254)">APPSYNC_API_KEY</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain">your_api_key</span><br></span></code></pre></div></div>
<ol start="2">
<li><strong>Clone and Install:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">git</span><span class="token plain"> clone https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming.git</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> cdk-iot-amazing-hand-streaming</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">install</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> lambda/amazing-hand-to-appsync </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">install</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">..</span><span class="token plain">/</span><span class="token punctuation" style="color:rgb(212, 212, 212)">..</span><br></span></code></pre></div></div>
<ol start="3">
<li><strong>Deploy:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token plain">./deploy.sh</span><br></span></code></pre></div></div>
<p>This bootstraps CDK (if needed) and deploys the stack with the AppSync configuration.</p>
<h1>What's Next</h1>
<p>In <strong>Part 3</strong>, I will cover the edge AI agent (<a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer"><code>strands-agents-amazing-hands</code></a>) — a Strands Agent powered by Amazon Nova 2 Lite running on an NVIDIA Jetson that subscribes to the MQTT sentence text published by the frontend in Part 1, translates them into physical servo movements on the Pollen Robotics Amazing Hand for ASL fingerspelling, records video, and publishes hand state back to IoT Core — which this Part 2 stack routes through to AppSync for the frontend to consume.</p>
<h1>Summary</h1>
<p>This post covered the cloud infrastructure layer of the voice-to-sign-language translation system:</p>
<ul>
<li><strong>IoT Rules Engine</strong> listens on <code>the-project/robotic-hand/+/state</code> and extracts device name from the topic path using <code>topic(3)</code></li>
<li><strong>Lambda function</strong> flattens nested finger angle payloads (<code>fingers.index.angle_1</code> → <code>indexAngle1</code>) and calls the AppSync <code>createHandState</code> GraphQL mutation</li>
<li><strong>AppSync</strong> persists to DynamoDB and broadcasts <code>onCreateHandState</code> subscriptions to connected React clients in real-time</li>
<li><strong>CDK stack</strong> is intentionally small (~74 lines) — it creates only the IoT Rule, Lambda, and IAM glue, relying on the Amplify-managed AppSync API from Part 1</li>
<li><strong>CI/CD pipeline</strong> automatically discovers AppSync configuration from SSM Parameter Store, CloudFormation stack resources, and direct AppSync API calls — no manual configuration needed</li>
<li>The stack completes the <strong>real-time feedback loop</strong>: edge device publishes state → IoT Core → Lambda → AppSync → React frontend updates 3D hand animation</li>
</ul>]]></content:encoded>
            <category>AWS</category>
            <category>AWS CDK</category>
            <category>IoT</category>
            <category>AWS IoT Core</category>
            <category>MQTT</category>
            <category>Lambda</category>
            <category>AppSync</category>
            <category>GraphQL</category>
            <category>DynamoDB</category>
            <category>Serverless</category>
            <category>Sign Language</category>
            <category>Accessibility</category>
            <category>Robotics</category>
        </item>
        <item>
            <title><![CDATA[Real-Time Voice to Sign Language Translation with Amazon Nova 2 Sonic and Pollen Robotics Amazing Hand - Part 1: Frontend and Voice Processing]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand</guid>
            <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 1 of 3: Building the React frontend that captures speech, processes it with Amazon Nova 2 Sonic bidirectional streaming, and publishes cleaned sentence text via AWS IoT Core MQTT for the edge device to translate into ASL fingerspelling]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/voice-to-sign-language"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Voice to Sign Language Translation</span><span class="description_NSta">Real-time voice-to-sign-language translation using Amazon Nova 2 Sonic, AWS IoT Core, and a Pollen Robotics Amazing Hand</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<iframe width="560" height="315" src="https://www.youtube.com/embed/MYzz_dtUJJs" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>
<p><img decoding="async" loading="lazy" alt="Pollen Robotics Amazing Hand" src="https://www.chiwaichan.co.nz/assets/images/amazing-hand-32f49bc117f85fda191a8e3f1cc0282e.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<p>This is Part 1 of a 3-part series covering a real-time voice-to-sign-language translation system. The complete solution spans three separate repositories, each responsible for a distinct layer of the system:</p>
<ol>
<li><strong>This post (Part 1) - Frontend and Voice Processing</strong> — The React web app that captures speech, streams it to Amazon Nova 2 Sonic on Bedrock, publishes cleaned sentence text via MQTT, and renders a real-time 3D hand visualisation</li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/14/voice-to-sign-language-cloud-infrastructure-iot-appsync-cdk"><strong>Part 2 - Cloud Infrastructure</strong></a> (<a href="https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming" target="_blank" rel="noopener noreferrer"><code>cdk-iot-amazing-hand-streaming</code></a>) — The AWS CDK stack that routes IoT Core messages through Lambda to AppSync, enabling real-time GraphQL subscriptions between the edge device and the frontend</li>
<li><a href="https://www.chiwaichan.co.nz/blog/2026/03/15/voice-to-sign-language-edge-ai-agent-strands-nvidia-jetson-amazing-hand"><strong>Part 3 - Edge AI Agent</strong></a> (<a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer"><code>strands-agents-amazing-hands</code></a>) — The Strands Agent powered by Amazon Nova 2 Lite running on an NVIDIA Jetson that receives MQTT sentence text, translates it to ASL servo commands, drives the Pollen Robotics Amazing Hand for fingerspelling, and streams video and state back</li>
</ol>
<p>In this post, I focus on how speech enters the system, how Amazon Nova 2 Sonic processes and cleans up the spoken input, and how the frontend publishes cleaned sentence text over MQTT — setting the stage for Parts 2 and 3.</p>
<p>The key idea is that Nova 2 Sonic is not used as a chatbot here — it is configured as a <strong>dumb speech-to-text relay pipe</strong> that cleans up grammar, removes filler words like "um" and "uh", translates non-English speech to English, and forwards the cleaned text via a forced tool invocation (<code>send_text</code>) on every single utterance. The frontend then publishes the cleaned sentence text to AWS IoT Core over MQTT for the edge device to translate into ASL servo commands.</p>
<h1>Goals</h1>
<ul>
<li>Capture speech in the browser and stream it to Amazon Nova 2 Sonic via bidirectional streaming — no backend servers required</li>
<li>Use Nova 2 Sonic's forced tool use (<code>send_text</code>) with <code>toolChoice: { any: {} }</code> to relay cleaned text on every utterance, not as a conversational chatbot</li>
<li>Publish cleaned sentence text to AWS IoT Core over MQTT for the edge device to translate into ASL servo commands</li>
<li>Subscribe to real-time hand state updates via GraphQL (AppSync) and synchronise a 3D Three.js hand animation with the physical hand</li>
<li>Use AWS Amplify Gen 2 for infrastructure-as-code backend definition in TypeScript (Cognito, AppSync, IAM policies)</li>
<li>Display a 3-column UI with signed letter history, 3D hand animation with video feed, and live transcript with microphone controls</li>
</ul>
<h1>The Overall System</h1>
<p>The end-to-end system takes spoken words from a browser microphone all the way through to physical ASL fingerspelling on an <a href="https://www.seeedstudio.com/Amazing-Hand-Right-Hand-The-Open-Source-Robotic-Hand-Developer-Kit.html" target="_blank" rel="noopener noreferrer">Amazing Hand</a> — an open-source robotic hand <a href="https://huggingface.co/blog/pollen-robotics/amazing-hand" target="_blank" rel="noopener noreferrer">designed by Pollen Robotics</a> and manufactured by Seeed Studio — passing through cloud AI, IoT messaging, and an edge AI agent along the way.</p>
<p><img decoding="async" loading="lazy" alt="Overall System Architecture" src="https://www.chiwaichan.co.nz/assets/images/01-overall-system-architecture-d475232d4626ab0d176906d7fd5497db.png" width="2714" height="2369" class="img_ev3q"></p>
<p><strong>System Components:</strong></p>
<ol>
<li><strong>React Frontend (this post)</strong> - Captures speech, streams to Bedrock, publishes cleaned sentence text to MQTT, renders 3D hand animation synchronised with the physical hand via GraphQL subscriptions</li>
<li><strong>Cloud Infrastructure (Part 2)</strong> - AWS CDK stack with IoT Core rules that route MQTT messages through Lambda to AppSync, enabling real-time GraphQL subscriptions between the edge device and the frontend</li>
<li><strong>Edge AI Agent (Part 3)</strong> - Strands Agent powered by Amazon Nova 2 Lite on an NVIDIA Jetson that receives MQTT sentence text, translates it to ASL servo commands, drives the Amazing Hand for fingerspelling letter by letter, records video, and publishes hand state back via IoT Core</li>
</ol>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#interactive-sequence-diagram" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">End-to-End Voice to Sign Language Flow</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From user speech to ASL fingerspelling on the Amazing Hand</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="e2e-sign-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->13</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="785" viewBox="0 0 900 785" style="display:block"><g><rect x="18.000000000000007" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="63.00000000000001" y="30" text-anchor="middle" fill="#8b5cf6" font-size="10" font-weight="600">User</text><text x="63.00000000000001" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="63.00000000000001" y1="60" x2="63.00000000000001" y2="765" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="172.79999999999998" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="217.79999999999998" y="30" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600">Browser</text><text x="217.79999999999998" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">React App</text><line x1="217.79999999999998" y1="60" x2="217.79999999999998" y2="765" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="327.59999999999997" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="372.59999999999997" y="30" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">Bedrock</text><text x="372.59999999999997" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Amazon Bedrock</text><line x1="372.59999999999997" y1="60" x2="372.59999999999997" y2="765" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="482.4" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="527.4" y="30" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">IoT Core</text><text x="527.4" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">AWS IoT Core</text><line x1="527.4" y1="60" x2="527.4" y2="765" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="637.2" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="682.2" y="30" text-anchor="middle" fill="#ec4899" font-size="10" font-weight="600">Jetson</text><text x="682.2" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">NVIDIA Jetson</text><line x1="682.2" y1="60" x2="682.2" y2="765" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="792" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="837" y="30" text-anchor="middle" fill="#06b6d4" font-size="10" font-weight="600">Hand</text><text x="837" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Amazing Hand</text><line x1="837" y1="60" x2="837" y2="765" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="10" y="144" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.0s</text><line x1="68" y1="140" x2="212.79999999999998" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="205.79999999999998,137 205.79999999999998,143 212.79999999999998,140" fill="#64748b"></polygon><text x="140.39999999999998" y="135" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Click microphone and speak</text><text x="140.39999999999998" y="150" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">User speaks naturally</text></g><g opacity="0.2"><text x="10" y="189" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.1s</text><path d="M 217.79999999999998 185
                        L 245.79999999999998 185
                        L 245.79999999999998 201
                        L 224.79999999999998 201" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="224.79999999999998,197 224.79999999999998,205 217.79999999999998,201" fill="#64748b"></polygon><text x="251.79999999999998" y="190" fill="#94a3b8" font-size="7" font-weight="400">AudioWorklet: capture → resample → PCM → Base64</text></g><g opacity="0.2"><text x="10" y="234" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.2s</text><line x1="222.79999999999998" y1="230" x2="367.59999999999997" y2="230" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="360.59999999999997,227 360.59999999999997,233 367.59999999999997,230" fill="#64748b"></polygon><text x="295.2" y="225" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">InvokeModelWithBidirectionalStream (audio chunks)</text><text x="295.2" y="240" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Real-time streaming</text></g><g opacity="0.2"><text x="10" y="279" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2.0s</text><line x1="367.59999999999997" y1="275" x2="222.79999999999998" y2="275" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="229.79999999999998,272 229.79999999999998,278 222.79999999999998,275" fill="#64748b"></polygon><text x="295.2" y="270" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">send_text tool invocation (cleaned English text)</text><text x="295.2" y="285" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Filler words removed, translated to English</text><circle cx="234.79999999999998" cy="275" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="324" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2.1s</text><line x1="222.79999999999998" y1="320" x2="522.4" y2="320" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="515.4,317 515.4,323 522.4,320" fill="#64748b"></polygon><text x="372.59999999999997" y="315" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">MQTT publish: { id, sentence, ts }</text><text x="372.59999999999997" y="330" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Plain text, no servo data</text></g><g opacity="0.2"><text x="10" y="369" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2.2s</text><line x1="532.4" y1="365" x2="677.2" y2="365" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="670.2,362 670.2,368 677.2,365" fill="#64748b"></polygon><text x="604.8" y="360" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">MQTT subscribe: receive sentence text</text></g><g opacity="0.2"><text x="10" y="414" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2.5s</text><path d="M 682.2 410
                        L 710.2 410
                        L 710.2 426
                        L 689.2 426" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="689.2,422 689.2,430 682.2,426" fill="#64748b"></polygon><text x="716.2" y="415" fill="#94a3b8" font-size="7" font-weight="400">Strands Agent + Nova 2 Lite: sentence → ASL letter l...</text><text x="716.2" y="425" fill="#64748b" font-size="6" font-style="italic">LLM tool use + ASL_ALPHABET table</text></g><g opacity="0.2"><text x="10" y="459" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">3.0s</text><line x1="687.2" y1="455" x2="832" y2="455" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="825,452 825,458 832,455" fill="#64748b"></polygon><text x="759.6" y="450" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Drive servos for ASL fingerspelling letter by letter</text><text x="759.6" y="465" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Letter by letter via serial</text></g><g opacity="0.2"><text x="10" y="504" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4.0s</text><line x1="677.2" y1="500" x2="532.4" y2="500" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="539.4,497 539.4,503 532.4,500" fill="#64748b"></polygon><text x="604.8" y="495" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Publish hand state (servo angles + video)</text></g><g opacity="0.2"><text x="10" y="549" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4.1s</text><line x1="522.4" y1="545" x2="222.79999999999998" y2="545" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="229.79999999999998,542 229.79999999999998,548 222.79999999999998,545" fill="#64748b"></polygon><text x="372.59999999999997" y="540" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">GraphQL subscription: hand state update</text><text x="372.59999999999997" y="555" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Via AppSync</text></g><g opacity="0.2"><text x="10" y="594" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4.2s</text><path d="M 217.79999999999998 590
                        L 245.79999999999998 590
                        L 245.79999999999998 606
                        L 224.79999999999998 606" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="224.79999999999998,602 224.79999999999998,610 217.79999999999998,606" fill="#64748b"></polygon><text x="251.79999999999998" y="595" fill="#94a3b8" font-size="7" font-weight="400">Update 3D hand animation + video feed + letter history</text><circle cx="451.79999999999995" cy="592" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="639" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4.5s</text><line x1="367.59999999999997" y1="635" x2="222.79999999999998" y2="635" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="229.79999999999998,632 229.79999999999998,638 222.79999999999998,635" fill="#64748b"></polygon><text x="295.2" y="630" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">audioOutput: voice confirmation ("Sent")</text></g><g opacity="0.2"><text x="10" y="684" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">4.6s</text><line x1="212.79999999999998" y1="680" x2="68" y2="680" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="75,677 75,683 68,680" fill="#64748b"></polygon><text x="140.39999999999998" y="675" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Play audio confirmation + see 3D hand + video</text><circle cx="80" cy="680" r="3" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;font-size:9px"><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">User</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Browser</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Bedrock</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Jetson</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">Hand</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#22c55e"></div><span style="color:#94a3b8">Complete</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.15));border:1px solid rgba(34, 197, 94, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#86efac;margin-bottom:4px">Total: <!-- -->13<!-- --> steps across <!-- -->6<!-- --> components (3 repos)</div><div style="font-size:14px;font-weight:700;color:#22c55e">Speech → Sentence → Edge AI → ASL Fingerspelling</div></div></div>
<h1>Architecture</h1>
<p>The frontend is built with React 19, Vite 7, and TypeScript 5.9. The application is structured around a main <code>VoiceChat.tsx</code> component that orchestrates four custom hooks, three utility modules, and a Three.js-based hand animation component.</p>
<p><img decoding="async" loading="lazy" alt="Application UI" src="https://www.chiwaichan.co.nz/assets/images/ui-581e37bbaad847c73d7769ce7fc1bb59.png" width="1871" height="939" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="react-hooks-architecture">React Hooks Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#react-hooks-architecture" class="hash-link" aria-label="Direct link to React Hooks Architecture" title="Direct link to React Hooks Architecture">​</a></h2>
<p><img decoding="async" loading="lazy" alt="React Hooks Architecture" src="https://www.chiwaichan.co.nz/assets/images/02-react-hooks-architecture-5db688561f90ddf7f06567ffc8fa8489.png" width="2620" height="2502" class="img_ev3q"></p>
<p><strong>Components:</strong></p>
<ul>
<li><strong>VoiceChat.tsx</strong> - Main UI component with a 3-column responsive layout. Coordinates all hooks, renders the transcript feed, microphone controls, signed letter history, hand state data grid, video feed, and 3D animation. Collapses to a single column on screens under 1100px</li>
<li><strong>useNovaSonic</strong> - Core hook managing the Bedrock bidirectional stream with <code>InvokeModelWithBidirectionalStreamCommand</code>. Handles authentication via Cognito, the Nova 2 Sonic event protocol (session/prompt/content lifecycle), the async generator input stream with backpressure, and <code>send_text</code> tool use responses. The tool is configured with <code>toolChoice: { any: {} }</code> to force tool invocation on every utterance</li>
<li><strong>useAudioRecorder</strong> - Captures microphone input using an inline AudioWorklet running in a separate thread. Accumulates 2048 samples per buffer, resamples from the device sample rate (typically 48kHz) to 16kHz, converts Float32 to PCM16, and Base64 encodes for transmission</li>
<li><strong>useAudioPlayer</strong> - Provides audio playback capability (FIFO queue of AudioBuffers at 24kHz). In the current implementation, Nova 2 Sonic's audio output is intentionally discarded since only the cleaned text via tool use is needed — the hook is available but not actively fed audio data</li>
<li><strong>useHandStream</strong> - Subscribes to AppSync GraphQL <code>onCreateHandState</code> subscription filtered by device name. Fetches the last 20 hand states on mount and maintains a real-time list of 8 servo angles (thumb, index, middle, ring — each with two joint angles), letters, and video URLs</li>
<li><strong>iotPublisher.ts</strong> - Publishes MQTT messages to the topic <code>the-project/robotic-hand/XIAOAmazingHandRight/action</code>. Publishes cleaned sentence text as <code>{ id, sentence, ts }</code> payloads and handles IoT policy attachment to the Cognito identity</li>
<li><strong>HandAnimation.tsx</strong> - Procedurally generated 3D robotic hand using Three.js with no external 3D models. The palm is built with LatheGeometry (curved cup shape), and each finger has a dual-joint rig (proximal + distal) with synchronised linkage. Uses WebGL rendering with PCFSoftShadowMap shadows, OrbitControls, and industrial-style materials with metalness/roughness</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="authentication-flow">Authentication Flow<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#authentication-flow" class="hash-link" aria-label="Direct link to Authentication Flow" title="Direct link to Authentication Flow">​</a></h2>
<p>The frontend needs temporary AWS credentials to call both Bedrock (for Nova 2 Sonic streaming) and IoT Core (for MQTT publishing). No long-term credentials are stored in the browser.</p>
<p><img decoding="async" loading="lazy" alt="Authentication Flow" src="https://www.chiwaichan.co.nz/assets/images/03-authentication-flow-aafe8e13a45bad1b2c1427b85b89cf8b.png" width="1691" height="1981" class="img_ev3q"></p>
<p><strong>Authentication Layers:</strong></p>
<ul>
<li><strong>Cognito User Pool</strong> - Handles user registration and login with email/password. Configured via Amplify Gen 2 <code>defineAuth</code> with <code>preferredUsername</code> as an optional attribute</li>
<li><strong>Cognito Identity Pool</strong> - Exchanges JWT tokens from the User Pool for temporary AWS credentials (access key, secret key, session token). Credentials are automatically refreshed by the Amplify SDK before expiration</li>
<li><strong>IAM Role</strong> - The authenticated user role grants two sets of permissions: <code>bedrock:InvokeModel</code> and <code>bedrock:InvokeModelWithResponseStream</code> scoped to <code>amazon.nova-2-sonic-v1:0</code> in us-east-1, and <code>iot:Publish</code>, <code>iot:Connect</code>, <code>iot:DescribeEndpoint</code>, and <code>iot:AttachPolicy</code> for IoT Core MQTT access. An IoT Core policy (<code>RoboticHandPolicy</code>) is also attached to the Cognito identity at runtime to authorise MQTT publishing to the <code>the-project/robotic-hand/*</code> topic pattern</li>
</ul>
<h1>How it works</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="audio-capture-and-processing">Audio Capture and Processing<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#audio-capture-and-processing" class="hash-link" aria-label="Direct link to Audio Capture and Processing" title="Direct link to Audio Capture and Processing">​</a></h2>
<p>The browser captures audio from the microphone using the Web Audio API and an AudioWorklet running in a separate thread. The AudioWorklet avoids main-thread blocking and processes audio in real-time with echo cancellation and noise suppression enabled.</p>
<p><img decoding="async" loading="lazy" alt="Audio Processing Pipeline" src="https://www.chiwaichan.co.nz/assets/images/04-audio-processing-pipeline-0d821601a82a9b306553486dff9af6a4.png" width="4362" height="733" class="img_ev3q"></p>
<p><strong>Input Processing (Recording):</strong></p>
<ol>
<li><strong>Microphone</strong> - Browser calls <code>getUserMedia()</code> to capture audio at the device's native sample rate (typically 48kHz) with mono channel, echo cancellation, and noise suppression enabled</li>
<li><strong>AudioWorklet</strong> - An inline <code>AudioCaptureProcessor</code> (loaded as a Blob URL to avoid CORS issues) runs in a separate thread. It accumulates samples in a buffer and posts a <code>Float32Array</code> message to the main thread every 2048 samples</li>
<li><strong>Resample</strong> - Linear interpolation resampling converts from 48kHz to 16kHz (Nova 2 Sonic's required input rate). The ratio is calculated dynamically from the actual device sample rate</li>
<li><strong>Float32 to PCM16</strong> - Floating point samples in the range [-1, 1] are converted to 16-bit signed integers. Negative values are multiplied by <code>0x8000</code> and positive values by <code>0x7FFF</code></li>
<li><strong>Base64 Encode</strong> - The binary PCM data is encoded to Base64 text for JSON transmission to Bedrock via a custom <code>uint8ArrayToBase64()</code> utility that iterates bytes into a binary string and then calls <code>btoa()</code></li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="amazon-nova-2-sonic-bidirectional-streaming">Amazon Nova 2 Sonic Bidirectional Streaming<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#amazon-nova-2-sonic-bidirectional-streaming" class="hash-link" aria-label="Direct link to Amazon Nova 2 Sonic Bidirectional Streaming" title="Direct link to Amazon Nova 2 Sonic Bidirectional Streaming">​</a></h2>
<p>The heart of the system is the bidirectional stream to Amazon Nova 2 Sonic using <code>InvokeModelWithBidirectionalStreamCommand</code>. Nova 2 Sonic is configured not as a chatbot, but as a speech relay that cleans up input and forwards it via forced tool use.</p>
<p><img decoding="async" loading="lazy" alt="Bidirectional Streaming Protocol" src="https://www.chiwaichan.co.nz/assets/images/05-bidirectional-streaming-e3d26734f334de2825143bae2e9e6248.png" width="2730" height="4004" class="img_ev3q"></p>
<p><strong>Input Events (sent to Bedrock):</strong></p>
<ul>
<li><strong>sessionStart</strong> - Initialises the session with inference configuration: <code>maxTokens: 1024</code>, <code>topP: 0.9</code>, <code>temperature: 0.7</code></li>
<li><strong>promptStart</strong> - Configures audio output format: <code>audio/lpcm</code> at 24kHz, 16-bit, mono, voice <code>matthew</code>, Base64 encoding. Also defines the <code>send_text</code> tool with <code>toolChoice: { any: {} }</code> to force tool invocation on every utterance</li>
<li><strong>contentStart (TEXT)</strong> - Sends the system prompt that instructs Nova 2 Sonic to act as a "dumb speech-to-text relay pipe" — clean up grammar, remove filler words, translate non-English to English, call <code>send_text</code> with the cleaned text, then respond with only "Sent"</li>
<li><strong>contentStart (AUDIO)</strong> - Marks the beginning of audio input content</li>
<li><strong>audioInput</strong> - Streams Base64-encoded 16kHz PCM audio chunks in real-time as the user speaks</li>
<li><strong>contentEnd / promptEnd / sessionEnd</strong> - Lifecycle events to terminate content blocks, prompts, and sessions</li>
</ul>
<p><strong>Output Events (received from Bedrock):</strong></p>
<ul>
<li><strong>textOutput</strong> - Returns transcribed user speech and the generated AI response text ("Sent")</li>
<li><strong>toolUse</strong> - The <code>send_text</code> tool invocation containing the cleaned text in <code>{ sentence: "..." }</code> format. This is the primary output — the frontend publishes the sentence to MQTT for the edge device to translate into ASL servo commands</li>
<li><strong>audioOutput</strong> - Synthesised voice response as Base64-encoded 24kHz PCM. In the current implementation, audio output is intentionally discarded since only the cleaned text via tool use is needed</li>
</ul>
<p><strong>Tool Use — <code>send_text</code>:</strong></p>
<ul>
<li>The tool is defined with <code>toolChoice: { any: {} }</code>, which forces Nova 2 Sonic to call it on every single utterance without exception</li>
<li>The tool accepts a single <code>sentence</code> parameter — the cleaned-up, well-formed sentence</li>
<li>When the tool invocation arrives, the frontend extracts the sentence and publishes it as <code>{ id, sentence, ts }</code> to IoT Core via MQTT using <code>publishSentence()</code>. The edge device then translates the sentence into ASL servo commands</li>
<li>A JSON tool result (<code>{ "status": "success", "sentence": "..." }</code>) is sent back to Nova 2 Sonic to complete the tool use cycle</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="publishing-sentences-via-mqtt">Publishing Sentences via MQTT<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#publishing-sentences-via-mqtt" class="hash-link" aria-label="Direct link to Publishing Sentences via MQTT" title="Direct link to Publishing Sentences via MQTT">​</a></h2>
<p>Once the cleaned sentence is extracted from the <code>send_text</code> tool invocation, <code>iotPublisher.ts</code> publishes it to the MQTT topic <code>the-project/robotic-hand/XIAOAmazingHandRight/action</code> via AWS IoT Core.</p>
<p><img decoding="async" loading="lazy" alt="MQTT Command Flow" src="https://www.chiwaichan.co.nz/assets/images/07-mqtt-command-flow-0e94235338094807c0421bcd77247fce.png" width="2268" height="2041" class="img_ev3q"></p>
<p>The payload is a simple JSON object containing:</p>
<ul>
<li><strong>id</strong> - A UUID for the message</li>
<li><strong>sentence</strong> - The cleaned sentence text from Nova 2 Sonic</li>
<li><strong>ts</strong> - Unix timestamp in seconds</li>
</ul>
<p>The edge device (covered in Part 3) receives this sentence and is responsible for translating it into ASL servo commands and driving the physical hand.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram-1">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#interactive-sequence-diagram-1" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">MQTT Command Pipeline</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From Nova 2 Sonic text output to IoT Core sentence publish</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="mqtt-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->7</span></div><div style="min-width:600px;overflow-x:auto"><svg width="100%" height="515" viewBox="0 0 800 515" style="display:block"><g><rect x="19" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="64" y="30" text-anchor="middle" fill="#22c55e" font-size="11" font-weight="600">Nova</text><text x="64" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Nova 2 Sonic</text><line x1="64" y1="60" x2="64" y2="495" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="186.99999999999997" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="231.99999999999997" y="30" text-anchor="middle" fill="#3b82f6" font-size="11" font-weight="600">Hook</text><text x="231.99999999999997" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">useNovaSonic</text><line x1="231.99999999999997" y1="60" x2="231.99999999999997" y2="495" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="355" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="400" y="30" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">VoiceChat</text><text x="400" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">VoiceChat.tsx</text><line x1="400" y1="60" x2="400" y2="495" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="523" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="568" y="30" text-anchor="middle" fill="#ec4899" font-size="11" font-weight="600">Publisher</text><text x="568" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">iotPublisher.ts</text><line x1="568" y1="60" x2="568" y2="495" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="691" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="736" y="30" text-anchor="middle" fill="#06b6d4" font-size="11" font-weight="600">IoT Core</text><text x="736" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">AWS IoT Core</text><line x1="736" y1="60" x2="736" y2="495" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="12" y="144" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0ms</text><line x1="70" y1="140" x2="225.99999999999997" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="217.99999999999997,136 217.99999999999997,144 225.99999999999997,140" fill="#64748b"></polygon><text x="148" y="134" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">send_text tool invocation: { sentence: "hello w...</text><text x="148" y="150" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Cleaned text from speech</text></g><g opacity="0.2"><text x="12" y="189" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1ms</text><line x1="237.99999999999997" y1="185" x2="394" y2="185" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="386,181 386,189 394,185" fill="#64748b"></polygon><text x="316" y="179" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">onToolUse callback with tool event</text></g><g opacity="0.2"><text x="12" y="234" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">2ms</text><path d="M 400 230
                        L 430 230
                        L 430 248
                        L 408 248" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="408,244 408,252 400,248" fill="#64748b"></polygon><text x="438" y="236" fill="#94a3b8" font-size="8" font-weight="400">Parse event.content JSON, extract sentence</text></g><g opacity="0.2"><text x="12" y="279" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3ms</text><line x1="406" y1="275" x2="562" y2="275" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="554,271 554,279 562,275" fill="#64748b"></polygon><text x="484" y="269" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">publishSentence(sentence)</text><circle cx="547" cy="275" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="324" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">4ms</text><path d="M 568 320
                        L 598 320
                        L 598 338
                        L 576 338" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="576,334 576,342 568,338" fill="#64748b"></polygon><text x="606" y="326" fill="#94a3b8" font-size="8" font-weight="400">Build payload: { id: uuid, sentence, ts }</text><text x="606" y="337" fill="#64748b" font-size="7" font-style="italic">Plain text, no servo data</text></g><g opacity="0.2"><text x="12" y="369" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5ms</text><line x1="574" y1="365" x2="730" y2="365" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="722,361 722,369 730,365" fill="#64748b"></polygon><text x="652" y="359" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">MQTT publish to .../action topic</text><circle cx="715" cy="365" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="414" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">6ms</text><line x1="225.99999999999997" y1="410" x2="70" y2="410" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="78,406 78,414 70,410" fill="#64748b"></polygon><text x="148" y="404" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Tool result: { status: "success", sentence: ".....</text></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:12px;flex-wrap:wrap;font-size:10px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Nova</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Hook</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">VoiceChat</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Publisher</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:8px;height:8px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(236, 72, 153, 0.15), rgba(219, 39, 119, 0.15));border:1px solid rgba(236, 72, 153, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#f9a8d4;margin-bottom:4px">Sentence Publish Pipeline</div><div style="font-size:11px;color:#f472b6">Speech → Nova 2 Sonic cleanup → send_text tool use → publishSentence → MQTT to edge device</div></div></div>
<p>The browser console logs the performance breakdown for each utterance through the voice-to-IoT pipeline. In this example, the end-to-end time from speech detection to IoT publish is approximately 2.9 seconds — with the majority spent on Speech-to-Text (2228ms) as Nova 2 Sonic processes the audio, followed by Text-to-Tool extraction (423ms) and IoT Publish (243ms):</p>
<p><img decoding="async" loading="lazy" alt="Voice-to-IoT Pipeline Performance" src="https://www.chiwaichan.co.nz/assets/images/model-timings-c33b5f8b205fa4f593c99cb05130ea15.png" width="2934" height="380" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="real-time-hand-state-via-graphql-subscription">Real-Time Hand State via GraphQL Subscription<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#real-time-hand-state-via-graphql-subscription" class="hash-link" aria-label="Direct link to Real-Time Hand State via GraphQL Subscription" title="Direct link to Real-Time Hand State via GraphQL Subscription">​</a></h2>
<p>The frontend subscribes to AppSync's <code>onCreateHandState</code> GraphQL subscription to receive real-time updates from the edge device. Each update includes the device name, current letter being signed, all 8 servo angles (thumb, index, middle, ring — each with two joint angles), a timestamp, and an optional video URL.</p>
<p>On mount, the hook fetches the last 20 hand states to populate the UI immediately. New states arrive in real-time as the edge device publishes them back through IoT Core → Lambda → AppSync. The data is displayed in both the signed letter history panel and the raw hand state data grid.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="3d-hand-visualisation">3D Hand Visualisation<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#3d-hand-visualisation" class="hash-link" aria-label="Direct link to 3D Hand Visualisation" title="Direct link to 3D Hand Visualisation">​</a></h2>
<p>The <code>HandAnimation.tsx</code> component renders a procedurally generated 3D robotic hand using Three.js — no external 3D models are loaded. The entire hand is built from code:</p>
<ul>
<li>The <strong>palm</strong> uses <code>LatheGeometry</code> to create a curved cup shape that tapers from a narrow wrist (radius 0.18) to wide knuckles (radius 0.56)</li>
<li>Each <strong>finger</strong> has a dual-joint rig with proximal and distal segments, knuckle joints, linkage bars, and fingertips. The thumb is mounted on the side of the palm and rotates on the Z-axis, while the index, middle, and ring fingers are mounted on the front rim and rotate on the X-axis</li>
<li>The distal joint automatically follows the proximal joint at 50% of its angle, simulating a synchronised linkage mechanism</li>
<li>Materials use industrial-style metalness/roughness: dark gray frame (0x2a2a2a), light gray joints (0x888888), and darker gray tips (0x555555)</li>
<li>The scene includes PCFSoftShadowMap shadows, ambient lighting (0.8), directional light (1.0), and a fill light (0.4), with OrbitControls for interactive zoom and rotation</li>
</ul>
<p>Servo angle updates from the GraphQL subscription drive the finger rotations in real-time, keeping the 3D animation synchronised with the physical Amazing Hand.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="audio-playback">Audio Playback<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#audio-playback" class="hash-link" aria-label="Direct link to Audio Playback" title="Direct link to Audio Playback">​</a></h2>
<p>The <code>useAudioPlayer</code> hook provides a FIFO queue-based audio playback capability for Web Audio <code>AudioBuffer</code> objects at 24kHz. However, in the current implementation, Nova 2 Sonic's audio output is intentionally discarded — the <code>onAudioOutput</code> callback is set to a no-op since only the cleaned text via the <code>send_text</code> tool use is needed to drive the MQTT pipeline. The hook remains available for future use if audio feedback is desired.</p>
<h1>Technical Challenges &amp; Solutions</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-1-audioworklet-cors-issues">Challenge 1: AudioWorklet CORS Issues<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#challenge-1-audioworklet-cors-issues" class="hash-link" aria-label="Direct link to Challenge 1: AudioWorklet CORS Issues" title="Direct link to Challenge 1: AudioWorklet CORS Issues">​</a></h2>
<p><strong>Problem:</strong> Loading an AudioWorklet processor from an external JavaScript file fails with CORS errors on some deployments, particularly when using Amplify Hosting.</p>
<p><strong>Solution:</strong> Inline the AudioWorklet code as a Blob URL. The processor code is defined as a string, converted to a <code>Blob</code> with type <code>application/javascript</code>, and loaded via <code>URL.createObjectURL()</code>. The object URL is revoked after the module is added:</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> blob </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">Blob</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">audioWorkletCode</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> type</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'application/javascript'</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> workletUrl </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">URL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">createObjectURL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">blob</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> audioContext</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">audioWorklet</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">addModule</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">workletUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token constant" style="color:rgb(100, 102, 149)">URL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">revokeObjectURL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">workletUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-2-forcing-tool-use-on-every-utterance">Challenge 2: Forcing Tool Use on Every Utterance<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#challenge-2-forcing-tool-use-on-every-utterance" class="hash-link" aria-label="Direct link to Challenge 2: Forcing Tool Use on Every Utterance" title="Direct link to Challenge 2: Forcing Tool Use on Every Utterance">​</a></h2>
<p><strong>Problem:</strong> Nova 2 Sonic is a conversational model by default — it wants to chat and respond naturally. But in this system, it needs to act as a pure relay, forwarding every single utterance as cleaned text without adding commentary or refusing any messages.</p>
<p><strong>Solution:</strong> A combination of system prompt engineering and forced tool use. The system prompt explicitly instructs Nova 2 Sonic to act as a "dumb speech-to-text relay pipe" and never add commentary. The <code>send_text</code> tool is configured with <code>toolChoice: { any: {} }</code>, which forces the model to invoke a tool on every response. After calling the tool, it is instructed to only respond with "Sent".</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-3-keeping-mqtt-payloads-simple">Challenge 3: Keeping MQTT Payloads Simple<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#challenge-3-keeping-mqtt-payloads-simple" class="hash-link" aria-label="Direct link to Challenge 3: Keeping MQTT Payloads Simple" title="Direct link to Challenge 3: Keeping MQTT Payloads Simple">​</a></h2>
<p><strong>Problem:</strong> The system needs to transmit the user's intent from the frontend to the edge device reliably via IoT Core MQTT.</p>
<p><strong>Solution:</strong> Rather than translating text to servo commands on the frontend (which would require large payloads with many servo poses), the frontend publishes only the cleaned sentence text as a compact <code>{ id, sentence, ts }</code> JSON payload. The edge device is responsible for translating the sentence into ASL servo commands, keeping the MQTT messages small and the frontend simple.</p>
<h1>Getting Started</h1>
<p><strong>GitHub Repository:</strong> <a href="https://github.com/chiwaichan/amplify-react-nova-sonic-voice-chat-amazing-hand" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/amplify-react-nova-sonic-voice-chat-amazing-hand</a></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="prerequisites">Prerequisites<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites">​</a></h2>
<ul>
<li>Node.js 18+</li>
<li>AWS Account with Amazon Nova 2 Sonic model access enabled in Bedrock (us-east-1 region)</li>
<li>AWS CLI configured with credentials</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="deployment-steps">Deployment Steps<a href="https://www.chiwaichan.co.nz/blog/2026/03/13/real-time-voice-to-sign-language-amazon-nova-sonic-amazing-hand#deployment-steps" class="hash-link" aria-label="Direct link to Deployment Steps" title="Direct link to Deployment Steps">​</a></h2>
<ol>
<li>
<p><strong>Enable Nova 2 Sonic in Bedrock Console</strong> (us-east-1 region)</p>
</li>
<li>
<p><strong>Clone and Install:</strong></p>
</li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">git</span><span class="token plain"> clone https://github.com/chiwaichan/amplify-react-nova-sonic-voice-chat-amazing-hand.git</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> amplify-react-nova-sonic-voice-chat-amazing-hand</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">install</span><br></span></code></pre></div></div>
<ol start="3">
<li><strong>Start Amplify Sandbox:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token plain">npx ampx sandbox</span><br></span></code></pre></div></div>
<ol start="4">
<li><strong>Run Development Server:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> run dev</span><br></span></code></pre></div></div>
<ol start="5">
<li><strong>Open Application:</strong>
Navigate to <code>http://localhost:5173</code>, create an account, and start talking. Note that the full system requires Parts 2 and 3 to be deployed for the physical hand to respond — but the frontend will still capture speech, process it through Nova 2 Sonic, and display the 3D hand animation independently.</li>
</ol>
<h1>What's Next</h1>
<p>In <strong>Part 2</strong>, I will cover the cloud infrastructure layer — the AWS CDK stack (<a href="https://github.com/chiwaichan/cdk-iot-amazing-hand-streaming" target="_blank" rel="noopener noreferrer"><code>cdk-iot-amazing-hand-streaming</code></a>) that routes IoT Core MQTT messages through Lambda to AppSync. This is the bridge that enables real-time GraphQL subscriptions, allowing the frontend to receive hand state updates from the edge device as they happen.</p>
<p>In <strong>Part 3</strong>, I will cover the edge AI agent (<a href="https://github.com/chiwaichan/strands-agents-amazing-hands" target="_blank" rel="noopener noreferrer"><code>strands-agents-amazing-hands</code></a>) — a Strands Agent powered by Amazon Nova 2 Lite running on an NVIDIA Jetson that subscribes to the MQTT sentence text published by this frontend, translates them into physical servo movements on the Pollen Robotics Amazing Hand for ASL fingerspelling, records video of the hand in action, and publishes state back through IoT Core.</p>
<h1>Summary</h1>
<p>This post covered the frontend and voice processing layer of a real-time voice-to-sign-language translation system:</p>
<ul>
<li><strong>Amazon Nova 2 Sonic</strong> is used not as a chatbot but as a speech relay — configured via system prompt and <code>toolChoice: { any: {} }</code> forced <code>send_text</code> tool use to clean up grammar, remove filler words, translate to English, and forward every utterance as text</li>
<li><strong>Audio pipeline</strong> captures at 48kHz via AudioWorklet, resamples to 16kHz, converts to PCM16 Base64 for Bedrock input. Nova 2 Sonic's audio output is intentionally discarded since only the cleaned text is needed</li>
<li><strong>MQTT publishing</strong> sends cleaned sentence text as <code>{ id, sentence, ts }</code> to AWS IoT Core for the edge device to translate into ASL servo commands</li>
<li><strong>Real-time feedback</strong> via GraphQL subscriptions keeps the 3D Three.js hand animation synchronised with the physical Amazing Hand using 8 servo angles (thumb, index, middle, ring — each with two joints)</li>
<li><strong>Fully serverless</strong> frontend using AWS Amplify Gen 2 with Cognito authentication, no backend servers — direct browser-to-Bedrock and browser-to-IoT Core communication</li>
</ul>]]></content:encoded>
            <category>AWS</category>
            <category>Bedrock</category>
            <category>Nova Sonic</category>
            <category>Amazon Nova</category>
            <category>Sign Language</category>
            <category>Accessibility</category>
            <category>IoT</category>
            <category>MQTT</category>
            <category>AWS IoT Core</category>
            <category>Amplify</category>
            <category>React</category>
            <category>TypeScript</category>
            <category>Three.js</category>
            <category>GenerativeAI</category>
            <category>Serverless</category>
            <category>Robotics</category>
        </item>
        <item>
            <title><![CDATA[Agentic based Over-The-Air Firmware Management of Seeed Studio XIAO ESP32S3 IoT Device Firmware using Amazon AgentCore and Strands Agents]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32</guid>
            <pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Building an agentic Over-The-Air firmware management system using Amazon AgentCore and Strands Agents, enabling natural language prompts to update, rollback, and manage IoT device firmware fleets]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/iot-firmware-device-management"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">IoT Firmware and Device Management</span><span class="description_NSta">Scalable firmware and device management with OTA updates using AWS IoT Jobs</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<p>I want to have the ability to be able to manage the firmware of all IoT devices using a prompt - it could be to upgrade a device to the latest version, or even to perform a rollback, whether across the entire IoT device fleet level - every device in all 20+ solution types, all the devices within a type of solution, or even at an individual device level.</p>
<h1>Goals</h1>
<ul>
<li>To be able to over-the-air flash a new firmware version using a prompt</li>
<li>To have an Agentic Agent do all the work, give it a prompt and it takes cares of the rest</li>
<li>Scalable in the number of IoT devices, as well as, being able to scale as the number of new IoT solution Types increases; with no effort required - implement once and forget</li>
<li>Have the ability to rollback to any firmware version specified in the prompt</li>
<li>This same solution can be interfaced with using the Model Context Protocol (MCP): whether via Kiro CLI or Claude Code</li>
<li>This same solution can be interfaced with using a chatbot</li>
<li>Must be authenticated to interface with this solution</li>
<li>Must be a completely serverless-solution</li>
<li>Firmware integrity verification using SHA256 checksums before flashing to ensure firmware hasn't been corrupted during download</li>
<li>Safe rollout with rate limiting and automatic abort thresholds to prevent fleet-wide failures</li>
<li>Device firmware version tracking via device shadows to enable version-based targeting for updates</li>
<li>Configuration-gated deployments to enable or disable OTA updates per device type for controlled rollouts</li>
</ul>
<h1>Architecture</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="end-to-end-ota-firmware-update-flow">End-to-End OTA Firmware Update Flow<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#end-to-end-ota-firmware-update-flow" class="hash-link" aria-label="Direct link to End-to-End OTA Firmware Update Flow" title="Direct link to End-to-End OTA Firmware Update Flow">​</a></h2>
<p>This diagram illustrates the complete flow from a user's natural language prompt to firmware being flashed on Seeed Studio XIAO ESP32S3 devices.</p>
<p><img decoding="async" loading="lazy" alt="End-to-End OTA Firmware Update Flow" src="https://www.chiwaichan.co.nz/assets/images/01-end-to-end-ota-flow-1cc07532120fbad1134e32dbd1e889aa.png" width="2524" height="1528" class="img_ev3q"></p>
<p><strong>Flow Steps:</strong></p>
<ol>
<li><strong>User Prompt</strong> - Developer/Operator provides a natural language command (e.g., "Update all vision_ai_face_detector devices to v2.0.0")</li>
<li><strong>AgentCore Runtime</strong> - Amazon Bedrock AgentCore receives and processes the request</li>
<li><strong>Strands Agent</strong> - The agent with <code>firmware_updater</code> tool reasons about the task</li>
<li><strong>Config Check</strong> - Agent queries DynamoDB to verify the device type is enabled for OTA updates</li>
<li><strong>Firmware Metadata</strong> - Agent retrieves firmware binary, SHA256 checksum, and metadata from S3</li>
<li><strong>Create IoT Job</strong> - Agent creates a continuous IoT Job targeting the specified device group</li>
<li><strong>MQTT Notification</strong> - AWS IoT Core notifies devices via MQTT topic <code>$aws/things/+/jobs/notify</code></li>
<li><strong>Firmware Download</strong> - Each XIAO ESP32S3 Vision AI Face Detector downloads the firmware directly from S3</li>
<li><strong>Version Reporting</strong> - Devices report their new firmware version to their Device Shadow</li>
</ol>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#interactive-sequence-diagram" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">End-to-End OTA Firmware Update Flow</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From natural language prompt to firmware flashed on Seeed Studio XIAO ESP32</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">◀ Prev</button><button class="ota-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">▶ Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next ▶</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->15</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="920" viewBox="0 0 900 920" style="display:block"><g><rect x="13.000000000000007" y="10" width="100" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="63.00000000000001" y="30" text-anchor="middle" fill="#8b5cf6" font-size="11" font-weight="600">User</text><text x="63.00000000000001" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Developer/Operator</text><line x1="63.00000000000001" y1="60" x2="63.00000000000001" y2="900" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="142" y="10" width="100" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="192" y="30" text-anchor="middle" fill="#3b82f6" font-size="11" font-weight="600">AgentCore</text><text x="192" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Bedrock AgentCore</text><line x1="192" y1="60" x2="192" y2="900" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="271" y="10" width="100" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="321" y="30" text-anchor="middle" fill="#22c55e" font-size="11" font-weight="600">Strands</text><text x="321" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Strands Agent</text><line x1="321" y1="60" x2="321" y2="900" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="400" y="10" width="100" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="450" y="30" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">DynamoDB</text><text x="450" y="46" text-anchor="middle" fill="#94a3b8" font-size="8"></text><line x1="450" y1="60" x2="450" y2="900" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="529.0000000000001" y="10" width="100" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="579.0000000000001" y="30" text-anchor="middle" fill="#06b6d4" font-size="11" font-weight="600">S3</text><text x="579.0000000000001" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">S3 Bucket</text><line x1="579.0000000000001" y1="60" x2="579.0000000000001" y2="900" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="658.0000000000001" y="10" width="100" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="708.0000000000001" y="30" text-anchor="middle" fill="#ec4899" font-size="11" font-weight="600">IoT Core</text><text x="708.0000000000001" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">AWS IoT Core</text><line x1="708.0000000000001" y1="60" x2="708.0000000000001" y2="900" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="787" y="10" width="100" height="50" rx="6" fill="#10b98130" stroke="#10b981" stroke-width="2"></rect><text x="837" y="30" text-anchor="middle" fill="#10b981" font-size="11" font-weight="600">XIAO</text><text x="837" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">XIAO ESP32</text><line x1="837" y1="60" x2="837" y2="900" stroke="#10b981" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="12" y="144" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.0s</text><line x1="69" y1="140" x2="186" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="178,136 178,144 186,140" fill="#64748b"></polygon><text x="127.5" y="134" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">"Update all vision_ai_face_detector to v2.0.0"</text><text x="127.5" y="152" text-anchor="middle" fill="#64748b" font-size="8" font-style="italic">Natural language</text></g><g opacity="0.2"><text x="12" y="192" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.1s</text><line x1="198" y1="188" x2="315" y2="188" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="307,184 307,192 315,188" fill="#64748b"></polygon><text x="256.5" y="182" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Invoke agent with prompt</text><circle cx="300" cy="188" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="240" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.2s</text><line x1="327" y1="236" x2="444" y2="236" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="436,232 436,240 444,236" fill="#64748b"></polygon><text x="385.5" y="230" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">GetItem: Check if device type enabled</text></g><g opacity="0.2"><text x="12" y="288" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.3s</text><line x1="444" y1="284" x2="327" y2="284" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="335,280 335,288 327,284" fill="#64748b"></polygon><text x="385.5" y="278" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">enabled: true</text></g><g opacity="0.2"><text x="12" y="336" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.4s</text><line x1="327" y1="332" x2="573.0000000000001" y2="332" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="565.0000000000001,328 565.0000000000001,336 573.0000000000001,332" fill="#64748b"></polygon><text x="450.00000000000006" y="326" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">validate_files_exist()</text></g><g opacity="0.2"><text x="12" y="384" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.5s</text><line x1="573.0000000000001" y1="380" x2="327" y2="380" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="335,376 335,384 327,380" fill="#64748b"></polygon><text x="450.00000000000006" y="374" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">firmware.bin, firmware.sha256, metadata.json ✓</text><circle cx="342" cy="380" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="432" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.6s</text><line x1="327" y1="428" x2="702.0000000000001" y2="428" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="694.0000000000001,424 694.0000000000001,432 702.0000000000001,428" fill="#64748b"></polygon><text x="514.5" y="422" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">CreateJob (continuous, targeting device group)</text></g><g opacity="0.2"><text x="12" y="480" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.7s</text><line x1="702.0000000000001" y1="476" x2="327" y2="476" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="335,472 335,480 327,476" fill="#64748b"></polygon><text x="514.5" y="470" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Job created: job-vision-ai-v2.0.0</text><circle cx="342" cy="476" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="528" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.8s</text><line x1="315" y1="524" x2="198" y2="524" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="206,520 206,528 198,524" fill="#64748b"></polygon><text x="256.5" y="518" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Success: OTA job created for 5 devices</text></g><g opacity="0.2"><text x="12" y="576" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.9s</text><line x1="186" y1="572" x2="69" y2="572" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="77,568 77,576 69,572" fill="#64748b"></polygon><text x="127.5" y="566" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">"Created OTA job for vision_ai_face_detector..."</text></g><g opacity="0.2"><text x="12" y="624" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1.0s</text><line x1="714.0000000000001" y1="620" x2="831" y2="620" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="823,616 823,624 831,620" fill="#64748b"></polygon><text x="772.5" y="614" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">MQTT: $aws/things/+/jobs/notify</text><text x="772.5" y="632" text-anchor="middle" fill="#64748b" font-size="8" font-style="italic">All devices in group</text></g><g opacity="0.2"><text x="12" y="672" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">2.0s</text><line x1="831" y1="668" x2="585.0000000000001" y2="668" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="593.0000000000001,664 593.0000000000001,672 585.0000000000001,668" fill="#64748b"></polygon><text x="708" y="662" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">GET firmware.bin (pre-signed URL)</text></g><g opacity="0.2"><text x="12" y="720" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.0s</text><line x1="585.0000000000001" y1="716" x2="831" y2="716" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="823,712 823,720 831,716" fill="#64748b"></polygon><text x="708" y="710" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Streaming download (1.6MB)</text><circle cx="816" cy="716" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="768" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">8.0s</text><path d="M 837 764
                        L 872 764
                        L 872 784
                        L 845 784" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="845,780 845,788 837,784" fill="#64748b"></polygon><text x="880" y="770" fill="#94a3b8" font-size="9" font-weight="400">Verify SHA256 → Flash to APP1 → Reboot</text></g><g opacity="0.2"><text x="12" y="816" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">12.0s</text><line x1="831" y1="812" x2="714.0000000000001" y2="812" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="722.0000000000001,808 722.0000000000001,816 714.0000000000001,812" fill="#64748b"></polygon><text x="772.5" y="806" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Shadow update: firmwareVersion = "2.0.0"</text><circle cx="729.0000000000001" cy="812" r="4" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:12px;flex-wrap:wrap;font-size:10px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">User</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">AgentCore</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Strands</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">DynamoDB</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">S3</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#10b981"></div><span style="color:#94a3b8">XIAO</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:8px;height:8px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:8px;height:8px;border-radius:50%;background:#22c55e"></div><span style="color:#94a3b8">Complete</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.15));border:1px solid rgba(34, 197, 94, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#86efac;margin-bottom:4px">Total: <!-- -->15<!-- --> message exchanges across <!-- -->7<!-- --> participants</div><div style="font-size:16px;font-weight:700;color:#22c55e">~12 seconds end-to-end (prompt to firmware flashed)</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="strands-agent-architecture-on-amazon-bedrock-agentcore">Strands Agent Architecture on Amazon Bedrock AgentCore<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#strands-agent-architecture-on-amazon-bedrock-agentcore" class="hash-link" aria-label="Direct link to Strands Agent Architecture on Amazon Bedrock AgentCore" title="Direct link to Strands Agent Architecture on Amazon Bedrock AgentCore">​</a></h2>
<p>This diagram details the internal architecture of the Strands Agent running on Amazon Bedrock AgentCore, showing how the LLM reasons about prompts and orchestrates tool execution.</p>
<p><img decoding="async" loading="lazy" alt="Strands Agent Architecture" src="https://www.chiwaichan.co.nz/assets/images/02-agent-architecture-a0d6e5c6b7d565209c9d11f7fab90f1c.png" width="1494" height="1288" class="img_ev3q"></p>
<p><strong>Components:</strong></p>
<ul>
<li><strong>Amazon Bedrock AgentCore</strong> - Managed runtime that hosts and scales the agent</li>
<li><strong>ECR Container</strong> - Docker image (Python 3.12) containing the Strands Agent code</li>
<li><strong>Amazon Nova 2 Lite</strong> - The LLM that provides reasoning capabilities</li>
<li><strong>Agent Loop</strong> - The core execution cycle: parse prompt → select tool → execute → respond</li>
<li><strong>firmware_updater Tools:</strong>
<ul>
<li><code>push_firmware_update()</code> - Main orchestrator that coordinates the entire OTA process</li>
<li><code>validate_files_exist()</code> - Validates firmware.bin, firmware.sha256, and metadata.json exist in S3</li>
<li><code>create_dynamic_thing_group()</code> - Creates Fleet Indexing queries to target devices by firmware version</li>
</ul>
</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="scalability-architecture">Scalability Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#scalability-architecture" class="hash-link" aria-label="Direct link to Scalability Architecture" title="Direct link to Scalability Architecture">​</a></h2>
<p>This diagram demonstrates how the solution scales effortlessly across multiple device types and large device fleets - implement once and forget.</p>
<p><img decoding="async" loading="lazy" alt="Scalability Architecture" src="https://www.chiwaichan.co.nz/assets/images/03-scalability-architecture-7e158c4ee1cc99aa4f980775bff5b7a1.png" width="2875" height="1379" class="img_ev3q"></p>
<p><strong>Key Scalability Features:</strong></p>
<ul>
<li><strong>Single Agent, Multiple Device Types</strong> - One Strands Agent manages all 26+ device groups without code changes</li>
<li><strong>S3 Folder Convention</strong> - Adding a new device type is as simple as creating a new folder (e.g., <code>firmwares/v1.0.0/new_device_type/</code>)</li>
<li><strong>Auto-Discovery Mapping</strong> - Folder names automatically map to Thing Groups (e.g., <code>vision_ai_face_detector</code> → <code>VisionAIFaceDetectorAWSDevice</code>)</li>
<li><strong>Fleet Indexing Queries</strong> - Dynamically target devices based on current firmware version, no hardcoded device lists</li>
<li><strong>Horizontal Scaling</strong> - Add unlimited devices to any group; IoT Jobs handles distribution automatically</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="firmware-rollback-architecture">Firmware Rollback Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#firmware-rollback-architecture" class="hash-link" aria-label="Direct link to Firmware Rollback Architecture" title="Direct link to Firmware Rollback Architecture">​</a></h2>
<p>This diagram shows how the solution enables rollback to any previous firmware version using a simple prompt, leveraging the dual-partition architecture of the Seeed Studio XIAO ESP32.</p>
<p><img decoding="async" loading="lazy" alt="Firmware Rollback Architecture" src="https://www.chiwaichan.co.nz/assets/images/04-firmware-rollback-4e6cbf64af56d2d713a079880c638327.png" width="1870" height="1305" class="img_ev3q"></p>
<p><strong>Key Rollback Features:</strong></p>
<ul>
<li><strong>Version History in S3</strong> - All firmware versions are retained (v1.0.0, v2.0.0, v3.0.0, etc.) enabling rollback to any point</li>
<li><strong>Dual-Partition Flash Layout</strong> - XIAO ESP32 uses APP0/APP1 partitions for safe ping-pong updates</li>
<li><strong>Persistent Storage</strong> - NVS (WiFi, config) and SPIFFS (certificates) survive firmware updates</li>
<li><strong>SHA256 Validation</strong> - Firmware integrity verified before committing to new partition</li>
<li><strong>Automatic Rollback</strong> - If new firmware fails to boot and connect to MQTT, device automatically reverts to previous partition</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram-1">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#interactive-sequence-diagram-1" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #1a0a2e 0%, #2d1b4e 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">Firmware Rollback Sequence</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">Rollback to any previous firmware version with dual-partition safety</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">◀ Prev</button><button class="rollback-play-pulse" style="background:linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(139, 92, 246, 0.4)">▶ Play</button><button style="background:#8b5cf6;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next ▶</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->17</span></div><div style="min-width:650px;overflow-x:auto"><svg width="100%" height="982" viewBox="0 0 850 982" style="display:block"><g><rect x="20" y="10" width="96" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="68" y="30" text-anchor="middle" fill="#8b5cf6" font-size="11" font-weight="600">User</text><text x="68" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Developer/Operator</text><line x1="68" y1="60" x2="68" y2="962" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="162.8" y="10" width="96" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="210.8" y="30" text-anchor="middle" fill="#22c55e" font-size="11" font-weight="600">Strands</text><text x="210.8" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Strands Agent</text><line x1="210.8" y1="60" x2="210.8" y2="962" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="305.6" y="10" width="96" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="353.6" y="30" text-anchor="middle" fill="#06b6d4" font-size="11" font-weight="600">S3</text><text x="353.6" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">S3 (Version History)</text><line x1="353.6" y1="60" x2="353.6" y2="962" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="448.4000000000001" y="10" width="96" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="496.4000000000001" y="30" text-anchor="middle" fill="#ec4899" font-size="11" font-weight="600">IoT Core</text><text x="496.4000000000001" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">AWS IoT Core</text><line x1="496.4000000000001" y1="60" x2="496.4000000000001" y2="962" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="591.2" y="10" width="96" height="50" rx="6" fill="#10b98130" stroke="#10b981" stroke-width="2"></rect><text x="639.2" y="30" text-anchor="middle" fill="#10b981" font-size="11" font-weight="600">XIAO</text><text x="639.2" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">XIAO ESP32</text><line x1="639.2" y1="60" x2="639.2" y2="962" stroke="#10b981" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="734" y="10" width="96" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="782" y="30" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">Flash</text><text x="782" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Flash Partitions</text><line x1="782" y1="60" x2="782" y2="962" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="12" y="144" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.0s</text><line x1="74" y1="140" x2="204.8" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="196.8,136 196.8,144 204.8,140" fill="#64748b"></polygon><text x="139.4" y="134" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">"Rollback vision_ai_face_detector to v1.0.0"</text><text x="139.4" y="152" text-anchor="middle" fill="#64748b" font-size="8" font-style="italic">Natural language</text></g><g opacity="0.2"><text x="12" y="190" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.2s</text><line x1="216.8" y1="186" x2="347.6" y2="186" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="339.6,182 339.6,190 347.6,186" fill="#64748b"></polygon><text x="282.20000000000005" y="180" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">List versions: v1.0.0, v2.0.0, v3.0.0</text></g><g opacity="0.2"><text x="12" y="236" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.3s</text><line x1="347.6" y1="232" x2="216.8" y2="232" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,228 224.8,236 216.8,232" fill="#64748b"></polygon><text x="282.20000000000005" y="226" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">v1.0.0 exists with firmware.bin + sha256</text><circle cx="231.8" cy="232" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="282" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.5s</text><line x1="216.8" y1="278" x2="490.4000000000001" y2="278" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="482.4000000000001,274 482.4000000000001,282 490.4000000000001,278" fill="#64748b"></polygon><text x="353.6" y="272" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">CreateJob: target v1.0.0 firmware</text></g><g opacity="0.2"><text x="12" y="328" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.6s</text><line x1="490.4000000000001" y1="324" x2="216.8" y2="324" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,320 224.8,328 216.8,324" fill="#64748b"></polygon><text x="353.6" y="318" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Job created: rollback-v1.0.0</text></g><g opacity="0.2"><text x="12" y="374" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.7s</text><line x1="204.8" y1="370" x2="74" y2="370" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="82,366 82,374 74,370" fill="#64748b"></polygon><text x="139.4" y="364" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">"Rollback job created, targeting 5 devices"</text></g><g opacity="0.2"><text x="12" y="420" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1.0s</text><line x1="502.4000000000001" y1="416" x2="633.2" y2="416" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="625.2,412 625.2,420 633.2,416" fill="#64748b"></polygon><text x="567.8000000000001" y="410" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">MQTT: Job notification with v1.0.0 URL</text></g><g opacity="0.2"><text x="12" y="466" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">2.0s</text><line x1="633.2" y1="462" x2="359.6" y2="462" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="367.6,458 367.6,466 359.6,462" fill="#64748b"></polygon><text x="496.40000000000003" y="456" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">GET v1.0.0/firmware.bin</text></g><g opacity="0.2"><text x="12" y="512" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.0s</text><line x1="359.6" y1="508" x2="633.2" y2="508" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="625.2,504 625.2,512 633.2,508" fill="#64748b"></polygon><text x="496.40000000000003" y="502" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Stream v1.0.0 firmware (1.6MB)</text><circle cx="618.2" cy="508" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="558" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">6.0s</text><line x1="645.2" y1="554" x2="776" y2="554" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="768,550 768,558 776,554" fill="#64748b"></polygon><text x="710.6" y="548" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Write to APP1 partition (inactive)</text></g><g opacity="0.2"><text x="12" y="604" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">7.0s</text><line x1="776" y1="600" x2="645.2" y2="600" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="653.2,596 653.2,604 645.2,600" fill="#64748b"></polygon><text x="710.6" y="594" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">APP1 written, SHA256 verified</text><circle cx="660.2" cy="600" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="650" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">7.5s</text><line x1="645.2" y1="646" x2="776" y2="646" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="768,642 768,650 776,646" fill="#64748b"></polygon><text x="710.6" y="640" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Set boot partition: APP1</text></g><g opacity="0.2"><text x="12" y="696" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">8.0s</text><path d="M 639.2 692 L 674.2 692 L 674.2 712 L 647.2 712" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="647.2,708 647.2,716 639.2,712" fill="#64748b"></polygon><text x="682.2" y="698" fill="#94a3b8" font-size="9" font-weight="400">ESP.restart() → Reboot</text></g><g opacity="0.2"><text x="12" y="742" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">10.0s</text><line x1="776" y1="738" x2="645.2" y2="738" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="653.2,734 653.2,742 645.2,738" fill="#64748b"></polygon><text x="710.6" y="732" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Boot from APP1 (v1.0.0)</text><circle cx="660.2" cy="738" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="788" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">12.0s</text><line x1="633.2" y1="784" x2="502.4000000000001" y2="784" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="510.4000000000001,780 510.4000000000001,788 502.4000000000001,784" fill="#64748b"></polygon><text x="567.8000000000001" y="778" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">MQTT Connect successful</text></g><g opacity="0.2"><text x="12" y="834" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">12.5s</text><line x1="645.2" y1="830" x2="776" y2="830" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="768,826 768,834 776,830" fill="#64748b"></polygon><text x="710.6" y="824" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Mark APP1 as valid (ota_mark_valid)</text><circle cx="761" cy="830" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="880" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">13.0s</text><line x1="633.2" y1="876" x2="502.4000000000001" y2="876" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="510.4000000000001,872 510.4000000000001,880 502.4000000000001,876" fill="#64748b"></polygon><text x="567.8000000000001" y="870" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Shadow: firmwareVersion = "1.0.0"</text><circle cx="517.4000000000001" cy="876" r="4" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:12px;flex-wrap:wrap;font-size:10px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">User</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Strands</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">S3</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">IoT Core</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#10b981"></div><span style="color:#94a3b8">XIAO</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">Flash</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(124, 58, 237, 0.15));border:1px solid rgba(139, 92, 246, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#c4b5fd;margin-bottom:4px">Dual-partition safety: APP0 preserved during rollback to APP1</div><div style="font-size:16px;font-weight:700;color:#a78bfa">~13 seconds to rollback (with auto-recovery on failure)</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="multi-interface-access-architecture">Multi-Interface Access Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#multi-interface-access-architecture" class="hash-link" aria-label="Direct link to Multi-Interface Access Architecture" title="Direct link to Multi-Interface Access Architecture">​</a></h2>
<p>This diagram demonstrates how the Strands Agent can be accessed through multiple interfaces with different authentication methods - enabling developers to use their preferred tools while operators can use a web-based chatbot.</p>
<p><img decoding="async" loading="lazy" alt="Multi-Interface Access Architecture" src="https://www.chiwaichan.co.nz/assets/images/05-multi-interface-access-4410fd5a950a214c934e80878d43213f.png" width="1652" height="1424" class="img_ev3q"></p>
<p><strong>Interface Options:</strong></p>
<ul>
<li><strong>MCP Clients (Developer Tools)</strong> - Claude Code and Kiro CLI connect via Model Context Protocol to a <strong>Streaming AgentCore Runtime</strong> using JWT/Cognito authentication</li>
<li><strong>Chatbot (Web UI)</strong> - AWS Amplify React app with FirmwareAssistant component connects via Lambda proxy to an <strong>IAM AgentCore Runtime</strong> using SigV4 authentication for service-to-service communication</li>
<li><strong>Two Runtimes, Same Agent Logic</strong> - Both runtimes run the same Strands Agent code but are deployed separately with different authentication methods suited to their use cases</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="firmware-ai-assistant-chatbot">Firmware AI Assistant Chatbot<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#firmware-ai-assistant-chatbot" class="hash-link" aria-label="Direct link to Firmware AI Assistant Chatbot" title="Direct link to Firmware AI Assistant Chatbot">​</a></h3>
<p>The chatbot interface in an Amplify React App provides a conversational way to manage firmware updates. In this example, the assistant lists all available firmware versions across device groups, and then creates an OTA job to update the <code>pet_feeder</code> device group to the latest firmware version.</p>
<p><img decoding="async" loading="lazy" alt="Firmware AI Assistant Chatbot" src="https://www.chiwaichan.co.nz/assets/images/chatbot-77cb1c10285bbe58c769ec3bcba8b125.png" width="2444" height="1588" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="authentication-architecture">Authentication Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#authentication-architecture" class="hash-link" aria-label="Direct link to Authentication Architecture" title="Direct link to Authentication Architecture">​</a></h2>
<p>This diagram illustrates the multi-layer security model ensuring that all access to the firmware management system is properly authenticated. Each interface uses a different authentication method suited to its use case.</p>
<p><img decoding="async" loading="lazy" alt="Authentication Architecture" src="https://www.chiwaichan.co.nz/assets/images/06-authentication-1a094dc27c7faab08c09d94f13433227.png" width="952" height="2636" class="img_ev3q"></p>
<p><strong>Authentication Layers:</strong></p>
<ul>
<li><strong>Cognito JWT (MCP Path)</strong> - Developers using Claude Code and Kiro CLI authenticate via Amazon Cognito User Pool and receive JWT tokens, connecting to the Streaming AgentCore Runtime</li>
<li><strong>IAM SigV4 (Chatbot Path)</strong> - The Lambda proxy authenticates using AWS IAM roles with SigV4 request signing for service-to-service communication with the IAM AgentCore Runtime</li>
<li><strong>X.509 Certificates (Device Path)</strong> - XIAO ESP32 devices authenticate to AWS IoT Core using TLS 1.2 mutual authentication with per-device certificates</li>
<li><strong>Certificate Chain</strong> - Amazon Root CA validates device certificates stored in SPIFFS (survives firmware updates)</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="serverless-architecture-overview">Serverless Architecture Overview<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#serverless-architecture-overview" class="hash-link" aria-label="Direct link to Serverless Architecture Overview" title="Direct link to Serverless Architecture Overview">​</a></h2>
<p>This diagram provides a comprehensive view of all AWS services used in the solution - every component is fully serverless with no EC2 instances to manage.</p>
<p><img decoding="async" loading="lazy" alt="Serverless Architecture Overview" src="https://www.chiwaichan.co.nz/assets/images/07-serverless-architecture-615696c8873d266d2c53d5b960ebf2c7.png" width="1467" height="2297" class="img_ev3q"></p>
<p><strong>Serverless Components:</strong></p>
<ul>
<li><strong>Frontend</strong> - AWS Amplify Hosting, AppSync GraphQL, Cognito User Pool</li>
<li><strong>Compute</strong> - Amazon Bedrock AgentCore, Lambda Functions, EventBridge Rules</li>
<li><strong>Storage</strong> - S3 Firmware Bucket, DynamoDB Config Table</li>
<li><strong>IoT</strong> - IoT Core, IoT Jobs, Device Shadows, Fleet Indexing</li>
<li><strong>Monitoring</strong> - CloudWatch Logs &amp; Alarms, SNS Notifications</li>
<li><strong>CI/CD</strong> - CodeBuild (ARM64), ECR Container Registry</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="firmware-integrity-verification-sha256">Firmware Integrity Verification (SHA256)<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#firmware-integrity-verification-sha256" class="hash-link" aria-label="Direct link to Firmware Integrity Verification (SHA256)" title="Direct link to Firmware Integrity Verification (SHA256)">​</a></h2>
<p>This diagram shows the firmware integrity verification process that ensures firmware hasn't been corrupted during download before flashing to the device.</p>
<p><img decoding="async" loading="lazy" alt="Firmware Integrity Verification" src="https://www.chiwaichan.co.nz/assets/images/08-sha256-integrity-7dbfd4813b79349ea2f08561e2a32ee5.png" width="1592" height="970" class="img_ev3q"></p>
<p><strong>Verification Flow:</strong></p>
<ol>
<li><strong>Download</strong> - XIAO ESP32 streams firmware.bin from S3 in chunks</li>
<li><strong>Calculate</strong> - SHA256 hash is calculated progressively during download (streaming hash)</li>
<li><strong>Compare</strong> - Calculated hash is compared against expected hash from firmware.sha256 file</li>
<li><strong>Flash Decision</strong> - Match: proceed to flash APP1 partition | Mismatch: abort OTA and report failure</li>
</ol>
<p><strong>Benefits:</strong></p>
<ul>
<li>Detects corruption during download (network issues, incomplete transfers)</li>
<li>Prevents flashing of tampered firmware</li>
<li>Memory-efficient streaming verification (no need to store entire firmware before hashing)</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram-2">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#interactive-sequence-diagram-2" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0a1628 0%, #1e3a5f 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">SHA256 Integrity Verification Sequence</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">Streaming hash verification during firmware download</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">◀ Prev</button><button class="sha256-play-pulse" style="background:linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(6, 182, 212, 0.4)">▶ Play</button><button style="background:#06b6d4;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next ▶</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->15</span></div><div style="min-width:600px;overflow-x:auto"><svg width="100%" height="890" viewBox="0 0 750 890" style="display:block"><g><rect x="30" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="75" y="30" text-anchor="middle" fill="#ec4899" font-size="11" font-weight="600">IoT Job</text><text x="75" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">AWS IoT Job</text><line x1="75" y1="60" x2="75" y2="870" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="180" y="10" width="90" height="50" rx="6" fill="#10b98130" stroke="#10b981" stroke-width="2"></rect><text x="225" y="30" text-anchor="middle" fill="#10b981" font-size="11" font-weight="600">XIAO</text><text x="225" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">XIAO ESP32</text><line x1="225" y1="60" x2="225" y2="870" stroke="#10b981" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="330" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="375" y="30" text-anchor="middle" fill="#06b6d4" font-size="11" font-weight="600">S3</text><text x="375" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">S3 Bucket</text><line x1="375" y1="60" x2="375" y2="870" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="480" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="525" y="30" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">OTA Mgr</text><text x="525" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">OTA Manager</text><line x1="525" y1="60" x2="525" y2="870" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="630" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="675" y="30" text-anchor="middle" fill="#8b5cf6" font-size="11" font-weight="600">Flash</text><text x="675" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Flash Memory</text><line x1="675" y1="60" x2="675" y2="870" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="12" y="144" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.0s</text><line x1="81" y1="140" x2="219" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="211,136 211,144 219,140" fill="#64748b"></polygon><text x="150" y="134" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Job document with firmware URL + expected SHA256</text></g><g opacity="0.2"><text x="12" y="190" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.1s</text><line x1="231" y1="186" x2="519" y2="186" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="511,182 511,190 519,186" fill="#64748b"></polygon><text x="375" y="180" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Start OTA: updateFromURLWithChecksum(url, sha256)</text></g><g opacity="0.2"><text x="12" y="236" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.2s</text><line x1="519" y1="232" x2="381" y2="232" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="389,228 389,236 381,232" fill="#64748b"></polygon><text x="450" y="226" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">HTTP GET firmware.bin (Content-Length: 1.6MB)</text></g><g opacity="0.2"><text x="12" y="282" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.5s</text><line x1="381" y1="278" x2="519" y2="278" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="511,274 511,282 519,278" fill="#64748b"></polygon><text x="450" y="272" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Stream chunk 1 (1KB)</text></g><g opacity="0.2"><text x="12" y="328" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.6s</text><path d="M 525 324 L 560 324 L 560 344 L 533 344" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="533,340 533,348 525,344" fill="#64748b"></polygon><text x="568" y="330" fill="#94a3b8" font-size="9" font-weight="400">SHA256.update(chunk1) → Running hash</text></g><g opacity="0.2"><text x="12" y="374" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.7s</text><line x1="531" y1="370" x2="669" y2="370" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="661,366 661,374 669,370" fill="#64748b"></polygon><text x="600" y="364" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Write chunk 1 to APP1 partition</text></g><g opacity="0.2"><text x="12" y="420" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.0s</text><line x1="381" y1="416" x2="519" y2="416" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="511,412 511,420 519,416" fill="#64748b"></polygon><text x="450" y="410" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Stream chunks 2...1600 (1KB each)</text><text x="450" y="428" text-anchor="middle" fill="#64748b" font-size="8" font-style="italic">~1600 iterations</text></g><g opacity="0.2"><text x="12" y="466" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.5s</text><path d="M 525 462 L 560 462 L 560 482 L 533 482" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="533,478 533,486 525,482" fill="#64748b"></polygon><text x="568" y="468" fill="#94a3b8" font-size="9" font-weight="400">SHA256.update(chunks) → Accumulating hash</text><circle cx="742" cy="464" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="512" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">4.0s</text><line x1="531" y1="508" x2="669" y2="508" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="661,504 661,512 669,508" fill="#64748b"></polygon><text x="600" y="502" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Write remaining chunks to APP1</text></g><g opacity="0.2"><text x="12" y="558" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.0s</text><line x1="381" y1="554" x2="519" y2="554" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="511,550 511,558 519,554" fill="#64748b"></polygon><text x="450" y="548" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">Download complete (EOF)</text><circle cx="504" cy="554" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="604" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.1s</text><path d="M 525 600 L 560 600 L 560 620 L 533 620" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="533,616 533,624 525,620" fill="#64748b"></polygon><text x="568" y="606" fill="#94a3b8" font-size="9" font-weight="400">SHA256.finalize() → Calculated hash</text></g><g opacity="0.2"><text x="12" y="650" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.2s</text><path d="M 525 646 L 560 646 L 560 666 L 533 666" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="533,662 533,670 525,666" fill="#64748b"></polygon><text x="568" y="652" fill="#94a3b8" font-size="9" font-weight="400">Compare: calculated == expected ?</text><circle cx="710" cy="648" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="696" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.3s</text><line x1="531" y1="692" x2="669" y2="692" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="661,688 661,696 669,692" fill="#64748b"></polygon><text x="600" y="686" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">✓ MATCH: Commit APP1, set boot partition</text><text x="600" y="704" text-anchor="middle" fill="#64748b" font-size="8" font-style="italic">Safe to flash!</text></g><g opacity="0.2"><text x="12" y="742" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.5s</text><line x1="669" y1="738" x2="531" y2="738" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="539,734 539,742 531,738" fill="#64748b"></polygon><text x="600" y="732" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">APP1 committed successfully</text><circle cx="546" cy="738" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="788" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.6s</text><line x1="519" y1="784" x2="231" y2="784" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="239,780 239,788 231,784" fill="#64748b"></polygon><text x="375" y="778" text-anchor="middle" fill="#94a3b8" font-size="9" font-weight="400">OTA_SUCCESS: Ready to reboot</text><circle cx="246" cy="784" r="4" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:12px;flex-wrap:wrap;font-size:10px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">IoT Job</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#10b981"></div><span style="color:#94a3b8">XIAO</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">S3</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">OTA Mgr</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">Flash</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(6, 182, 212, 0.15), rgba(8, 145, 178, 0.15));border:1px solid rgba(6, 182, 212, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#67e8f9;margin-bottom:4px">Memory-efficient: Hash calculated during download, not after</div><div style="font-size:16px;font-weight:700;color:#22d3ee">~5.6 seconds (download + verify + commit)</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="safe-rollout-with-rate-limiting--abort-thresholds">Safe Rollout with Rate Limiting &amp; Abort Thresholds<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#safe-rollout-with-rate-limiting--abort-thresholds" class="hash-link" aria-label="Direct link to Safe Rollout with Rate Limiting &amp; Abort Thresholds" title="Direct link to Safe Rollout with Rate Limiting &amp; Abort Thresholds">​</a></h2>
<p>This diagram illustrates the safety mechanisms that prevent fleet-wide failures during OTA updates by controlling rollout speed and automatically aborting when issues are detected.</p>
<p><img decoding="async" loading="lazy" alt="Safe Rollout with Rate Limiting" src="https://www.chiwaichan.co.nz/assets/images/09-safe-rollout-1c4b0d2448c1114e0c1c00889efdffd5.png" width="1840" height="1772" class="img_ev3q"></p>
<p><strong>Safety Mechanisms:</strong></p>
<ul>
<li><strong>Rate Limiting</strong> - Updates are deployed to a maximum of 10 devices concurrently, preventing network congestion and allowing monitoring</li>
<li><strong>Abort Thresholds</strong> - Job automatically cancels if failure rate exceeds 5% or more than 10 absolute failures occur</li>
<li><strong>Batch Processing</strong> - Fleet of 100 devices is updated in batches, with completed, in-progress, and pending states tracked</li>
<li><strong>Failure Monitoring</strong> - Real-time tracking of success/failure status feeds into abort decision logic</li>
<li><strong>Auto-Cancel</strong> - When threshold is exceeded, all pending device updates are automatically cancelled</li>
<li><strong>SNS Alerts</strong> - Operators are immediately notified when an OTA rollout is aborted</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram-3">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#interactive-sequence-diagram-3" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #1a0a0a 0%, #3d1515 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">Safe Rollout with Abort Threshold</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">Rate-limited deployment with automatic abort on failure threshold</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">◀ Prev</button><button class="safe-rollout-play-pulse" style="background:linear-gradient(135deg, #ef4444 0%, #dc2626 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(239, 68, 68, 0.4)">▶ Play</button><button style="background:#ef4444;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next ▶</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->16</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="936" viewBox="0 0 850 936" style="display:block"><g><rect x="23" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="68" y="30" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">Strands</text><text x="68" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">Strands Agent</text><line x1="68" y1="60" x2="68" y2="916" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="165.8" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="210.8" y="30" text-anchor="middle" fill="#ec4899" font-size="10" font-weight="600">IoT Jobs</text><text x="210.8" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">AWS IoT Jobs</text><line x1="210.8" y1="60" x2="210.8" y2="916" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="308.6" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="353.6" y="30" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600">Batch 1</text><text x="353.6" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">Batch 1 (10 devices)</text><line x1="353.6" y1="60" x2="353.6" y2="916" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="451.4000000000001" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="496.4000000000001" y="30" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">Batch 2</text><text x="496.4000000000001" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">Batch 2 (10 devices)</text><line x1="496.4000000000001" y1="60" x2="496.4000000000001" y2="916" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="594.2" y="10" width="90" height="50" rx="6" fill="#ef444430" stroke="#ef4444" stroke-width="2"></rect><text x="639.2" y="30" text-anchor="middle" fill="#ef4444" font-size="10" font-weight="600">Monitor</text><text x="639.2" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">Failure Monitor</text><line x1="639.2" y1="60" x2="639.2" y2="916" stroke="#ef4444" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="737" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="782" y="30" text-anchor="middle" fill="#8b5cf6" font-size="10" font-weight="600">SNS</text><text x="782" y="46" text-anchor="middle" fill="#94a3b8" font-size="7">SNS Alerts</text><line x1="782" y1="60" x2="782" y2="916" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="10" y="144" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.0s</text><line x1="74" y1="140" x2="204.8" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="196.8,136 196.8,144 204.8,140" fill="#64748b"></polygon><text x="139.4" y="134" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">CreateJob: maxConcurrent=10, abortThreshol...</text><text x="139.4" y="151" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Rate limiting config</text></g><g opacity="0.2"><text x="10" y="190" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.1s</text><line x1="204.8" y1="186" x2="74" y2="186" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="82,182 82,190 74,186" fill="#64748b"></polygon><text x="139.4" y="180" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Job created: 100 devices in queue</text><circle cx="86" cy="186" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="236" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0.5s</text><line x1="216.8" y1="232" x2="347.6" y2="232" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="339.6,228 339.6,236 347.6,232" fill="#64748b"></polygon><text x="282.20000000000005" y="226" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Deploy to Batch 1 (devices 1-10)</text><text x="282.20000000000005" y="243" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">First 10 devices</text></g><g opacity="0.2"><text x="10" y="282" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">15.0s</text><line x1="347.6" y1="278" x2="216.8" y2="278" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,274 224.8,282 216.8,278" fill="#64748b"></polygon><text x="282.20000000000005" y="272" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Device 1-8: SUCCESS</text></g><g opacity="0.2"><text x="10" y="328" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">18.0s</text><line x1="347.6" y1="324" x2="216.8" y2="324" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,320 224.8,328 216.8,324" fill="#64748b"></polygon><text x="282.20000000000005" y="318" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Device 9-10: SUCCESS</text><circle cx="228.8" cy="324" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="374" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">18.5s</text><line x1="216.8" y1="370" x2="633.2" y2="370" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="625.2,366 625.2,374 633.2,370" fill="#64748b"></polygon><text x="425" y="364" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Batch 1 complete: 10/10 success (0% failure)</text></g><g opacity="0.2"><text x="10" y="420" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">18.6s</text><line x1="633.2" y1="416" x2="216.8" y2="416" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,412 224.8,420 216.8,416" fill="#64748b"></polygon><text x="425" y="410" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">✓ Below threshold, continue rollout</text></g><g opacity="0.2"><text x="10" y="466" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">19.0s</text><line x1="216.8" y1="462" x2="490.4000000000001" y2="462" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="482.4000000000001,458 482.4000000000001,466 490.4000000000001,462" fill="#64748b"></polygon><text x="353.6" y="456" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Deploy to Batch 2 (devices 11-20)</text></g><g opacity="0.2"><text x="10" y="512" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">32.0s</text><line x1="490.4000000000001" y1="508" x2="216.8" y2="508" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,504 224.8,512 216.8,508" fill="#64748b"></polygon><text x="353.6" y="502" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Device 11-14: SUCCESS</text></g><g opacity="0.2"><text x="10" y="558" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">45.0s</text><line x1="490.4000000000001" y1="554" x2="216.8" y2="554" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="224.8,550 224.8,558 216.8,554" fill="#64748b"></polygon><text x="353.6" y="548" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Device 15-17: FAILED (network timeout)</text><text x="353.6" y="565" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">3 failures!</text></g><g opacity="0.2"><text x="10" y="604" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">45.5s</text><line x1="216.8" y1="600" x2="633.2" y2="600" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="625.2,596 625.2,604 633.2,600" fill="#64748b"></polygon><text x="425" y="594" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Running total: 13 success, 3 failed (18.75%)</text><circle cx="621.2" cy="600" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="650" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">45.6s</text><path d="M 639.2 646 L 671.2 646 L 671.2 664 L 647.2 664" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="647.2,660 647.2,668 639.2,664" fill="#64748b"></polygon><text x="677.2" y="652" fill="#94a3b8" font-size="8" font-weight="400">Check: 18.75% &gt; 5% threshold</text></g><g opacity="0.2"><text x="10" y="696" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">45.7s</text><line x1="633.2" y1="692" x2="216.8" y2="692" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="224.8,688 224.8,696 216.8,692" fill="#64748b"></polygon><text x="425" y="686" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">⚠️ ABORT: Failure threshold exceeded!</text><text x="425" y="703" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Stop rollout</text></g><g opacity="0.2"><text x="10" y="742" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">46.0s</text><path d="M 210.8 738 L 242.8 738 L 242.8 756 L 218.8 756" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="218.8,752 218.8,760 210.8,756" fill="#64748b"></polygon><text x="248.8" y="744" fill="#94a3b8" font-size="8" font-weight="400">Cancel pending jobs (devices 18-100)</text><circle cx="400.8" cy="740" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="788" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">46.5s</text><line x1="216.8" y1="784" x2="776" y2="784" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="768,780 768,788 776,784" fill="#64748b"></polygon><text x="496.4" y="778" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Publish abort notification</text></g><g opacity="0.2"><text x="10" y="834" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">47.0s</text><path d="M 782 830 L 814 830 L 814 848 L 790 848" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="790,844 790,852 782,848" fill="#64748b"></polygon><text x="820" y="836" fill="#94a3b8" font-size="8" font-weight="400">Email/SMS: "OTA aborted: 3 failures i...</text><circle cx="988" cy="832" r="4" fill="#22c55e"></circle></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;font-size:9px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Strands</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">IoT Jobs</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Batch 1</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">Batch 2</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ef4444"></div><span style="color:#94a3b8">Monitor</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">SNS</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.15));border:1px solid rgba(239, 68, 68, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#fca5a5;margin-bottom:4px">Prevented 84 additional devices from receiving bad firmware</div><div style="font-size:16px;font-weight:700;color:#f87171">Fleet-wide failure avoided by abort threshold</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="source-code">Source Code<a href="https://www.chiwaichan.co.nz/blog/2026/03/03/using-aws-agentcore-strands-agents-over-the-air-firmware-update-seeed-studio-xiao-esp32#source-code" class="hash-link" aria-label="Direct link to Source Code" title="Direct link to Source Code">​</a></h2>
<p>The source code for this project is available on GitHub:</p>
<ul>
<li><strong><a href="https://github.com/chiwaichan/cdk-iot-firmware-manager" target="_blank" rel="noopener noreferrer">cdk-iot-firmware-manager</a></strong> - AWS CDK infrastructure, Strands Agent code, and Lambda functions for firmware management</li>
</ul>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p>This repository is not yet open sourced. It will be made public in a future update.</p></div></div>]]></content:encoded>
            <category>AWS</category>
            <category>IoT</category>
            <category>ota</category>
            <category>firmware</category>
            <category>agentcore</category>
            <category>Strands Agents</category>
            <category>Bedrock</category>
            <category>esp32s3</category>
            <category>xiao</category>
            <category>Seeed Studio</category>
            <category>Serverless</category>
        </item>
        <item>
            <title><![CDATA[UART Communication Between Seeed Studio XIAO ESP32C6 and NVIDIA Jetson Nano Super]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/02/28/uart-xiao-esp32c6-nvidia-jetson-nano-super</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/02/28/uart-xiao-esp32c6-nvidia-jetson-nano-super</guid>
            <pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Sending messages from a Seeed Studio XIAO ESP32C6 to an NVIDIA Jetson Nano Super over UART for fast, single digit millisecond sensor data transfer]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="Jetson Nano Super and XIAO ESP32C6 full setup" src="https://www.chiwaichan.co.nz/assets/images/jetson-xiao-full-setup-3587469c97eb27185dbe920e0eed1b0d.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="XIAO ESP32C6 wiring close-up" src="https://www.chiwaichan.co.nz/assets/images/xiao-wiring-closeup-a24e0ffc7360c9c5b6f9c97c7d0db78d.jpeg" width="1500" height="2000" class="img_ev3q"></p>
<p>I want to use the Jetson Nano to leverage any sensor readings captured by the ESP32C6 and use it for inferences downstream. In the past I would have tried to send the messages between the devices via AWS IoT Core, but over the wires using UART it is definitely much faster - single digit milliseconds over UART.</p>
<p>Here is the source code to use as a building block to enable a Seeed Studio XIAO ESP32C6 to send messages to a NVIDIA Jetson Nano Super over the UART protocol; uni-direction. The XIAO code is a PlatformIO project and the Jetson Nano Super is a Python script.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="source-code">Source Code<a href="https://www.chiwaichan.co.nz/blog/2026/02/28/uart-xiao-esp32c6-nvidia-jetson-nano-super#source-code" class="hash-link" aria-label="Direct link to Source Code" title="Direct link to Source Code">​</a></h2>
<p>You can find the source code for this project here: <a href="https://github.com/chiwaichan/nvidia-jetson-nano-super-seeed-studio-xiao-esp32c6-uart" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/nvidia-jetson-nano-super-seeed-studio-xiao-esp32c6-uart</a></p>]]></content:encoded>
            <category>IoT</category>
            <category>uart</category>
            <category>xiao</category>
            <category>esp32c6</category>
            <category>Seeed Studio</category>
            <category>NVIDIA</category>
            <category>Jetson</category>
            <category>jetson-nano-super</category>
            <category>platformio</category>
            <category>Python</category>
            <category>Micro Controllers</category>
            <category>communication</category>
        </item>
        <item>
            <title><![CDATA[I Got Me One of These - NVIDIA Jetson AGX Thor Developer Kit]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/01/24/nvidia-jetson-agx-thor-developer-kit</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/01/24/nvidia-jetson-agx-thor-developer-kit</guid>
            <pubDate>Sat, 24 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Unboxing my new NVIDIA Jetson AGX Thor Developer Kit]]></description>
            <content:encoded><![CDATA[<p>I got me one of these!</p>
<p><img decoding="async" loading="lazy" alt="NVIDIA Jetson AGX Thor Developer Kit" src="https://www.chiwaichan.co.nz/assets/images/jetson-agx-thor-box-d569901bf489879f8d3678086ee89367.jpeg" width="1600" height="1200" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Jetson AGX Thor Unit" src="https://www.chiwaichan.co.nz/assets/images/jetson-agx-thor-unit-8676266d76502ec18df28cf55146a80c.jpeg" width="1824" height="1026" class="img_ev3q"></p>
<p>This thing is a beast and I am keen to see what kind of AI robotics experiments I can create from this.</p>]]></content:encoded>
            <category>NVIDIA</category>
            <category>Jetson</category>
            <category>agx-thor</category>
            <category>Robotics</category>
            <category>ai</category>
            <category>Edge Computing</category>
        </item>
        <item>
            <title><![CDATA[Real-Time Voice Chat with Amazon Nova Sonic using React and AWS Amplify Gen 2]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify</guid>
            <pubDate>Mon, 19 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Architecture for a serverless LLM-based voice chatbot using Amazon Nova 2 Sonic speech-to-speech model with direct browser-to-Bedrock bidirectional streaming]]></description>
            <content:encoded><![CDATA[<p>These days I am often creating small generic re-usable building blocks that I can pontentially use across new or existing projects, in this blog I talk about the architecture for a LLM based voice chatbot in a web browser built entirely as a serverless based solution.</p>
<p>The key component of this solution is using Amazon Nova 2 Sonic, a speech-to-speech foundation model that can understand spoken audio directly and generate voice responses - all through a single bidirectional stream from the browser directly to Amazon Bedrock, with no backend servers required - no EC2 instances and no Containers.</p>
<h1>Goals</h1>
<ul>
<li>Enable real-time voice-to-voice conversations with AI using Amazon Nova 2 Sonic</li>
<li>Direct browser-to-Bedrock communication using bidirectional streaming - no Lambda functions or API Gateway required</li>
<li>Use AWS Amplify Gen 2 for infrastructure-as-code backend definition in TypeScript</li>
<li>Implement secure authentication using Cognito User Pool and Identity Pool for temporary AWS credentials</li>
<li>Handle real-time audio capture, processing, and playback entirely in the browser</li>
<li>Must be a completely serverless solution with automatic scaling</li>
<li>Support click-to-talk interaction model for intuitive user experience</li>
<li>Display live transcripts of both user speech and AI responses</li>
</ul>
<h1>Architecture</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="end-to-end-voice-chat-flow">End-to-End Voice Chat Flow<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#end-to-end-voice-chat-flow" class="hash-link" aria-label="Direct link to End-to-End Voice Chat Flow" title="Direct link to End-to-End Voice Chat Flow">​</a></h2>
<p>This diagram illustrates the complete flow from a user speaking into their microphone to hearing the AI assistant's voice response.</p>
<p><img decoding="async" loading="lazy" alt="End-to-End Voice Chat Flow" src="https://www.chiwaichan.co.nz/assets/images/01-end-to-end-voice-chat-flow-d28058285dea17aee7f19ba5d6679836.png" width="1992" height="2164" class="img_ev3q"></p>
<p><strong>Flow Steps:</strong></p>
<ol>
<li><strong>User Speaks</strong> - User clicks the microphone button and speaks naturally</li>
<li><strong>Audio Capture</strong> - Browser captures audio via Web Audio API at 48kHz</li>
<li><strong>Authentication</strong> - React app authenticates with Cognito User Pool</li>
<li><strong>Token Exchange</strong> - JWT tokens exchanged for Identity Pool credentials</li>
<li><strong>AWS Credentials</strong> - Temporary AWS credentials (access key, secret, session token) returned</li>
<li><strong>Bidirectional Stream</strong> - Audio streamed to Bedrock via <code>InvokeModelWithBidirectionalStream</code></li>
<li><strong>Voice Response</strong> - Nova Sonic processes speech and returns synthesized voice response</li>
<li><strong>Audio Playback</strong> - Response audio decoded and played through speakers</li>
<li><strong>User Hears</strong> - User hears the AI assistant's natural voice response</li>
</ol>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#interactive-sequence-diagram" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">Voice Chat Sequence Flow</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">From user speech to AI voice response via Amazon Nova 2 Sonic</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="voice-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->21</span></div><div style="min-width:600px;overflow-x:auto"><svg width="100%" height="1145" viewBox="0 0 800 1145" style="display:block"><g><rect x="19" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="64" y="30" text-anchor="middle" fill="#8b5cf6" font-size="11" font-weight="600">User</text><text x="64" y="46" text-anchor="middle" fill="#94a3b8" font-size="8"></text><line x1="64" y1="60" x2="64" y2="1125" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="186.99999999999997" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="231.99999999999997" y="30" text-anchor="middle" fill="#3b82f6" font-size="11" font-weight="600">Browser</text><text x="231.99999999999997" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">React App</text><line x1="231.99999999999997" y1="60" x2="231.99999999999997" y2="1125" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="355" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="400" y="30" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">Cognito</text><text x="400" y="46" text-anchor="middle" fill="#94a3b8" font-size="8"></text><line x1="400" y1="60" x2="400" y2="1125" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="523" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="568" y="30" text-anchor="middle" fill="#22c55e" font-size="11" font-weight="600">Bedrock</text><text x="568" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Amazon Bedrock</text><line x1="568" y1="60" x2="568" y2="1125" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="691" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="736" y="30" text-anchor="middle" fill="#ec4899" font-size="11" font-weight="600">Nova</text><text x="736" y="46" text-anchor="middle" fill="#94a3b8" font-size="8">Nova 2 Sonic</text><line x1="736" y1="60" x2="736" y2="1125" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="12" y="144" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.0s</text><line x1="70" y1="140" x2="225.99999999999997" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="217.99999999999997,136 217.99999999999997,144 225.99999999999997,140" fill="#64748b"></polygon><text x="148" y="134" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Click microphone button</text><text x="148" y="152" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">User interaction</text></g><g opacity="0.2"><text x="12" y="189" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.1s</text><line x1="237.99999999999997" y1="185" x2="394" y2="185" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="386,181 386,189 394,185" fill="#64748b"></polygon><text x="316" y="179" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">fetchAuthSession()</text></g><g opacity="0.2"><text x="12" y="234" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.2s</text><line x1="394" y1="230" x2="237.99999999999997" y2="230" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="245.99999999999997,226 245.99999999999997,234 237.99999999999997,230" fill="#64748b"></polygon><text x="316" y="224" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Temporary AWS credentials</text><circle cx="252.99999999999997" cy="230" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="279" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.3s</text><line x1="237.99999999999997" y1="275" x2="562" y2="275" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="554,271 554,279 562,275" fill="#64748b"></polygon><text x="400" y="269" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">InvokeModelWithBidirectionalStream</text><text x="400" y="287" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">HTTP/2 streaming</text></g><g opacity="0.2"><text x="12" y="324" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.4s</text><line x1="237.99999999999997" y1="320" x2="562" y2="320" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="554,316 554,324 562,320" fill="#64748b"></polygon><text x="400" y="314" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">sessionStart + promptStart + contentStart</text></g><g opacity="0.2"><text x="12" y="369" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">0.5s</text><line x1="574" y1="365" x2="730" y2="365" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="722,361 722,369 730,365" fill="#64748b"></polygon><text x="652" y="359" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Initialize speech-to-speech model</text></g><g opacity="0.2"><text x="12" y="414" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1.0s</text><line x1="70" y1="410" x2="225.99999999999997" y2="410" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="217.99999999999997,406 217.99999999999997,414 225.99999999999997,410" fill="#64748b"></polygon><text x="148" y="404" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Speak into microphone</text><text x="148" y="422" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Audio capture</text></g><g opacity="0.2"><text x="12" y="459" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1.1s</text><path d="M 231.99999999999997 455
                        L 262 455
                        L 262 473
                        L 239.99999999999997 473" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="239.99999999999997,469 239.99999999999997,477 231.99999999999997,473" fill="#64748b"></polygon><text x="270" y="461" fill="#94a3b8" font-size="8" font-weight="400">AudioWorklet: capture → resample → PCM → B...</text></g><g opacity="0.2"><text x="12" y="504" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">1.2s</text><line x1="237.99999999999997" y1="500" x2="562" y2="500" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="554,496 554,504 562,500" fill="#64748b"></polygon><text x="400" y="494" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">audioInput events (streaming chunks)</text><text x="400" y="512" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Real-time</text></g><g opacity="0.2"><text x="12" y="549" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.0s</text><line x1="70" y1="545" x2="225.99999999999997" y2="545" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="217.99999999999997,541 217.99999999999997,549 225.99999999999997,545" fill="#64748b"></polygon><text x="148" y="539" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Click mic to stop recording</text></g><g opacity="0.2"><text x="12" y="594" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.1s</text><line x1="237.99999999999997" y1="590" x2="562" y2="590" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="554,586 554,594 562,590" fill="#64748b"></polygon><text x="400" y="584" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">contentEnd + new contentStart (AUDIO)</text></g><g opacity="0.2"><text x="12" y="639" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.2s</text><line x1="574" y1="635" x2="730" y2="635" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="722,631 722,639 730,635" fill="#64748b"></polygon><text x="652" y="629" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Process audio → transcribe → generate resp...</text><circle cx="715" cy="635" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="684" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.5s</text><line x1="730" y1="680" x2="574" y2="680" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="582,676 582,684 574,680" fill="#64748b"></polygon><text x="652" y="674" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">textOutput: transcription + response text</text></g><g opacity="0.2"><text x="12" y="729" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.6s</text><line x1="562" y1="725" x2="237.99999999999997" y2="725" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="245.99999999999997,721 245.99999999999997,729 237.99999999999997,725" fill="#64748b"></polygon><text x="400" y="719" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">textOutput event</text></g><g opacity="0.2"><text x="12" y="774" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.7s</text><line x1="730" y1="770" x2="574" y2="770" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="582,766 582,774 574,770" fill="#64748b"></polygon><text x="652" y="764" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Synthesize voice response (24kHz)</text></g><g opacity="0.2"><text x="12" y="819" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.8s</text><line x1="562" y1="815" x2="237.99999999999997" y2="815" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="245.99999999999997,811 245.99999999999997,819 237.99999999999997,815" fill="#64748b"></polygon><text x="400" y="809" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">audioOutput events (streaming chunks)</text></g><g opacity="0.2"><text x="12" y="864" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">3.9s</text><path d="M 231.99999999999997 860
                        L 262 860
                        L 262 878
                        L 239.99999999999997 878" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="239.99999999999997,874 239.99999999999997,882 231.99999999999997,878" fill="#64748b"></polygon><text x="270" y="866" fill="#94a3b8" font-size="8" font-weight="400">Base64 → PCM → Float32 → AudioBuffer</text></g><g opacity="0.2"><text x="12" y="909" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">4.0s</text><line x1="225.99999999999997" y1="905" x2="70" y2="905" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="78,901 78,909 70,905" fill="#64748b"></polygon><text x="148" y="899" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Play audio through speakers</text><circle cx="85" cy="905" r="4" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="12" y="954" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.0s</text><line x1="70" y1="950" x2="225.99999999999997" y2="950" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="217.99999999999997,946 217.99999999999997,954 225.99999999999997,950" fill="#64748b"></polygon><text x="148" y="944" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">Click mic to continue conversation</text><text x="148" y="962" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Same session</text></g><g opacity="0.2"><text x="12" y="999" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.1s</text><path d="M 231.99999999999997 995
                        L 262 995
                        L 262 1013
                        L 239.99999999999997 1013" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="239.99999999999997,1009 239.99999999999997,1017 231.99999999999997,1013" fill="#64748b"></polygon><text x="270" y="1001" fill="#94a3b8" font-size="8" font-weight="400">Start recording (session already open)</text></g><g opacity="0.2"><text x="12" y="1044" fill="#64748b" font-size="9" font-family="monospace" font-weight="400">5.2s</text><line x1="237.99999999999997" y1="1040" x2="562" y2="1040" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="554,1036 554,1044 562,1040" fill="#64748b"></polygon><text x="400" y="1034" text-anchor="middle" fill="#94a3b8" font-size="8" font-weight="400">New audioInput events (next turn)</text><text x="400" y="1052" text-anchor="middle" fill="#64748b" font-size="7" font-style="italic">Repeat cycle</text></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:12px;flex-wrap:wrap;font-size:10px"><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">User</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Browser</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">Cognito</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Bedrock</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:10px;height:10px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Nova</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:8px;height:8px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div><div style="display:flex;align-items:center;gap:4px"><div style="width:8px;height:8px;border-radius:50%;background:#22c55e"></div><span style="color:#94a3b8">Complete</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.15));border:1px solid rgba(34, 197, 94, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#86efac;margin-bottom:4px">Total: <!-- -->21<!-- --> steps across <!-- -->5<!-- --> components</div><div style="font-size:16px;font-weight:700;color:#22c55e">~4 seconds end-to-end latency</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="react-hooks-architecture">React Hooks Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#react-hooks-architecture" class="hash-link" aria-label="Direct link to React Hooks Architecture" title="Direct link to React Hooks Architecture">​</a></h2>
<p>This diagram details the internal architecture of the React application, showing how custom hooks orchestrate audio capture, Bedrock communication, and playback.</p>
<p><img decoding="async" loading="lazy" alt="React Hooks Architecture" src="https://www.chiwaichan.co.nz/assets/images/02-react-hooks-architecture-da02cf7779ff892aa0a77e6334be7dfb.png" width="1608" height="2498" class="img_ev3q"></p>
<p><strong>Components:</strong></p>
<ul>
<li><strong>VoiceChat.tsx</strong> - Main UI component that coordinates all hooks and renders the interface</li>
<li><strong>useNovaSonic</strong> - Core hook managing Bedrock bidirectional stream, authentication, and event protocol</li>
<li><strong>useAudioRecorder</strong> - Captures microphone input using AudioWorklet in a separate thread</li>
<li><strong>useAudioPlayer</strong> - Manages audio playback queue and Web Audio API buffer sources</li>
<li><strong>audioUtils.ts</strong> - Low-level utilities for PCM conversion, resampling, and Base64 encoding</li>
</ul>
<p><strong>Data Flow:</strong></p>
<ol>
<li>Microphone audio captured by <code>useAudioRecorder</code> via MediaStream</li>
<li>AudioWorklet processes samples in real-time (separate thread)</li>
<li>Audio resampled from 48kHz to 16kHz, converted to PCM16, then Base64</li>
<li><code>useNovaSonic</code> streams audio chunks to Bedrock</li>
<li>Response audio received as Base64, decoded to PCM, converted to Float32</li>
<li><code>useAudioPlayer</code> queues AudioBuffers and plays through AudioContext</li>
</ol>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="authentication-flow">Authentication Flow<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#authentication-flow" class="hash-link" aria-label="Direct link to Authentication Flow" title="Direct link to Authentication Flow">​</a></h2>
<p>This diagram shows the multi-layer authentication flow that enables secure browser-to-Bedrock communication without exposing long-term credentials.</p>
<p><img decoding="async" loading="lazy" alt="Authentication Flow" src="https://www.chiwaichan.co.nz/assets/images/03-authentication-flow-9222abad16775f49dab3f74a02374193.png" width="1695" height="1981" class="img_ev3q"></p>
<p><strong>Authentication Layers:</strong></p>
<ul>
<li><strong>Cognito User Pool</strong> - Handles user registration and login with email/password</li>
<li><strong>Cognito Identity Pool</strong> - Exchanges JWT tokens for temporary AWS credentials</li>
<li><strong>IAM Role</strong> - Defines permissions for authenticated users (Bedrock invoke access)</li>
<li><strong>SigV4 Signing</strong> - AWS SDK automatically signs all Bedrock requests</li>
</ul>
<p><strong>Key Security Features:</strong></p>
<ul>
<li>No AWS credentials stored in browser - only temporary session credentials</li>
<li>Credentials automatically refreshed by Amplify SDK before expiration</li>
<li>IAM policy scoped to specific Bedrock model (<code>amazon.nova-2-sonic-v1:0</code>)</li>
<li>All communication over HTTPS with TLS 1.2+</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="audio-processing-pipeline">Audio Processing Pipeline<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#audio-processing-pipeline" class="hash-link" aria-label="Direct link to Audio Processing Pipeline" title="Direct link to Audio Processing Pipeline">​</a></h2>
<p>This diagram shows the real-time audio processing that converts browser audio to Bedrock's required format and vice versa.</p>
<p><img decoding="async" loading="lazy" alt="Audio Processing Pipeline" src="https://www.chiwaichan.co.nz/assets/images/04-audio-processing-pipeline-06e6544de047614de92eac2c9da44bcc.png" width="4362" height="733" class="img_ev3q"></p>
<p><strong>Input Processing (Recording):</strong></p>
<ol>
<li><strong>Microphone</strong> - Browser captures audio at native sample rate (typically 48kHz)</li>
<li><strong>AudioWorklet</strong> - Processes audio in separate thread, accumulates 2048 samples</li>
<li><strong>Resample</strong> - Linear interpolation converts 48kHz → 16kHz (Nova Sonic requirement)</li>
<li><strong>Float32 → PCM16</strong> - Converts floating point [-1,1] to 16-bit signed integers</li>
<li><strong>Base64 Encode</strong> - Binary PCM encoded for JSON transmission</li>
</ol>
<p><strong>Output Processing (Playback):</strong></p>
<ol>
<li><strong>Base64 Decode</strong> - Received audio converted from Base64 to binary</li>
<li><strong>PCM16 → Float32</strong> - 16-bit integers converted to floating point</li>
<li><strong>AudioBuffer</strong> - Web Audio API buffer created at 24kHz (Nova Sonic output rate)</li>
<li><strong>Queue &amp; Play</strong> - Buffers queued and played sequentially through speakers</li>
</ol>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="interactive-sequence-diagram-1">Interactive Sequence Diagram<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#interactive-sequence-diagram-1" class="hash-link" aria-label="Direct link to Interactive Sequence Diagram" title="Direct link to Interactive Sequence Diagram">​</a></h3>
<div style="width:100%;background:linear-gradient(135deg, #0f172a 0%, #1e293b 100%);padding:clamp(12px, 3vw, 24px);font-family:'Segoe UI', system-ui, sans-serif;color:#e2e8f0;border-radius:12px;box-sizing:border-box;margin-top:20px;margin-bottom:20px;overflow-x:auto"><h3 style="text-align:center;font-size:clamp(14px, 3vw, 20px);font-weight:600;margin:0 0 4px 0;color:#f8fafc">Audio Processing Pipeline</h3><p style="text-align:center;font-size:clamp(10px, 1.6vw, 12px);color:#94a3b8;margin:0 0 16px 0">Real-time audio capture, format conversion, and playback</p><div style="display:flex;justify-content:center;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap"><button disabled="" style="background:#334155;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:not-allowed;font-size:12px;font-weight:600;opacity:0.5">Prev</button><button class="audio-play-pulse" style="background:linear-gradient(135deg, #22c55e 0%, #16a34a 100%);color:#fff;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;box-shadow:0 4px 15px rgba(34, 197, 94, 0.4)">Play</button><button style="background:#6366f1;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:1">Next</button><div style="width:1px;height:20px;background:#475569;margin:0 4px"></div><button style="background:#475569;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">Reset</button><span style="font-size:11px;color:#94a3b8;font-family:monospace">0<!-- -->/<!-- -->16</span></div><div style="min-width:700px;overflow-x:auto"><svg width="100%" height="872" viewBox="0 0 900 872" style="display:block"><g><rect x="18.000000000000007" y="10" width="90" height="50" rx="6" fill="#8b5cf630" stroke="#8b5cf6" stroke-width="2"></rect><text x="63.00000000000001" y="30" text-anchor="middle" fill="#8b5cf6" font-size="10" font-weight="600">Mic</text><text x="63.00000000000001" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">Microphone</text><line x1="63.00000000000001" y1="60" x2="63.00000000000001" y2="852" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="172.79999999999998" y="10" width="90" height="50" rx="6" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"></rect><text x="217.79999999999998" y="30" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600">Worklet</text><text x="217.79999999999998" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">AudioWorklet</text><line x1="217.79999999999998" y1="60" x2="217.79999999999998" y2="852" stroke="#3b82f6" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="327.59999999999997" y="10" width="90" height="50" rx="6" fill="#f59e0b30" stroke="#f59e0b" stroke-width="2"></rect><text x="372.59999999999997" y="30" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">Utils</text><text x="372.59999999999997" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">audioUtils.ts</text><line x1="372.59999999999997" y1="60" x2="372.59999999999997" y2="852" stroke="#f59e0b" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="482.4" y="10" width="90" height="50" rx="6" fill="#22c55e30" stroke="#22c55e" stroke-width="2"></rect><text x="527.4" y="30" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">Hook</text><text x="527.4" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">useNovaSonic</text><line x1="527.4" y1="60" x2="527.4" y2="852" stroke="#22c55e" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="637.2" y="10" width="90" height="50" rx="6" fill="#ec489930" stroke="#ec4899" stroke-width="2"></rect><text x="682.2" y="30" text-anchor="middle" fill="#ec4899" font-size="10" font-weight="600">Bedrock</text><text x="682.2" y="44" text-anchor="middle" fill="#94a3b8" font-size="7"></text><line x1="682.2" y1="60" x2="682.2" y2="852" stroke="#ec4899" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g><rect x="792" y="10" width="90" height="50" rx="6" fill="#06b6d430" stroke="#06b6d4" stroke-width="2"></rect><text x="837" y="30" text-anchor="middle" fill="#06b6d4" font-size="10" font-weight="600">Player</text><text x="837" y="44" text-anchor="middle" fill="#94a3b8" font-size="7">useAudioPlayer</text><line x1="837" y1="60" x2="837" y2="852" stroke="#06b6d4" stroke-width="2" stroke-dasharray="6,4" opacity="0.4"></line></g><g opacity="0.2"><text x="10" y="144" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">0ms</text><line x1="68" y1="140" x2="212.79999999999998" y2="140" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="205.79999999999998,137 205.79999999999998,143 212.79999999999998,140" fill="#64748b"></polygon><text x="140.39999999999998" y="135" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">getUserMedia() → MediaStream (48kHz Float32)</text><text x="140.39999999999998" y="150" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Browser native</text></g><g opacity="0.2"><text x="10" y="186" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">20ms</text><path d="M 217.79999999999998 182
                        L 245.79999999999998 182
                        L 245.79999999999998 198
                        L 224.79999999999998 198" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="224.79999999999998,194 224.79999999999998,202 217.79999999999998,198" fill="#64748b"></polygon><text x="251.79999999999998" y="187" fill="#94a3b8" font-size="7" font-weight="400">process() called ~50x/sec, accumulate 2048 samples</text></g><g opacity="0.2"><text x="10" y="228" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">40ms</text><line x1="222.79999999999998" y1="224" x2="367.59999999999997" y2="224" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="360.59999999999997,221 360.59999999999997,227 367.59999999999997,224" fill="#64748b"></polygon><text x="295.2" y="219" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">postMessage(Float32Array)</text><text x="295.2" y="234" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">To main thread</text></g><g opacity="0.2"><text x="10" y="270" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">41ms</text><path d="M 372.59999999999997 266
                        L 400.59999999999997 266
                        L 400.59999999999997 282
                        L 379.59999999999997 282" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="379.59999999999997,278 379.59999999999997,286 372.59999999999997,282" fill="#64748b"></polygon><text x="406.59999999999997" y="271" fill="#94a3b8" font-size="7" font-weight="400">resampleAudio(48kHz → 16kHz)</text><text x="406.59999999999997" y="281" fill="#64748b" font-size="6" font-style="italic">Linear interp</text></g><g opacity="0.2"><text x="10" y="312" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">42ms</text><path d="M 372.59999999999997 308
                        L 400.59999999999997 308
                        L 400.59999999999997 324
                        L 379.59999999999997 324" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="379.59999999999997,320 379.59999999999997,328 372.59999999999997,324" fill="#64748b"></polygon><text x="406.59999999999997" y="313" fill="#94a3b8" font-size="7" font-weight="400">float32ToPcm16(): [-1,1] → 16-bit signed int</text></g><g opacity="0.2"><text x="10" y="354" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">43ms</text><path d="M 372.59999999999997 350
                        L 400.59999999999997 350
                        L 400.59999999999997 366
                        L 379.59999999999997 366" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="379.59999999999997,362 379.59999999999997,370 372.59999999999997,366" fill="#64748b"></polygon><text x="406.59999999999997" y="355" fill="#94a3b8" font-size="7" font-weight="400">uint8ArrayToBase64(): binary → text</text></g><g opacity="0.2"><text x="10" y="396" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">44ms</text><line x1="377.59999999999997" y1="392" x2="522.4" y2="392" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="515.4,389 515.4,395 522.4,392" fill="#64748b"></polygon><text x="450" y="387" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">onAudioData(base64String)</text><circle cx="510.4" cy="392" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="438" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">45ms</text><line x1="532.4" y1="434" x2="677.2" y2="434" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="670.2,431 670.2,437 677.2,434" fill="#64748b"></polygon><text x="604.8" y="429" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">audioInput event: { content: base64 }</text><text x="604.8" y="444" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Via SDK</text></g><g opacity="0.2"><text x="10" y="480" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2000ms</text><line x1="677.2" y1="476" x2="532.4" y2="476" stroke="#64748b" stroke-width="1.5" stroke-dasharray="6,3" class=""></line><polygon points="539.4,473 539.4,479 532.4,476" fill="#64748b"></polygon><text x="604.8" y="471" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">audioOutput event: { content: base64 } (24kHz)</text><text x="604.8" y="486" text-anchor="middle" fill="#64748b" font-size="6" font-style="italic">Streaming</text></g><g opacity="0.2"><text x="10" y="522" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2001ms</text><line x1="532.4" y1="518" x2="832" y2="518" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="825,515 825,521 832,518" fill="#64748b"></polygon><text x="682.2" y="513" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">queueAudio(base64String)</text></g><g opacity="0.2"><text x="10" y="564" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2002ms</text><line x1="832" y1="560" x2="377.59999999999997" y2="560" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="384.59999999999997,557 384.59999999999997,563 377.59999999999997,560" fill="#64748b"></polygon><text x="604.8" y="555" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">base64ToUint8Array()</text></g><g opacity="0.2"><text x="10" y="606" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2003ms</text><line x1="377.59999999999997" y1="602" x2="832" y2="602" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="825,599 825,605 832,602" fill="#64748b"></polygon><text x="604.8" y="597" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">Uint8Array (PCM bytes)</text></g><g opacity="0.2"><text x="10" y="648" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2004ms</text><line x1="832" y1="644" x2="377.59999999999997" y2="644" stroke="#64748b" stroke-width="1.5" stroke-dasharray="none" class=""></line><polygon points="384.59999999999997,641 384.59999999999997,647 377.59999999999997,644" fill="#64748b"></polygon><text x="604.8" y="639" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">createAudioBufferFromPcm()</text></g><g opacity="0.2"><text x="10" y="690" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2005ms</text><path d="M 372.59999999999997 686
                        L 400.59999999999997 686
                        L 400.59999999999997 702
                        L 379.59999999999997 702" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="379.59999999999997,698 379.59999999999997,706 372.59999999999997,702" fill="#64748b"></polygon><text x="406.59999999999997" y="691" fill="#94a3b8" font-size="7" font-weight="400">pcm16ToFloat32(): 16-bit int → [-1,1]</text></g><g opacity="0.2"><text x="10" y="732" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2006ms</text><line x1="377.59999999999997" y1="728" x2="832" y2="728" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4,2" class=""></line><polygon points="825,725 825,731 832,728" fill="#64748b"></polygon><text x="604.8" y="723" text-anchor="middle" fill="#94a3b8" font-size="7" font-weight="400">AudioBuffer (24kHz)</text><circle cx="820" cy="728" r="3" fill="#fbbf24"></circle></g><g opacity="0.2"><text x="10" y="774" fill="#64748b" font-size="8" font-family="monospace" font-weight="400">2007ms</text><path d="M 837 770
                        L 865 770
                        L 865 786
                        L 844 786" fill="none" stroke="#64748b" stroke-width="1.5" class=""></path><polygon points="844,782 844,790 837,786" fill="#64748b"></polygon><text x="871" y="775" fill="#94a3b8" font-size="7" font-weight="400">AudioBufferSourceNode.start() → speakers</text></g></svg></div><div style="margin-top:16px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;font-size:9px"><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#8b5cf6"></div><span style="color:#94a3b8">Mic</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#3b82f6"></div><span style="color:#94a3b8">Worklet</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#f59e0b"></div><span style="color:#94a3b8">Utils</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#22c55e"></div><span style="color:#94a3b8">Hook</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#ec4899"></div><span style="color:#94a3b8">Bedrock</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:8px;height:8px;border-radius:2px;background:#06b6d4"></div><span style="color:#94a3b8">Player</span></div><div style="display:flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-radius:50%;background:#fbbf24"></div><span style="color:#94a3b8">Milestone</span></div></div><div style="margin-top:16px;background:linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.15));border:1px solid rgba(59, 130, 246, 0.4);border-radius:8px;padding:12px;text-align:center"><div style="font-size:10px;color:#93c5fd;margin-bottom:4px">Audio Format Conversions</div><div style="font-size:11px;color:#60a5fa">Input: 48kHz Float32 → 16kHz PCM16 → Base64 | Output: Base64 → PCM16 → Float32 → 24kHz AudioBuffer</div></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="bidirectional-streaming-protocol">Bidirectional Streaming Protocol<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#bidirectional-streaming-protocol" class="hash-link" aria-label="Direct link to Bidirectional Streaming Protocol" title="Direct link to Bidirectional Streaming Protocol">​</a></h2>
<p>This diagram illustrates how the useNovaSonic hook manages the complex bidirectional streaming protocol with Amazon Bedrock.</p>
<p><img decoding="async" loading="lazy" alt="Bidirectional Streaming Protocol" src="https://www.chiwaichan.co.nz/assets/images/05-bidirectional-streaming-72b1b745541dd196d9bb780a460722c7.png" width="2230" height="3537" class="img_ev3q"></p>
<p><strong>Event Protocol:</strong>
Nova Sonic uses an event-based protocol where each interaction consists of named sessions, prompts, and content blocks.</p>
<p><strong>Input Events (sent to Bedrock):</strong></p>
<ul>
<li><strong>sessionStart</strong> - Initializes session with inference parameters (maxTokens: 1024, topP: 0.9, temperature: 0.7)</li>
<li><strong>promptStart</strong> - Defines output audio format (24kHz, LPCM, voice "matthew")</li>
<li><strong>contentStart</strong> - Marks beginning of content blocks (TEXT for system prompt, AUDIO for user speech)</li>
<li><strong>textInput</strong> - Sends system prompt text content</li>
<li><strong>audioInput</strong> - Streams user audio chunks as Base64-encoded 16kHz PCM</li>
<li><strong>contentEnd</strong> - Marks end of content block</li>
<li><strong>promptEnd</strong> / <strong>sessionEnd</strong> - Terminates prompt and session</li>
</ul>
<p><strong>Output Events (received from Bedrock):</strong></p>
<ul>
<li><strong>contentStart</strong> - Marks role transitions (USER for ASR, ASSISTANT for response)</li>
<li><strong>textOutput</strong> - Returns transcribed user speech and generated AI response text</li>
<li><strong>audioOutput</strong> - Returns synthesized voice response as Base64-encoded 24kHz PCM</li>
<li><strong>contentEnd</strong> - Marks end of response content</li>
</ul>
<p><strong>Async Generator Pattern:</strong>
The SDK requires input as <code>AsyncIterable&lt;Uint8Array&gt;</code>. The hook implements this using:</p>
<ul>
<li><strong>Event Queue</strong> - Pre-queued initialization events before stream starts</li>
<li><strong>Promise Resolver</strong> - Backpressure control for yielding events on demand</li>
<li><strong>pushEvent()</strong> - Adds new events during conversation (audio chunks)</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="serverless-architecture-overview">Serverless Architecture Overview<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#serverless-architecture-overview" class="hash-link" aria-label="Direct link to Serverless Architecture Overview" title="Direct link to Serverless Architecture Overview">​</a></h2>
<p>This diagram provides a comprehensive view of all components - the entire solution is serverless with no EC2 instances or containers to manage.</p>
<p><img decoding="async" loading="lazy" alt="Serverless Architecture Overview" src="https://www.chiwaichan.co.nz/assets/images/06-serverless-architecture-e3d9fec224f3454da0207fd321be55a2.png" width="1285" height="3427" class="img_ev3q"></p>
<p><strong>Frontend Stack:</strong></p>
<ul>
<li><strong>React</strong> - Component-based UI framework</li>
<li><strong>Vite</strong> - Build tool and dev server</li>
<li><strong>TypeScript</strong> - Type-safe development</li>
<li><strong>AWS Amplify Hosting</strong> - Static web hosting with global CDN</li>
</ul>
<p><strong>Backend Stack (Amplify Gen 2):</strong></p>
<ul>
<li><strong>amplify/backend.ts</strong> - Infrastructure defined in TypeScript</li>
<li><strong>Cognito User Pool</strong> - Email-based authentication</li>
<li><strong>Cognito Identity Pool</strong> - AWS credential vending</li>
<li><strong>IAM Policy</strong> - Grants <code>bedrock:InvokeModel</code> permission for bidirectional streaming</li>
</ul>
<p><strong>AI Service:</strong></p>
<ul>
<li><strong>Amazon Bedrock</strong> - Managed foundation model inference</li>
<li><strong>Nova 2 Sonic</strong> - Speech-to-speech model (us-east-1)</li>
<li><strong>Bidirectional Streaming</strong> - Real-time duplex communication</li>
</ul>
<h1>Technical Challenges &amp; Solutions</h1>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-1-audioworklet-cors-issues">Challenge 1: AudioWorklet CORS Issues<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#challenge-1-audioworklet-cors-issues" class="hash-link" aria-label="Direct link to Challenge 1: AudioWorklet CORS Issues" title="Direct link to Challenge 1: AudioWorklet CORS Issues">​</a></h2>
<p><strong>Problem:</strong> Loading AudioWorklet from external file fails with CORS errors on some deployments.</p>
<p><strong>Solution:</strong> Inline the AudioWorklet code as a Blob URL:</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> blob </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">Blob</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">audioWorkletCode</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> type</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'application/javascript'</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> workletUrl </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">URL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">createObjectURL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">blob</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> audioContext</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">audioWorklet</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">addModule</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">workletUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token constant" style="color:rgb(100, 102, 149)">URL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">revokeObjectURL</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">workletUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-2-sample-rate-mismatch">Challenge 2: Sample Rate Mismatch<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#challenge-2-sample-rate-mismatch" class="hash-link" aria-label="Direct link to Challenge 2: Sample Rate Mismatch" title="Direct link to Challenge 2: Sample Rate Mismatch">​</a></h2>
<p><strong>Problem:</strong> Browsers capture audio at 48kHz, but Nova Sonic requires 16kHz input.</p>
<p><strong>Solution:</strong> Linear interpolation resampling in real-time:</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:rgb(220, 220, 170)">resampleAudio</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">audioData</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> Float32Array</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> sourceSampleRate</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">number</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> targetSampleRate</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">number</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> ratio </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> sourceSampleRate </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain"> targetSampleRate</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> newLength </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">floor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">audioData</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain"> ratio</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> result </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">Float32Array</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">newLength</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">for</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token keyword" style="color:rgb(86, 156, 214)">let</span><span class="token plain"> i </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> i </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain"> newLength</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> i</span><span class="token operator" style="color:rgb(212, 212, 212)">++</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> srcIndex </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> i </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> ratio</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> floor </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">floor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">srcIndex</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> ceil </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">min</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">floor </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> audioData</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> t </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> srcIndex </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> floor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    result</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">i</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> audioData</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">floor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> t</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> audioData</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">ceil</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> t</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> result</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="challenge-3-sdk-bidirectional-stream-input">Challenge 3: SDK Bidirectional Stream Input<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#challenge-3-sdk-bidirectional-stream-input" class="hash-link" aria-label="Direct link to Challenge 3: SDK Bidirectional Stream Input" title="Direct link to Challenge 3: SDK Bidirectional Stream Input">​</a></h2>
<p><strong>Problem:</strong> AWS SDK requires input as <code>AsyncIterable&lt;Uint8Array&gt;</code>, but events need to be pushed dynamically during the conversation.</p>
<p><strong>Solution:</strong> Async generator with event queue and promise-based backpressure:</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">async</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">createInputStream</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">while</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">isActiveRef</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">current </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">!</span><span class="token plain">ctrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">closed</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">while</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">ctrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">eventQueue</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token keyword" style="color:rgb(86, 156, 214)">yield</span><span class="token plain"> ctrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">eventQueue</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">shift</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> nextEvent </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name builtin" style="color:rgb(86, 156, 214)">Promise</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">resolve </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">      ctrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">resolver </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> resolve</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">nextEvent </span><span class="token operator" style="color:rgb(212, 212, 212)">===</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">null</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">break</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">yield</span><span class="token plain"> nextEvent</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></span></code></pre></div></div>
<h1>Getting Started</h1>
<p><strong>GitHub Repository:</strong> <a href="https://github.com/chiwaichan/amplify-react-amazon-nova-2-sonic-voice-chat" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/amplify-react-amazon-nova-2-sonic-voice-chat</a></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="prerequisites">Prerequisites<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites">​</a></h2>
<ul>
<li>Node.js 18+</li>
<li>AWS Account with Bedrock access enabled</li>
<li>AWS CLI configured with credentials</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="deployment-steps">Deployment Steps<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/real-time-voice-chat-amazon-nova-sonic-react-amplify#deployment-steps" class="hash-link" aria-label="Direct link to Deployment Steps" title="Direct link to Deployment Steps">​</a></h2>
<ol>
<li>
<p><strong>Enable Nova 2 Sonic in Bedrock Console</strong> (us-east-1 region)</p>
</li>
<li>
<p><strong>Clone and Install:</strong></p>
</li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">git</span><span class="token plain"> clone https://github.com/chiwaichan/amplify-react-amazon-nova-2-sonic-voice-chat.git</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> amplify-react-amazon-nova-2-sonic-voice-chat</span><br></span><span class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">install</span><br></span></code></pre></div></div>
<ol start="3">
<li><strong>Start Amplify Sandbox:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token plain">npx ampx sandbox</span><br></span></code></pre></div></div>
<ol start="4">
<li><strong>Run Development Server:</strong></li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">npm</span><span class="token plain"> run dev</span><br></span></code></pre></div></div>
<ol start="5">
<li><strong>Open Application:</strong>
Navigate to <code>http://localhost:5173</code>, create an account, and start talking!</li>
</ol>
<h1>Summary</h1>
<p>This architecture provides a reusable building block for voice-enabled AI applications:</p>
<ul>
<li><strong>Zero backend servers</strong> - Direct browser-to-Bedrock communication</li>
<li><strong>Real-time streaming</strong> - HTTP/2 bidirectional streaming for low latency</li>
<li><strong>Secure authentication</strong> - Cognito User Pool + Identity Pool + IAM policies</li>
<li><strong>Audio processing pipeline</strong> - Web Audio API, AudioWorklet, PCM conversion</li>
<li><strong>Infrastructure as code</strong> - AWS Amplify Gen 2 with TypeScript backend definition</li>
</ul>
<p>The entire interaction happens in real-time: speak naturally, and hear the AI respond within seconds.</p>]]></content:encoded>
            <category>AWS</category>
            <category>Bedrock</category>
            <category>Nova Sonic</category>
            <category>React</category>
            <category>Amplify</category>
            <category>Voice</category>
            <category>Audio</category>
            <category>Serverless</category>
            <category>TypeScript</category>
            <category>Speech-to-Speech</category>
        </item>
        <item>
            <title><![CDATA[Cloud-Connected Sphero RVR Robot with AWS IoT Core and Seeed Studio XIAO ESP32S3]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3</link>
            <guid>https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3</guid>
            <pubDate>Mon, 19 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Building a cloud-connected Sphero RVR robot using a Seeed Studio XIAO ESP32S3 microcontroller, with real-time telemetry and remote control via AWS IoT Core MQTT]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/robotics"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Robotics</span><span class="description_NSta">Cloud-connected robotics with Sphero RVR and Hugging Face LeRobot</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<p><img decoding="async" loading="lazy" alt="Seeed Studio XIAO ESP32S3" src="https://www.chiwaichan.co.nz/assets/images/seeed-studio-0a90fa56471bc3ede853221c1a523982.jpg" width="4000" height="2252" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Sphero RVR" src="https://www.chiwaichan.co.nz/assets/images/rvr-2450c2399f73bbcdc2b367e17caf940b.jpg" width="4000" height="3000" class="img_ev3q"></p>
<p>A Sphero RVR integrated with a Seeed Studio XIAO ESP32S3 with telemetry uploaded into, and also, basic drive remote control commands received from any where leveraging AWS IoT Core.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="overview">Overview<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#overview" class="hash-link" aria-label="Direct link to Overview" title="Direct link to Overview">​</a></h2>
<p>Lately I have been aiming to go deep on AI Robotics, and last year I have been slowly experimenting more and more with anything that is AI, IoT and Robotics related; with the intention of learning and going as wide and as deep as possible in any pillars I can think of. You can check out my blogs under the <a href="https://www.chiwaichan.co.nz/projects/robotics">Robotics</a> Project to see what I have been up to. This year I want to focus on enabling mobility for my experiments - as in providing wheels for solutions to move around the house, ideally autonomously; starting off with wheel based solutions bought off-shelve, followed by solutions that I build myself from open-sourced projects people have kindly contirbuted online, and then ambitiously designed, 3D Printed and built all from the ground up - perhaps in a couple of years time.</p>
<p>This project uses a Seeed Studio XIAO ESP32S3 microcontroller to communicate with a Sphero RVR robot via UART, while simultaneously connecting to AWS IoT Core over WiFi. The system publishes real-time sensor telemetry and accepts remote drive commands through MQTT.</p>
<iframe width="315" height="560" src="https://www.youtube.com/embed/WM8tDXyR6oo" title="Sphero RVR and Seeed Studio Demo" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="hardware-components">Hardware Components<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#hardware-components" class="hash-link" aria-label="Direct link to Hardware Components" title="Direct link to Hardware Components">​</a></h2>
<table><thead><tr><th>Component</th><th>Description</th></tr></thead><tbody><tr><td><a href="https://www.seeedstudio.com/XIAO-ESP32S3-p-5627.html" target="_blank" rel="noopener noreferrer">Seeed Studio XIAO ESP32S3</a></td><td>Compact ESP32-S3 microcontroller with WiFi, 8MB flash</td></tr><tr><td><a href="https://www.amazon.com/Sphero-RVR-All-Terrain-Programmable-Customizable/dp/B07RBBRQW3" target="_blank" rel="noopener noreferrer">Sphero RVR</a></td><td>Programmable robot with motors, IMU, color sensor, encoders</td></tr><tr><td><a href="https://www.seeedstudio.com/Seeeduino-XIAO-Expansion-board-p-4746.html" target="_blank" rel="noopener noreferrer">XIAO Expansion Board</a></td><td>Provides OLED display (128x64 SSD1306) for status info</td></tr></tbody></table>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="hardware-wiring">Hardware Wiring<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#hardware-wiring" class="hash-link" aria-label="Direct link to Hardware Wiring" title="Direct link to Hardware Wiring">​</a></h3>
<p><img decoding="async" loading="lazy" alt="Hardware Wiring" src="https://www.chiwaichan.co.nz/assets/images/03-hardware-wiring-3240485fc9f90d8d04fb279deb305d22.png" width="1053" height="2366" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="features">Features<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#features" class="hash-link" aria-label="Direct link to Features" title="Direct link to Features">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="real-time-telemetry">Real-time Telemetry<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#real-time-telemetry" class="hash-link" aria-label="Direct link to Real-time Telemetry" title="Direct link to Real-time Telemetry">​</a></h3>
<p>The system publishes comprehensive sensor data every 60 seconds:</p>
<ul>
<li><strong>IMU Data</strong>: Pitch, roll, yaw orientation</li>
<li><strong>Accelerometer &amp; Gyroscope</strong>: Motion and rotation data</li>
<li><strong>Color Sensor</strong>: RGB values with confidence</li>
<li><strong>Compass</strong>: Heading in degrees</li>
<li><strong>Ambient Light</strong>: Lux measurements</li>
<li><strong>Motor Thermal</strong>: Temperature and protection status</li>
<li><strong>Encoders</strong>: Wheel tick counts</li>
<li><strong>Position &amp; Velocity</strong>: Locator data in meters</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="remote-commands-via-mqtt">Remote Commands via MQTT<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#remote-commands-via-mqtt" class="hash-link" aria-label="Direct link to Remote Commands via MQTT" title="Direct link to Remote Commands via MQTT">​</a></h3>
<p>Control the RVR from anywhere using JSON commands:</p>
<ul>
<li><strong>Drive</strong>: Speed and heading control</li>
<li><strong>Tank</strong>: Independent left/right motor control</li>
<li><strong>Raw Motors</strong>: Direct motor speed control</li>
<li><strong>LED Control</strong>: Headlights, brakelights, status LEDs</li>
<li><strong>Navigation</strong>: Reset yaw, reset locator</li>
<li><strong>Power</strong>: Wake and sleep commands</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="local-oled-display">Local OLED Display<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#local-oled-display" class="hash-link" aria-label="Direct link to Local OLED Display" title="Direct link to Local OLED Display">​</a></h3>
<p>The XIAO Expansion Board's OLED display shows real-time sensor readings for local monitoring.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="mqtt-message-flow">MQTT Message Flow<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#mqtt-message-flow" class="hash-link" aria-label="Direct link to MQTT Message Flow" title="Direct link to MQTT Message Flow">​</a></h3>
<p><img decoding="async" loading="lazy" alt="MQTT Message Flow" src="https://www.chiwaichan.co.nz/assets/images/04-mqtt-message-flow-60d9e28bd0b79807dd09db8ce0ac3d72.png" width="1273" height="2989" class="img_ev3q"></p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="sensor-data-pipeline">Sensor Data Pipeline<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#sensor-data-pipeline" class="hash-link" aria-label="Direct link to Sensor Data Pipeline" title="Direct link to Sensor Data Pipeline">​</a></h3>
<p><img decoding="async" loading="lazy" alt="Sensor Data Pipeline" src="https://www.chiwaichan.co.nz/assets/images/06-sensor-data-pipeline-147b429f830aecf92b85da5498139bd3.png" width="2164" height="3073" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="architecture">Architecture<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#architecture" class="hash-link" aria-label="Direct link to Architecture" title="Direct link to Architecture">​</a></h2>
<p>The XIAO ESP32S3 acts as a bridge between the Sphero RVR and AWS IoT Core:</p>
<ol>
<li><strong>UART Communication</strong>: The ESP32S3 communicates with the RVR via UART (GPIO43/44)</li>
<li><strong>WiFi Connection</strong>: Connects to local WiFi network</li>
<li><strong>MQTT over TLS</strong>: Secure connection to AWS IoT Core with X.509 certificates</li>
<li><strong>Bidirectional</strong>: Publishes telemetry and subscribes to command topics</li>
</ol>
<p><img decoding="async" loading="lazy" alt="High-Level System Architecture" src="https://www.chiwaichan.co.nz/assets/images/01-high-level-architecture-ded224ee711b0966bbd4ff60ba62ee97.png" width="1120" height="1880" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Communication Protocol Stack" src="https://www.chiwaichan.co.nz/assets/images/02-protocol-stack-55678b55b104bcf8b1337344dd673c75.png" width="678" height="2823" class="img_ev3q"></p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="sphero-rvr-protocol">Sphero RVR Protocol<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#sphero-rvr-protocol" class="hash-link" aria-label="Direct link to Sphero RVR Protocol" title="Direct link to Sphero RVR Protocol">​</a></h3>
<p>The Sphero RVR uses a binary packet-based protocol over UART. Each packet contains a start-of-packet byte (0x8D), an 8-byte header with device ID and command ID, variable-length data body, checksum, and end-of-packet byte (0xD8). The RVR has two internal processors: Nordic (handles BLE, power, color detection) and ST (handles motors, IMU, encoders).</p>
<p><img decoding="async" loading="lazy" alt="Sphero RVR Protocol Architecture" src="https://www.chiwaichan.co.nz/assets/images/05-sphero-rvr-protocol-f0b79bb9b3a33a12762bfc658debe25d.png" width="1724" height="2278" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="source-code">Source Code<a href="https://www.chiwaichan.co.nz/blog/2026/01/19/sphero-rvr-aws-iot-esp32s3#source-code" class="hash-link" aria-label="Direct link to Source Code" title="Direct link to Source Code">​</a></h2>
<p>I ported the code into this project to control the RVR using the UART protocol based on the Sphero SDK.</p>
<p>You can find the source code for this project here: <a href="https://github.com/chiwaichan/platformio-aws-iot-seeed-studio-esp32s3-sphero-rvr" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/platformio-aws-iot-seeed-studio-esp32s3-sphero-rvr</a></p>]]></content:encoded>
            <category>AWS</category>
            <category>IoT</category>
            <category>Robotics</category>
            <category>sphero</category>
            <category>rvr</category>
            <category>esp32s3</category>
            <category>xiao</category>
            <category>Seeed Studio</category>
            <category>MQTT</category>
        </item>
        <item>
            <title><![CDATA[AWS Community Day 2025 Aotearoa New Zealand Slides]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/09/28/aws-community-day-2025-slides</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/09/28/aws-community-day-2025-slides</guid>
            <pubDate>Sun, 28 Sep 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[AWS Community Day 2025 - Building IoT Solutions on AWS and pivoting to AIoT and Robotics]]></description>
            <content:encoded><![CDATA[<iframe src="https://docs.google.com/presentation/d/1LWLR8uEm8NmC-7AFXtPBvrJGDo93xv2g/embed?start=false&amp;loop=false&amp;delayms=3000" frameborder="0" width="480" height="299" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>]]></content:encoded>
            <category>Amazon Web Services</category>
            <category>aws-community-day</category>
            <category>IoT</category>
            <category>community</category>
            <category>conference</category>
        </item>
        <item>
            <title><![CDATA[AWS Community Day 2025 Aotearoa New Zealand]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/09/18/aws-community-day-2025</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/09/18/aws-community-day-2025</guid>
            <pubDate>Thu, 18 Sep 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[AWS Community Day 2025 - Building IoT Solutions on AWS and pivoting to AIoT and Robotics]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="Title slide: Building IoT Solutions on AWS and pivoting to AIoT and Robotics" src="https://www.chiwaichan.co.nz/assets/images/talk-title-slide-595a1e12d63f0f65926acea73fa118a7.jpeg" width="1600" height="1200" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Slide: Scale out the number of IoT Devices and Device Types" src="https://www.chiwaichan.co.nz/assets/images/talk-scale-out-devices-slide-ae068ebe905fd145031a98aaae2c68c6.jpeg" width="1600" height="1200" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Wide shot of the talk from the back of the room" src="https://www.chiwaichan.co.nz/assets/images/talk-stage-wide-shot-86bfdaccb8c54232d0ce8465bb44e6e5.jpeg" width="1600" height="1200" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Live demo of the vehicle tracking dashboard" src="https://www.chiwaichan.co.nz/assets/images/talk-vehicle-tracking-dashboard-5b8e4d04d7452d2699a506234b2577a9.png" width="1024" height="768" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Live demo of the robotics control page driving a robot arm" src="https://www.chiwaichan.co.nz/assets/images/talk-robotics-control-demo-672f76e73a80623a1b5669fb2903222f.png" width="1024" height="768" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Slide: REST API architecture with API Gateway, Lambda, DynamoDB and S3" src="https://www.chiwaichan.co.nz/assets/images/talk-rest-api-architecture-slide-47d98fa3a36c6bc5a47bf1646355e879.png" width="1024" height="768" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Slide: AWS IoT fleet indexing stack with things and thing groups" src="https://www.chiwaichan.co.nz/assets/images/talk-fleet-indexing-slide-ccd76bd1efdd4cdac94d44ffd7593ee6.png" width="1024" height="768" class="img_ev3q"></p>]]></content:encoded>
            <category>Amazon Web Services</category>
            <category>aws-community-day</category>
            <category>IoT</category>
            <category>community</category>
            <category>conference</category>
        </item>
        <item>
            <title><![CDATA[Controlling Hugging Face LeRobot SO101 arms over AWS IoT Core using a Seeed Studio XIAO ESP32C3]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/08/21/aws-iot-core-lerobot-huggingface-xiao-esp32c3</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/08/21/aws-iot-core-lerobot-huggingface-xiao-esp32c3</guid>
            <pubDate>Thu, 21 Aug 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[LeRobot Architecture]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/robotics"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Robotics</span><span class="description_NSta">Cloud-connected robotics with Sphero RVR and Hugging Face LeRobot</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<p><img decoding="async" loading="lazy" alt="LeRobot Architecture" src="https://www.chiwaichan.co.nz/assets/images/lerobot-architecture-7b24832d2bf216082af7b351d0789888.png" width="1825" height="1813" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="Seeed Studio XIAO ESP32C3 and Bus Servo Driver Board" src="https://www.chiwaichan.co.nz/assets/images/xiao-esp32c3-bus-servo-driver-board-c6166e39fa828fe36b75f7a9cb630974.jpg" width="4000" height="3000" class="img_ev3q"></p>
<p>The LeRobot Follower arm is subscribed to an IoT Topic that is being published in real-time by the LeRobot Leader arm over AWS IoT Core, using a Seeed Studio XIAO ESP32C3 integrated with a Seeed Studio Bus Servo Driver Board, the driver board is controlling the 6 Feetech 3215 Servos over the UART protocol.</p>
<p>In this video I demonstrate how to control a set of Hugging Face SO-101 arms over AWS IoT Core, without the use of the LeRobot framework, nor using a device such as a Mac nor a device like Nvidia Jetson Orin Nano Super Developer Kit. Only using Seeed Studio XIAO ESP32C3 and AWS IoT.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/r5JrSNI0Rbw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
<p>You can find the source code for this solution here: <a href="https://github.com/chiwaichan/aws-iot-core-lerobot-so101" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/aws-iot-core-lerobot-so101</a></p>]]></content:encoded>
            <category>AWS</category>
            <category>IoT</category>
            <category>Robotics</category>
            <category>huggingface</category>
            <category>lerobot</category>
            <category>esp32c3</category>
            <category>xiao</category>
            <category>servo</category>
            <category>Seeed Studio</category>
        </item>
        <item>
            <title><![CDATA[Good Times]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/07/13/good-times</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/07/13/good-times</guid>
            <pubDate>Sun, 13 Jul 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Good times]]></description>
            <content:encoded><![CDATA[<p>Good times</p>
<p><img decoding="async" loading="lazy" alt="Good Times" src="https://www.chiwaichan.co.nz/assets/images/good-times-2025-07-13-bd7a0c7a6e976ca97484385316d70498.jpeg" width="5120" height="3840" class="img_ev3q"></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Sydney Summit 2025]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/06/04/aws-sydney-summit</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/06/04/aws-sydney-summit</guid>
            <pubDate>Wed, 04 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[AWS Sydney Summit 2025]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="AWS Sydney Summit Booth" src="https://www.chiwaichan.co.nz/assets/images/aws-sydney-summit-booth-3a6019dec68f94874d1968d177ecd248.jpg" width="1536" height="2048" class="img_ev3q"></p>
<p>I was hungry and tried to feed myself but I forgot to bring cat food.</p>
<p>Here is the source-code Streamlit App I demoed at the AWS Sydney Summit, to demonstrate how to control IoT devices using the Strands Agents: <a href="https://github.com/chiwaichan/AWSSydneySummit2025Demo" target="_blank" rel="noopener noreferrer">https://github.com/chiwaichan/AWSSydneySummit2025Demo</a></p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/4jqdVPfLztk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/0-AtCnOMUxI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>]]></content:encoded>
            <category>Amazon Web Services</category>
            <category>aws-summit</category>
            <category>sydney</category>
            <category>conference</category>
        </item>
        <item>
            <title><![CDATA[Iron Man Mark 3 Helmet]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2025/05/03/iron-man-mark-3-helmet</link>
            <guid>https://www.chiwaichan.co.nz/blog/2025/05/03/iron-man-mark-3-helmet</guid>
            <pubDate>Sat, 03 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Iron Man Mark 85 Faceplate]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/iron-man-iot"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Iron Man Suit with AWS IoT</span><span class="description_NSta">3D printed Iron Man suit integrated with AWS IoT Core and ESP32</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<iframe width="560" height="315" src="https://www.youtube.com/embed/4jqdVPfLztk?si=Tkfbolj8CO6pzdJB" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>]]></content:encoded>
            <category>IoT</category>
            <category>3DPrinting</category>
            <category>3D Printer</category>
        </item>
        <item>
            <title><![CDATA[Iron Man Mark 85 Faceplate]]></title>
            <link>https://www.chiwaichan.co.nz/blog/2024/10/24/iron-man-mark-85-faceplate</link>
            <guid>https://www.chiwaichan.co.nz/blog/2024/10/24/iron-man-mark-85-faceplate</guid>
            <pubDate>Thu, 24 Oct 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[Iron Man Mark 85 Faceplate]]></description>
            <content:encoded><![CDATA[<div class="container_Aij_"><a class="link_Lg2Q" href="https://www.chiwaichan.co.nz/projects/iron-man-iot"><div class="card_PejQ"><div class="iconWrapper_Keiy"><svg class="icon_r1n_" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"></rect><rect x="9" y="9" width="6" height="6"></rect><path d="M9 1v3"></path><path d="M15 1v3"></path><path d="M9 20v3"></path><path d="M15 20v3"></path><path d="M20 9h3"></path><path d="M20 14h3"></path><path d="M1 9h3"></path><path d="M1 14h3"></path></svg></div><div class="content_ffeU"><span class="label_ZJga">Part of Project</span><span class="title_nHJN">Iron Man Suit with AWS IoT</span><span class="description_NSta">3D printed Iron Man suit integrated with AWS IoT Core and ESP32</span></div><div class="arrow_LuKy"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg></div></div></a></div>
<p><img decoding="async" loading="lazy" alt="Iron Man Mark 85 Faceplate" src="https://www.chiwaichan.co.nz/assets/images/mark-85-faceplate-973f7d656ebca5be7295103e6103787f.png" width="3314" height="1882" class="img_ev3q"></p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/gma3qcVY0To" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>]]></content:encoded>
            <category>IoT</category>
            <category>3DPrinting</category>
            <category>3D Printer</category>
        </item>
    </channel>
</rss>