<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://jaredrhodes.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jaredrhodes.com/" rel="alternate" type="text/html" /><updated>2026-08-24T16:08:36+00:00</updated><id>https://jaredrhodes.com/feed.xml</id><title type="html">Cloud | Mobile | Edge</title><subtitle>Jared Rhodes - Microsoft Azure MVP and software architect building edge and hybrid cloud demos across Azure, AWS, and local infrastructure.</subtitle><author><name>Jared Rhodes</name><uri>https://jaredrhodes.com/about</uri></author><entry><title type="html">Specialized Hardware Without an App Rewrite</title><link href="https://jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/" rel="alternate" type="text/html" title="Specialized Hardware Without an App Rewrite" /><published>2026-08-24T09:00:00+00:00</published><updated>2026-08-24T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite</id><content type="html" xml:base="https://jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li class="series-nav-current" aria-current="true">Specialized Hardware Without an App Rewrite</li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>New hardware is fun. Rewriting a working application to accommodate it is not. That trade shows up in almost every accelerator demo I have sat through: the board goes live, and quietly the app grows a special client library, then a special request shape, then a special set of apologies for whenever the board is having a bad day. If adding an accelerator means rewriting the app, the hardware lane has become an island.</p>

<p>Demo 6 keeps the application completely unchanged. Tenstorrent - or a mock accelerator that behaves like one - registers as another backend behind the AI on the Edge Gateway, and the routing policy decides what that is worth.</p>

<h2 id="how-hardware-becomes-an-island">How Hardware Becomes an Island</h2>

<p>Accelerator demos tend to drift toward hardware-specific code paths:</p>

<ul>
  <li>A different client library.</li>
  <li>A different request shape.</li>
  <li>A different health check.</li>
  <li>A different dashboard.</li>
  <li>A different fallback story.</li>
</ul>

<p>Each step is defensible on its own during bring-up, and together they are bad for application architecture. The application should ask for a workload. The router should decide whether an accelerator is eligible, healthy, and preferred.</p>

<p>Nobody here is claiming one hardware path is always faster. The claim is narrower and more useful: specialized hardware can join the governed edge platform without becoming a separate product.</p>

<h2 id="one-more-backend-in-the-registry">One More Backend in the Registry</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/accelerator-lane.svg" alt="Specialized accelerator lane: the same app request carries an AcceleratorPreferred workload tag to the OpenAI-compatible gateway, which performs capability discovery, health checks, saturation handling, and routes to Tenstorrent, mock accelerator, or local fallback backends" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The whole trick is that the accelerator lane is just a backend registration, using the same registry schema as every other backend:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tenstorrent-edge"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OpenAICompatible"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://tenstorrent-edge:8000/v1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"accelerator-chat"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"edge"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"streaming"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"local"</span><span class="p">,</span><span class="w"> </span><span class="s2">"accelerator"</span><span class="p">,</span><span class="w"> </span><span class="s2">"specialized-hardware"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"priority"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/accelerator-compatibility-gates.svg" alt="Accelerator compatibility gates: endpoint registration, model discovery, and backend tags must pass chat, streaming, embeddings, and saturation checks before the router prefers the accelerator, falls back locally, or denies unsupported capabilities" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>From there the gateway treats Tenstorrent like any other OpenAI-compatible backend until it has a reason not to:</p>

<ul>
  <li>Discover models through <code class="language-plaintext highlighter-rouge">/v1/models</code> or a configured model list.</li>
  <li>Record capabilities explicitly.</li>
  <li>Test chat, streaming, and embeddings independently.</li>
  <li>Track latency, status codes, saturation, selected count, and fallback count.</li>
  <li>Expose compatibility failures in the route trace.</li>
</ul>

<p>The routing policy is <code class="language-plaintext highlighter-rouge">AcceleratorPreferred</code>, not <code class="language-plaintext highlighter-rouge">AcceleratorOnly</code>. If the accelerator is healthy and supports the requested model family, it wins. If it is unhealthy or saturated, the router picks another eligible local backend, and the audience never has to think about it.</p>

<p>One status caveat I want on the record: as of August 2026, Tenstorrent’s documentation describes TT-Inference-Server for deploying LLM serving on its hardware, including container management, model downloads, serving configuration, and an OpenAI-compatible API endpoint. Model support still depends on the validated hardware and software combination, which is exactly why the gateway discovers and displays capability rather than assuming it.</p>

<h2 id="what-the-demo-has-to-prove">What the Demo Has to Prove</h2>

<p>The proof the audience needs is that no application code changes. Everything else is staging:</p>

<ol>
  <li>Show the backend registry: Foundry Local, Azure Foundry, Tenstorrent, mock accelerator.</li>
  <li>Ask the same operational question used in Demo 1.</li>
  <li>Add workload metadata: <code class="language-plaintext highlighter-rouge">AcceleratorPreferred</code>.</li>
  <li>Show the router selects <code class="language-plaintext highlighter-rouge">tenstorrent-edge</code> or <code class="language-plaintext highlighter-rouge">mock-accelerator</code>.</li>
  <li>Stream the response through the same app and gateway.</li>
  <li>Show metrics and backend health.</li>
  <li>Mark the accelerator saturated.</li>
  <li>Ask again.</li>
  <li>Show fallback to another eligible local backend.</li>
  <li>Show the app request did not change.</li>
</ol>

<p>Step ten is the entire point. Steps two through nine exist to make step ten believable.</p>

<p>I also avoid benchmark claims unless the evaluation harness produced them. The architecture claim here is portability and governance, not a vague performance win - vague wins are exactly what skeptical rooms smell first.</p>

<h2 id="what-is-actually-left-to-build">What Is Actually Left to Build</h2>

<p>Most of this lane is assembly, not invention. The private cloud buildout already describes the Tenstorrent lab path and the mixed accelerator environment. The Demo 1 gateway already provides the abstraction that keeps the app unchanged, and the Demo 5 design supplies saturation and fallback controls. What remains is adapter hardening:</p>

<ul>
  <li>Tenstorrent backend config profile.</li>
  <li>Capability discovery and display.</li>
  <li>Health check and timeout behavior.</li>
  <li>Compatibility tests for the supported API paths.</li>
  <li>Accelerator-preferred routing policy.</li>
  <li>Saturation fallback behavior.</li>
  <li>Optional hardware metrics later, starting with endpoint-level metrics now.</li>
</ul>

<p>The mock accelerator is required, not nice-to-have. Real hardware is valuable, but the session still has to work when the board is offline, in use by something else, or running a different model than the demo expects. A dependency I cannot reproduce on demand is not a demo plan; it is a coin flip.</p>

<h2 id="where-overpromising-starts">Where Overpromising Starts</h2>

<p>The failure mode is overpromising hardware support. OpenAI-compatible does not mean feature-complete: chat, streaming, embeddings, tool calls, batch behavior, and error semantics all need separate smoke tests, because each one is its own little contract the hardware may or may not honor.</p>

<p>So the gateway displays capability and denial explicitly instead of discovering the gap mid-request:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"backendId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tenstorrent-edge"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"eligible"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Backend does not advertise embeddings capability."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>An honest “no” from the router beats a mystery timeout from the hardware every time.</p>

<h2 id="what-done-looks-like">What Done Looks Like</h2>

<p>Demo 6 is complete when:</p>

<ol>
  <li>Tenstorrent or mock accelerator can be registered as a backend.</li>
  <li>The router can prefer it for tagged workloads.</li>
  <li>The app does not change when the backend changes.</li>
  <li>Fallback works when the accelerator is unhealthy or saturated.</li>
  <li>Metrics show selected backend, latency, errors, and fallback.</li>
  <li>No performance claims appear unless produced by the evaluation harness.</li>
</ol>

<p>Criterion three is the one I watch. The moment swapping a backend means touching the application again, this stopped being an architecture and became a science project.</p>

<h2 id="related-posts">Related Posts</h2>

<p>This post connects the <a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Tenstorrent private cloud buildout</a> to the AI on the Edge gateway. The hardware is part of the architecture, but not the whole architecture - that distinction is why the application survived this demo untouched.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://docs.tenstorrent.com/tools/index.html">Tenstorrent tools documentation</a></li>
  <li><a href="https://docs.tenstorrent.com/getting-started/vLLM-servers.html">Deploy LLMs with TT-Inference-Server</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="tenstorrent" /><category term="home-lab" /><category term="dotnet" /><category term="model-routing" /><category term="observability" /><category term="accelerator" /><summary type="html"><![CDATA[AI on the Edge Demo 6: registering a Tenstorrent or mock accelerator as an OpenAI-compatible gateway backend with capability discovery, health checks, fallback, and accelerator-preferred routing - no application rewrite.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/specialized-hardware-without-an-app-rewrite.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/specialized-hardware-without-an-app-rewrite.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">When the Edge Has to Stand Alone</title><link href="https://jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/" rel="alternate" type="text/html" title="When the Edge Has to Stand Alone" /><published>2026-08-17T09:00:00+00:00</published><updated>2026-08-17T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/when-the-edge-has-to-stand-alone</id><content type="html" xml:base="https://jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li class="series-nav-current" aria-current="true">When the Edge Has to Stand Alone</li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>The edge has to stand alone sometimes. Internet links fail. Cloud services throttle. A local model crashes mid-request, an accelerator endpoint saturates, and sooner or later a response adapter meets a payload that is almost, but not quite, OpenAI-compatible. None of that is hypothetical; it is just a Tuesday.</p>

<p>Demo 5 is the failure lab - a controlled set of ways for all of that to happen on demand - built to prove the architecture fails in visible, policy-correct ways. Happy-path demos are easy to fake, and failure behavior is part of the product here, so I would rather show it on purpose than meet it for the first time on stage.</p>

<h2 id="why-build-a-failure-lab">Why Build a Failure Lab</h2>

<p>Happy-path AI demos are easy to fake. Real systems need answers to harder questions:</p>

<ul>
  <li>What happens when the cloud is unavailable?</li>
  <li>What happens when the local model is unavailable?</li>
  <li>What happens when the best backend is slow or saturated?</li>
  <li>What happens when a backend returns malformed output?</li>
  <li>What happens when a restricted request has no eligible backend?</li>
  <li>Can the presenter reset the demo without debugging state live?</li>
</ul>

<p>The failure behavior is part of the product, so the demo has to be able to show it on purpose rather than by accident.</p>

<h2 id="inside-the-failure-lab">Inside the Failure Lab</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/failure-lab.svg" alt="Failure lab architecture: demo controls inject backend, cloud, and LocalOnly faults; the gateway records fault state, reevaluates healthy eligible backends, then falls back, denies, or continues local operation while reset and smoke tests verify behavior" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>In the design, <code class="language-plaintext highlighter-rouge">AiOnTheEdge.DemoScenarios</code> owns deterministic failures and reset controls behind the shared demo endpoints. Fault types are posted to the single faults endpoint, and reset is the shared endpoint every service exposes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /admin/demo/faults   { "fault": "backend-down",      "backendId": "&lt;id&gt;" }
POST /admin/demo/faults   { "fault": "backend-slow",      "backendId": "&lt;id&gt;" }
POST /admin/demo/faults   { "fault": "backend-malformed", "backendId": "&lt;id&gt;" }
POST /admin/demo/faults   { "fault": "network-cloud-blocked" }
POST /admin/demo/faults   { "fault": "policy-local-only" }
POST /admin/demo/reset    clears active faults and restores the seeded state
</code></pre></div></div>

<p>The dashboard needs a Failure Lab page:</p>

<table>
  <thead>
    <tr>
      <th>Control</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Toggle backend down</td>
      <td>Backend health changes and route decisions adapt.</td>
    </tr>
    <tr>
      <td>Force restricted prompt</td>
      <td>Policy changes to <code class="language-plaintext highlighter-rouge">LocalOnly</code>.</td>
    </tr>
    <tr>
      <td>Block cloud</td>
      <td>Cloud backends become unavailable.</td>
    </tr>
    <tr>
      <td>Return malformed response</td>
      <td>Adapter error is recorded and fallback is evaluated safely.</td>
    </tr>
    <tr>
      <td>Run smoke test</td>
      <td>Pass/fail appears for all primary demo scenarios.</td>
    </tr>
    <tr>
      <td>Reset</td>
      <td>Stable state returns without manual cleanup.</td>
    </tr>
  </tbody>
</table>

<p>Faults should be simple and explicit - every one maps to something boring that can actually happen:</p>

<table>
  <thead>
    <tr>
      <th>Fault</th>
      <th>Implementation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Backend down</td>
      <td>Disable backend or point to a dead URL.</td>
    </tr>
    <tr>
      <td>Backend slow</td>
      <td>Add delay in mock backend.</td>
    </tr>
    <tr>
      <td>Malformed response</td>
      <td>Return invalid OpenAI-compatible payload.</td>
    </tr>
    <tr>
      <td>Cloud blocked</td>
      <td>Mark cloud backends unhealthy in demo mode.</td>
    </tr>
    <tr>
      <td>Local unavailable</td>
      <td>Stop or disable local adapter.</td>
    </tr>
    <tr>
      <td>Accelerator saturated</td>
      <td>Return HTTP 429 or a configured saturation signal.</td>
    </tr>
  </tbody>
</table>

<h2 id="staging-a-fail-closed-denial">Staging a Fail-Closed Denial</h2>

<p>A fail-closed denial is the moment worth staging. The sequence I use:</p>

<ol>
  <li>Start with all backends healthy.</li>
  <li>Ask a normal public question and show a valid route.</li>
  <li>Mark the prompt restricted and show <code class="language-plaintext highlighter-rouge">LocalOnly</code>.</li>
  <li>Disable the local backend.</li>
  <li>Ask again and show denial because cloud is not eligible.</li>
  <li>Change policy to <code class="language-plaintext highlighter-rouge">CloudAllowed</code> for nonrestricted content.</li>
  <li>Show fallback to an allowed backend.</li>
  <li>Block cloud.</li>
  <li>Show local RAG and camera incident summaries still work with cached models and local data.</li>
  <li>Run <code class="language-plaintext highlighter-rouge">make demo-smoke-test</code>.</li>
  <li>Reset the demo.</li>
</ol>

<p>The edge does not have to answer every request. It has to answer the requests it is allowed to answer and refuse the rest clearly. That sentence is the whole design review, and everything else in this post is machinery for proving it.</p>

<h2 id="parts-that-can-already-break">Parts That Can Already Break</h2>

<p>The earlier demos already provide the components that can fail: gateway route selection, RAG retrieval and generation, camera event replay, Azure-backed telemetry, and accelerator backend registration. Demo 5 turns those components into test cases instead of ad hoc failure stories.</p>

<h2 id="the-new-work-is-the-harness">The New Work Is the Harness</h2>

<p>The new build is the failure harness itself:</p>

<ul>
  <li>Central fault state.</li>
  <li>Reset and seed endpoints.</li>
  <li>Smoke-test runner.</li>
  <li>Failure Lab UI.</li>
  <li>Deterministic mock backend behaviors.</li>
  <li>Local telemetry when Azure is unavailable.</li>
  <li>Dashboard panels for denials, fallbacks, adapter errors, and reset status.</li>
</ul>

<p>The smoke test should validate the contract the presenter needs:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make demo-reset
make demo-seed
make demo-smoke-test
</code></pre></div></div>

<p>That test should cover local route, cloud-allowed fallback, local-only denial, malformed response handling, camera replay, RAG citation, and reset. When it passes, I stop worrying about whatever the venue’s Wi-Fi is doing.</p>

<h2 id="too-many-faults-ruin-the-show">Too Many Faults Ruin the Show</h2>

<p>The failure lab can become too noisy. The audience should see two or three failures live, not every possible fault; a parade of toggles is its own kind of confusion. The best live sequence is:</p>

<ol>
  <li>Local backend down.</li>
  <li>Restricted prompt denied.</li>
  <li>Cloud blocked but local RAG still answers.</li>
</ol>

<p>Everything else is useful for validation and backup, but not every control needs to be shown in a 45-minute talk.</p>

<p>It pays to be precise about what offline actually means here: local application behavior can continue after warmup, while live Azure management and cloud telemetry depend on connectivity and prior setup. Anything vaguer and someone walks away convinced the whole stack runs air-gapped forever.</p>

<h2 id="what-done-looks-like">What Done Looks Like</h2>

<p>Demo 5 is complete when:</p>

<ol>
  <li>Every fault can be enabled and reset through API and UI.</li>
  <li>The gateway never routes restricted prompts to cloud.</li>
  <li>Backend failures produce clear user-facing and telemetry-facing reasons.</li>
  <li><code class="language-plaintext highlighter-rouge">make demo-smoke-test</code> covers every primary failure path.</li>
  <li>At least one complete demo path runs with no internet connection after warmup.</li>
  <li>Reset is designed to return the system to a known state in under one minute, fast enough to run between talk segments.</li>
</ol>

<p>That reset budget is not vanity. A demo you cannot recover from between segments is a demo you will only run once.</p>

<h2 id="related-posts">Related Posts</h2>

<p>This is the credibility test for the gateway, RAG assistant, operations assistant, and Azure operations layer. It should be built before depending on real hardware or live cloud services in a session - credibility is much cheaper to install up front.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/what-is-foundry-local">What is Foundry Local?</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-best-practice">Best practices and troubleshooting guide for Foundry Local CLI</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/overview-iot-operations">Azure IoT Operations overview</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/connect-to-cloud/overview-dataflow">Process and route data with data flows</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="foundry-local" /><category term="azure-iot-operations" /><category term="observability" /><category term="resilience" /><category term="dotnet" /><category term="offline" /><summary type="html"><![CDATA[Demo 5 of AI on the Edge: disconnected operation, backend failures, malformed responses, cloud-blocked mode, local-only denial, smoke tests, and reset controls.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/when-the-edge-has-to-stand-alone.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/when-the-edge-has-to-stand-alone.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Edge AI You Can Actually Operate</title><link href="https://jaredrhodes.com/blog/edge-ai-you-can-actually-operate/" rel="alternate" type="text/html" title="Edge AI You Can Actually Operate" /><published>2026-08-10T09:00:00+00:00</published><updated>2026-08-10T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/edge-ai-you-can-actually-operate</id><content type="html" xml:base="https://jaredrhodes.com/blog/edge-ai-you-can-actually-operate/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li class="series-nav-current" aria-current="true">Edge AI You Can Actually Operate</li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>Answering prompts is not the same as operating a system, a distinction I did not fully appreciate until the demos started stacking up. Teams also have to deploy it, secure it, observe it, rotate its secrets, understand its failures, and prove policy decisions after the fact. That is what Demo 4 sets up with Azure. The model may run locally, but the estate should still be visible and governable.</p>

<h2 id="local-ai-goes-invisible">Local AI Goes Invisible</h2>

<p>Local AI can become invisible infrastructure, and it happens without anyone deciding anything. A model server starts on a developer machine. A Kubernetes deployment gets copied to an edge node. A gateway ends up with API keys in a config file. Logs stay local. Metrics are whatever the process prints. Nobody can tell which requests went where. I have watched perfectly serious systems drift into exactly that state one shortcut at a time, and invisibility is not an operating model an enterprise can run on.</p>

<p>For AI on the Edge, every local execution path needs an operations path:</p>

<ul>
  <li>How was it deployed?</li>
  <li>Which version is running?</li>
  <li>Which backends are healthy?</li>
  <li>Which requests fell back?</li>
  <li>Which requests were denied?</li>
  <li>Where are secrets stored?</li>
  <li>Which policies are being enforced?</li>
</ul>

<h2 id="the-control-plane-around-local-execution">The Control Plane Around Local Execution</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/azure-governance-operations.svg" alt="Azure governance and observability architecture: an edge cluster running the gateway and assistants emits metrics and route events while Azure Arc, GitOps, Policy, Monitor, Managed Prometheus, Grafana, and Key Vault provide management, telemetry, dashboards, and secrets" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The Azure-governed mode uses Azure as the control plane around local execution. Each layer gets exactly one job:</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Azure Arc-enabled Kubernetes</td>
      <td>Brings the edge cluster into Azure inventory and management.</td>
    </tr>
    <tr>
      <td>GitOps with Flux</td>
      <td>Reconciles the cluster from Git.</td>
    </tr>
    <tr>
      <td>Azure Monitor and Log Analytics</td>
      <td>Centralizes routing events, incident events, and service logs.</td>
    </tr>
    <tr>
      <td>Managed Prometheus</td>
      <td>Scrapes service and Kubernetes metrics.</td>
    </tr>
    <tr>
      <td>Azure Managed Grafana</td>
      <td>Presents routing, latency, privacy, and incident dashboards.</td>
    </tr>
    <tr>
      <td>Key Vault</td>
      <td>Stores backend API keys, certificates, and demo secrets.</td>
    </tr>
    <tr>
      <td>Policy</td>
      <td>Enforces approved endpoints, local-only classifications, tags, and secret-source rules.</td>
    </tr>
  </tbody>
</table>

<p>The Kubernetes deployment should be conventional:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>infra/
  azure/
  kubernetes/
  arc/
  dashboards/
  policies/
</code></pre></div></div>

<p>Cluster resources should include:</p>

<table>
  <thead>
    <tr>
      <th>Resource</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Gateway deployment</td>
      <td>OpenAI-compatible facade and route policy.</td>
    </tr>
    <tr>
      <td>Knowledge Assistant deployment</td>
      <td>Private RAG service.</td>
    </tr>
    <tr>
      <td>Operations Assistant deployment</td>
      <td>IoT incident assistant.</td>
    </tr>
    <tr>
      <td>Dashboard deployment</td>
      <td>Control UI.</td>
    </tr>
    <tr>
      <td>ServiceMonitor or PodMonitor</td>
      <td>Prometheus scraping.</td>
    </tr>
    <tr>
      <td>SecretProviderClass</td>
      <td>Key Vault-backed secret mounting where enabled.</td>
    </tr>
    <tr>
      <td>Ingress</td>
      <td>TLS endpoint for gateway and dashboard.</td>
    </tr>
    <tr>
      <td>Demo namespace</td>
      <td>Isolated presentation environment.</td>
    </tr>
  </tbody>
</table>

<p>The dashboards should make the architecture measurable:</p>

<ul>
  <li>Requests by backend.</li>
  <li>Fallback count.</li>
  <li>Denied count.</li>
  <li>P50/P95/P99 latency.</li>
  <li>Prompt body logging posture.</li>
  <li>Local-only request count.</li>
  <li>Backend health.</li>
  <li>Camera events by site and type.</li>
  <li>Active incidents and summaries.</li>
</ul>

<p>If a number is not on a dashboard, it does not exist during an outage.</p>

<h2 id="how-the-talk-actually-runs">How the Talk Actually Runs</h2>

<p>Policy evidence has to land in three places at once: the app response, the dashboards, and the query history. Any one of them alone is a story; together they are proof. So the talk runs like this:</p>

<ol>
  <li>Show the app running at the edge.</li>
  <li>Show the Arc-connected cluster in Azure.</li>
  <li>Show GitOps sync status.</li>
  <li>Send requests through the gateway.</li>
  <li>Open Grafana and show backend selection, latency, fallback, and denial metrics.</li>
  <li>Open Log Analytics and run a KQL query over routing events.</li>
  <li>Trigger a <code class="language-plaintext highlighter-rouge">LocalOnly</code> denial.</li>
  <li>Show the denial in the app, dashboard, and KQL.</li>
  <li>Rotate or change a backend secret if implemented, then show config reload or rollout status.</li>
</ol>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/operations-evidence-flow.svg" alt="Azure operations evidence flow: routing, inference, and camera events flow into Log Analytics and policy records, Managed Prometheus scrapes service metrics endpoints, and the results appear in Grafana dashboards, KQL queries, and Arc or GitOps status views" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>One caveat before anyone pastes these into their own workspace: the queries below target the planned Log Analytics schema. <code class="language-plaintext highlighter-rouge">AiRoutingEvents</code>, <code class="language-plaintext highlighter-rouge">AiInferenceRequests</code>, and <code class="language-plaintext highlighter-rouge">CameraEvents</code> exist once the ingestion pipeline ships, so read them as the observability contract rather than as queries against a live workspace:</p>

<pre><code class="language-kusto">AiRoutingEvents
| summarize count() by selectedBackend, policy, decision
</code></pre>

<pre><code class="language-kusto">AiRoutingEvents
| where policy == "LocalOnly" and decision == "Denied"
</code></pre>

<pre><code class="language-kusto">AiInferenceRequests
| summarize p95Latency=percentile(latencyMs, 95) by backendId, bin(timestamp, 5m)
</code></pre>

<pre><code class="language-kusto">CameraEvents
| summarize count() by siteId, cameraId, eventType, bin(timestamp, 5m)
</code></pre>

<h2 id="signals-that-already-exist">Signals That Already Exist</h2>

<p>The earlier demos already emit the signals worth collecting: gateway routing decisions, RAG retrieval and privacy decisions, camera incident summaries, and backend health and failures. The private cloud buildout already frames Arc and GitOps as the management overlay for the local Kubernetes environment. Demo 4 turns those signals into operational evidence instead of log lines nobody reads.</p>

<h2 id="what-had-to-be-built">What Had to Be Built</h2>

<p>The new work is infrastructure and observability - none of it glamorous, all of it load-bearing:</p>

<ul>
  <li>Bicep or Terraform for the Azure resources.</li>
  <li>Helm or Kustomize for Kubernetes deployment.</li>
  <li>Managed Prometheus scrape config.</li>
  <li>Grafana dashboard JSON.</li>
  <li>KQL saved queries.</li>
  <li>Key Vault secret integration for the Azure-governed path.</li>
  <li>Application policy events that match dashboard and query fields.</li>
</ul>

<p>Policy examples, split by the layer that actually enforces them. This split matters more than it looks, because people assume Azure Policy reaches inside applications, and it does not:</p>

<table>
  <thead>
    <tr>
      <th>Policy</th>
      <th>Enforced by</th>
      <th>Demo behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Approved model endpoints only</td>
      <td>Application policy (gateway admin surface)</td>
      <td>Unknown endpoint cannot be enabled.</td>
    </tr>
    <tr>
      <td>Local-only classification</td>
      <td>Application policy (routing engine)</td>
      <td>Restricted workload cannot route to cloud.</td>
    </tr>
    <tr>
      <td>No prompt body logging</td>
      <td>Application policy (telemetry pipeline)</td>
      <td>Restricted prompts emit metadata-only telemetry.</td>
    </tr>
    <tr>
      <td>Required tags and labels</td>
      <td>Azure Policy</td>
      <td>Manifests include <code class="language-plaintext highlighter-rouge">app</code>, <code class="language-plaintext highlighter-rouge">scenario</code>, <code class="language-plaintext highlighter-rouge">owner</code>, and <code class="language-plaintext highlighter-rouge">data-classification</code>.</td>
    </tr>
    <tr>
      <td>Required secret source</td>
      <td>Azure Policy</td>
      <td>Production manifests cannot use raw API keys.</td>
    </tr>
  </tbody>
</table>

<p>To say it plainly: Azure Policy governs Azure resources such as tags and secret sources; it cannot block a gateway from enabling an unknown model backend. The routing boundaries in this demo are application-level policy events, surfaced through the same evidence flow as the Azure-side rules.</p>

<h2 id="the-brittle-demo-trap">The Brittle Demo Trap</h2>

<p>The failure mode here is demo brittleness from trying to install cloud operations live during a talk. Do not do that. Preflight the Azure path, keep a recorded or screenshot backup for portal views, and run the local dashboard live.</p>

<p>Arc and Azure Monitor require outbound connectivity and setup. Disconnected demos should show local behavior and buffered telemetry, not pretend Azure is live while the edge is offline. Nobody in the audience can see your resource group anyway; everybody can see a stalled terminal.</p>

<h2 id="what-done-looks-like">What Done Looks Like</h2>

<p>Demo 4 is complete when:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">make infra-plan</code> and <code class="language-plaintext highlighter-rouge">make infra-apply</code> produce repeatable Azure resources.</li>
  <li>Gateway, assistants, and dashboard deploy through Helm or Kustomize.</li>
  <li>Metrics are visible locally and in Managed Prometheus when Azure is connected.</li>
  <li>Grafana dashboards can be imported automatically.</li>
  <li>Log Analytics receives routing and incident events.</li>
  <li>Policy denial is visible in app logs, dashboard, and KQL.</li>
  <li><code class="language-plaintext highlighter-rouge">make teardown</code> removes demo resources safely.</li>
</ol>

<p>That last one matters more than it sounds. Leaving resources behind is how a demo quietly becomes a bill.</p>

<h2 id="related-posts">Related Posts</h2>

<p>This post makes the prior demos operational. It depends on the gateway routing events from <a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a> and the camera incident events from <a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a>.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-arc/kubernetes/overview">Azure Arc-enabled Kubernetes overview</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-arc/kubernetes/conceptual-gitops-flux2">Application deployments with GitOps using Flux v2</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-monitor/containers/kubernetes-monitoring-enable-arc">Enable monitoring for Arc-enabled Kubernetes clusters</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-arc/kubernetes/tutorial-akv-secrets-provider">Use Azure Key Vault Secrets Provider extension with Arc-enabled Kubernetes</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="azure-arc" /><category term="azure-local" /><category term="azure-monitor" /><category term="grafana" /><category term="observability" /><category term="gitops" /><category term="key-vault" /><summary type="html"><![CDATA[Demo 4 of AI on the Edge: operating local AI with Azure Arc, GitOps, Azure Monitor, Managed Prometheus, Grafana, Key Vault, policy events, KQL, and dashboard evidence.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/edge-ai-you-can-actually-operate.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/edge-ai-you-can-actually-operate.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From Camera Events to Operator Guidance</title><link href="https://jaredrhodes.com/blog/from-camera-events-to-operator-guidance/" rel="alternate" type="text/html" title="From Camera Events to Operator Guidance" /><published>2026-08-03T09:00:00+00:00</published><updated>2026-08-03T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/from-camera-events-to-operator-guidance</id><content type="html" xml:base="https://jaredrhodes.com/blog/from-camera-events-to-operator-guidance/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li class="series-nav-current" aria-current="true">From Camera Events to Operator Guidance</li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>Back in 2019 a vendor shut down the cloud behind my cameras and bricked them; I repurposed what was left instead of throwing the hardware out, and the fleet never left. So when this series needed a real workload instead of a generic AI sample, the camera fleet was the obvious pick. Availability, RTSP loss, stale heartbeats, motion bursts, command acknowledgements, config drift, and connector-fronted cameras all flow through the existing <code class="language-plaintext highlighter-rouge">cameras/#</code> contract already.</p>

<p>The goal is not asking a model to read random logs. The goal is turning structured edge events into operator guidance without changing the camera control plane. Those are two very different projects, and I only signed up for one of them.</p>

<h2 id="what-an-operator-actually-needs">What an Operator Actually Needs</h2>

<p>Edge operators do not need another dashboard full of raw events. I have stared at enough of those to know they mostly teach you where to look next. What an operator wants when a site goes quiet is simple: what happened, what is impacted, what evidence supports that conclusion, and what action should happen first.</p>

<p>The camera control plane already has the right shape for this:</p>

<ul>
  <li>Cameras or gateways connect outbound.</li>
  <li>They publish status, inventory, availability, events, metrics, command acknowledgements, and logs.</li>
  <li>The topic tree is consistent: <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/&lt;channel&gt;</code>.</li>
  <li>Azure IoT Operations can sit under that topic contract as the MQTT broker and edge data plane.</li>
  <li>A data flow can forward the same <code class="language-plaintext highlighter-rouge">cameras/#</code> stream northbound without changing producers.</li>
</ul>

<p>So the operations assistant builds on that contract. It does not create a second camera model. One camera model in this house is plenty.</p>

<h2 id="three-ways-in-one-event-shape">Three Ways In, One Event Shape</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/camera-ops-assistant.svg" alt="Camera operations assistant architecture: simulated replay, MQTT cameras/#, and Event Hubs telemetry feed event normalization, fleet state, incident building, runbook retrieval, prompt building, and operator output with LocalOnly route evidence" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>In the design, <code class="language-plaintext highlighter-rouge">AiOnTheEdge.OperationsAssistant</code> takes input three ways. Simulated mode replays JSON from <code class="language-plaintext highlighter-rouge">samples/camera-events</code>, which is the reliable presentation path. MQTT mode subscribes to <code class="language-plaintext highlighter-rouge">cameras/#</code>, which is the local edge path. Event Hubs mode consumes forwarded camera telemetry, which is the cloud analytics path.</p>

<p>Every input mode normalizes events into one shape. The sample below is synthetic:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"eventId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evt-001"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"timestampUtc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-30T12:00:00Z"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"siteId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"remote1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"cameraId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-east"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"channel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"event"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"eventType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rtsp_loss"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"warning"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"streamUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rtsp://camera/stream1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"durationSeconds"</span><span class="p">:</span><span class="w"> </span><span class="mi">90</span><span class="p">,</span><span class="w">
    </span><span class="nl">"lastFrameUtc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-30T11:58:30Z"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>From there the service builds incidents from facts. Six pieces split the work:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CameraEventIngestWorker</code> reads replay files, MQTT, or Event Hubs.</li>
  <li><code class="language-plaintext highlighter-rouge">FleetStateStore</code> tracks latest status, inventory, availability, version, config hash, and recent events.</li>
  <li><code class="language-plaintext highlighter-rouge">IncidentBuilder</code> groups related events by site, camera, event type, time window, and severity.</li>
  <li><code class="language-plaintext highlighter-rouge">RunbookRetriever</code> pulls relevant local runbook chunks from the Knowledge Assistant.</li>
  <li><code class="language-plaintext highlighter-rouge">OperatorPromptBuilder</code> creates a compact prompt from structured facts and runbook snippets.</li>
  <li><code class="language-plaintext highlighter-rouge">OperationsAssistantController</code> exposes incident summaries and fleet questions.</li>
</ul>

<p>And whatever the assistant answers, evidence rides along with it:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"summary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Remote property 1 likely has a site-level network issue."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"impact"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"garage-east offline"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"driveway-west offline"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"front-door heartbeat stale"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"evidence"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"timestampUtc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-30T11:58:30Z"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"cameraId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-east"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"eventType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rtsp_loss"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"recommendedActions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"Check the remote1 camera VLAN gateway and VPN tunnel."</span><span class="p">,</span><span class="w">
    </span><span class="s2">"Verify NTP and DNS availability for the camera VLAN."</span><span class="p">,</span><span class="w">
    </span><span class="s2">"Avoid rebooting individual cameras until site connectivity is confirmed."</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"routeDecision"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LocalOnly"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"selectedBackend"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foundry-local"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="how-the-talk-actually-runs">How the Talk Actually Runs</h2>

<p>The moment worth staging takes a raw event through incident grouping to a first action. Here is the run order I actually use:</p>

<ol>
  <li>Start replay: <code class="language-plaintext highlighter-rouge">site-network-partition</code>.</li>
  <li>Show raw events arriving with their original <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/&lt;channel&gt;</code> topics.</li>
  <li>Show normalized events updating fleet state.</li>
  <li>Ask: <code class="language-plaintext highlighter-rouge">What happened at remote1 in the last 15 minutes?</code></li>
  <li>Show the assistant summary, impacted cameras, evidence, runbook citations, and first actions.</li>
  <li>Open the route trace and show local-only inference due to operational camera telemetry.</li>
  <li>Trigger <code class="language-plaintext highlighter-rouge">config-drift</code>.</li>
  <li>Ask: <code class="language-plaintext highlighter-rouge">Which cameras need config remediation?</code></li>
  <li>Show desired versus reported config hash and the <code class="language-plaintext highlighter-rouge">apply_config</code> recommendation.</li>
  <li>Show that the same raw events can also flow through Azure IoT Operations and Event Hubs in the full Azure path.</li>
</ol>

<p>Notice what the model never does: invent cameras, sites, or causes. It summarizes only from structured incident facts and retrieved runbook text. If the facts do not name a cause, neither does the assistant.</p>

<h2 id="standing-on-the-camera-series">Standing on the Camera Series</h2>

<p>The three camera posts already define the control plane, and this demo does not reopen it:</p>

<ul>
  <li>Part 1 defines the topic tree, network model, command set, desired/reported state, and camera classes.</li>
  <li>Part 2 swaps the broker under the same contract to Azure IoT Operations.</li>
  <li>Part 3 forwards <code class="language-plaintext highlighter-rouge">cameras/#</code> to Event Hubs and adds the ONVIF connector bridge path.</li>
</ul>

<p>Those posts are prerequisites here; this one adds a local AI operations layer on top and stops arguing about cameras. The new build is just the operations surface:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /camera-events
GET  /fleet/sites
GET  /fleet/sites/{siteId}
GET  /fleet/cameras/{siteId}/{cameraId}
GET  /incidents
GET  /incidents/{incidentId}
POST /incidents/{incidentId}/summarize
POST /fleet/query
POST /admin/demo/replay/{scenarioName}
</code></pre></div></div>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/camera-incident-scenarios.svg" alt="Camera operations seeded incident scenarios: RTSP loss, site partition, stale agent version, config drift, motion burst, and connector-fronted camera events feed normalization, incident grouping, hallucination guards, and operator guidance with summary, evidence, and first actions" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>Six seed scenarios give the demo its plot:</p>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Events</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rtsp-loss-single-camera</code></td>
      <td>One camera reachable but RTSP failing.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">site-network-partition</code></td>
      <td>Multiple cameras offline at one site within 90 seconds.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">stale-agent-version</code></td>
      <td>Camera healthy but agent version behind desired version.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">config-drift</code></td>
      <td>Desired config hash differs from reported config hash.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">motion-burst</code></td>
      <td>Many motion events across cameras at one site.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">connector-fronted-camera</code></td>
      <td>AIO connector event mapped back into the camera contract.</td>
    </tr>
  </tbody>
</table>

<p>Each scenario lives in <code class="language-plaintext highlighter-rouge">AiOnTheEdge.DemoScenarios</code> as a deterministic script of normalized events, so the demo behaves the same way in a hotel ballroom as it did on my desk. For example, the shape of the <code class="language-plaintext highlighter-rouge">site-network-partition</code> scenario file is:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"scenarioName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"site-network-partition"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Several cameras at one site lose connectivity inside a 90-second window."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"events"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"offsetSeconds"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"eventId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evt-partition-001"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"siteId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"remote1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"cameraId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-east"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"channel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"event"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"eventType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rtsp_loss"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"warning"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"offsetSeconds"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</span><span class="p">,</span><span class="w">
      </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"eventId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evt-partition-002"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"siteId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"remote1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"cameraId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"driveway-west"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"channel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"availability"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"eventType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"offline"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"error"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="the-hallucination-trap">The Hallucination Trap</h2>

<p>The failure to avoid is a confident hallucinated incident. An assistant that infers a site outage because it sounds plausible is worse than no assistant at all; somebody will act on that answer and start rebooting the wrong things. It should only name a cause when the structured facts and runbook snippets support it.</p>

<p>For a live demo, the simulator is the default. Real cameras and Azure IoT Operations are valuable, but the presentation should not depend on a camera or a VPN behaving perfectly. Talks provide enough surprises on their own.</p>

<h2 id="what-done-looks-like">What Done Looks Like</h2>

<p>Demo 3 earns its place when:</p>

<ol>
  <li>Camera event replay produces deterministic fleet state.</li>
  <li>MQTT mode can subscribe to <code class="language-plaintext highlighter-rouge">cameras/#</code> when a broker is present.</li>
  <li>Event Hubs mode can consume forwarded telemetry when Azure is connected.</li>
  <li>The assistant summarizes at least three seeded incident types.</li>
  <li>The assistant cites structured evidence and runbook snippets.</li>
  <li>The UI shows original topic, normalized event, incident, and AI answer.</li>
  <li>The answer never invents cameras or sites not present in the event store.</li>
  <li>The full demo runs without real cameras.</li>
</ol>

<p>That last item is the point. If the demo needed real cameras, I would be doing IT support on stage instead of showing an architecture.</p>

<h2 id="related-posts">Related Posts</h2>

<p>This is the direct continuation of the Azure IoT Operations camera series. The existing <a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">control-plane post</a> owns the <code class="language-plaintext highlighter-rouge">cameras/#</code> contract; this post uses that contract as the AI workload.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/overview-iot-operations">Azure IoT Operations overview</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/manage-mqtt-broker/overview-broker">Azure IoT Operations built-in local MQTT broker</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/connect-to-cloud/overview-dataflow">Process and route data with data flows</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/discover-manage-assets/howto-use-onvif-connector">Configure the connector for ONVIF</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="azure-iot-operations" /><category term="mqtt" /><category term="event-hubs" /><category term="dotnet" /><category term="iot" /><category term="cameras" /><category term="rag" /><summary type="html"><![CDATA[Demo 3 of AI on the Edge: turning the existing cameras/# MQTT contract into an operations assistant using simulated, MQTT, and Event Hubs input modes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/from-camera-events-to-operator-guidance.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/from-camera-events-to-operator-guidance.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Private RAG That Cannot Leave the Edge</title><link href="https://jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/" rel="alternate" type="text/html" title="Private RAG That Cannot Leave the Edge" /><published>2026-07-27T09:00:00+00:00</published><updated>2026-07-27T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge</id><content type="html" xml:base="https://jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li class="series-nav-current" aria-current="true">Private RAG That Cannot Leave the Edge</li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>Private data is the easiest reason to care about edge AI. If the data cannot leave the site, then the answer cannot depend on a cloud fallback that nobody noticed.</p>

<p>Demo 2 extends the platform with a <strong>Knowledge Assistant</strong>. In the design, it ingests local documents, builds embeddings locally when available, retrieves relevant chunks, generates grounded answers, and sends every generation request through the same routing policy engine from Demo 1. The RAG part is nearly conventional. The part I actually care about is that the privacy promise gets enforced by routing instead of by hope.</p>

<h2 id="where-most-rag-demos-leak">Where most RAG demos leak</h2>

<p>RAG demos tend to blur the privacy boundary in the same comfortable way: the documents are local, but answer generation quietly calls a cloud model. For public docs, nobody gets hurt. For camera runbooks, incident notes, network diagrams, customer data, or site-specific operational history, that silent hop is the whole ballgame.</p>

<p>So the privacy promise has to be testable:</p>

<ul>
  <li>What classification was assigned to the document?</li>
  <li>Which chunks were retrieved?</li>
  <li>Was the prompt body logged?</li>
  <li>Which backend generated the answer?</li>
  <li>What would happen if the local backend failed?</li>
</ul>

<p>Each of those answers has to be visible in the demo itself, or the demo is proving nothing.</p>

<h2 id="four-jobs-and-a-deliberately-boring-vector-store">Four jobs and a deliberately boring vector store</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/private-rag-local-only.svg" alt="Private RAG local-only architecture: private documents are chunked, embedded, stored in a vector store, retrieved for a restricted question, grounded into a prompt, and routed through a LocalOnly gateway policy that allows local generation while denying cloud fallback" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p><code class="language-plaintext highlighter-rouge">AiOnTheEdge.KnowledgeAssistant</code> has four jobs:</p>

<ol>
  <li>Ingest documents with metadata and classification.</li>
  <li>Chunk and embed the documents.</li>
  <li>Retrieve relevant context for a question.</li>
  <li>Ask the gateway for an answer under an explicit policy.</li>
</ol>

<p>The first version should keep storage boring: one local vector store, wrapped behind an interface, with the retrieval trace visible. The citations come from retrieval metadata, not from model-generated prose - if the model is writing its own bibliography, the citations are fiction with good formatting.</p>

<p>A seeded document looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"documentId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-camera-runbook"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Garage Camera Runbook, Remote Property 1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"sourcePath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"samples/documents/garage-camera-runbook.md"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"classification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Restricted"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"iot"</span><span class="p">,</span><span class="w"> </span><span class="s2">"cameras"</span><span class="p">,</span><span class="w"> </span><span class="s2">"runbook"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"createdUtc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-11T09:00:00Z"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>One honesty note before anyone asks for the dataset: the documents, queries, and responses in this post are synthetic samples rather than captured traffic. Site ids follow the camera series naming - <code class="language-plaintext highlighter-rouge">remote1</code> and <code class="language-plaintext highlighter-rouge">remote2</code> are the remote properties in the Part 1 network model, and <code class="language-plaintext highlighter-rouge">garage-east</code> is the Class B agent camera configured on <code class="language-plaintext highlighter-rouge">remote1</code> in Part 2.</p>

<p>The query shape makes policy explicit:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"question"</span><span class="p">:</span><span class="w"> </span><span class="s2">"What should I check first if the remote1 garage camera is offline?"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"classification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Restricted"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LocalOnly"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"topK"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
  </span><span class="nl">"includeCitations"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The response carries everything those five testability questions need: the answer, citations, route decision, privacy decision, and retrieval trace:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"answer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Start with the remote1 site network path before rebooting individual cameras..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"citations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"documentId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-camera-runbook"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"heading"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Offline camera checklist"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"score"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.86</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"routeDecision"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LocalOnly"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"selectedBackend"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foundry-local"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"fallbackUsed"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"privacyDecision"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"promptBodyLogged"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
    </span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Restricted content suppresses prompt body logging."</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Foundry Local is a good fit for the laptop version because Microsoft documents local embedding generation and RAG-style workflows that run on device. The implementation still supports mock embeddings, because the demo has to run even before every model is cached.</p>

<h2 id="running-the-denial-on-purpose">Running the denial on purpose</h2>

<p>What the audience watches for is a denied fallback.</p>

<ol>
  <li>Show the document library: camera runbooks, deployment notes, incident notes, and the existing camera architecture posts.</li>
  <li>Ask: <code class="language-plaintext highlighter-rouge">Which cameras are offline, what probably caused it, and what should the operator check first?</code></li>
  <li>Show retrieved chunks and citation metadata.</li>
  <li>Show the grounded answer.</li>
  <li>Open the route trace: classification <code class="language-plaintext highlighter-rouge">Restricted</code>, policy <code class="language-plaintext highlighter-rouge">LocalOnly</code>, selected local backend.</li>
  <li>Disable the local backend.</li>
  <li>Ask again.</li>
  <li>Show the request fails closed because cloud fallback is not allowed.</li>
  <li>Change the classification to <code class="language-plaintext highlighter-rouge">Internal</code>.</li>
  <li>Ask again with a policy that allows fallback.</li>
  <li>Show cloud or mock-cloud fallback only after the policy changes.</li>
</ol>

<p>That last step is the point of the whole demo: the privacy boundary is enforced by the platform, not by hope.</p>

<h2 id="seed-documents-i-already-own">Seed documents I already own</h2>

<p>The existing posts pull double duty as seed documents:</p>

<ul>
  <li>The Tenstorrent private cloud buildout gives hardware and environment context.</li>
  <li>The Azure IoT Operations camera posts give camera topology, MQTT topics, broker security, data-flow routing, and connector notes.</li>
</ul>

<p>Those posts get ingested as samples, but this demo does not retell them. It proves that private operational knowledge can be queried locally with citations.</p>

<p>The new build is <code class="language-plaintext highlighter-rouge">AiOnTheEdge.KnowledgeAssistant</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /documents/ingest
GET  /documents
POST /query
POST /query/explain
POST /admin/reindex
POST /admin/demo/seed-rag
</code></pre></div></div>

<p>Implementation requirements:</p>

<ul>
  <li>Ingest Markdown, PDF text, JSON, CSV, and plain text.</li>
  <li>Chunk by heading, paragraph, and token budget.</li>
  <li>Preserve source filename, heading path, and classification.</li>
  <li>Use Foundry Local embeddings where available.</li>
  <li>Support local or mock embeddings for fallback.</li>
  <li>Generate citations from retrieval metadata.</li>
  <li>Send answer generation through the AI on the Edge Gateway.</li>
  <li>Evaluate known questions with expected citations.</li>
</ul>

<h2 id="the-outage-that-proves-the-boundary">The outage that proves the boundary</h2>

<p>The critical failure for this design is a local model outage while the data is restricted.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/rag-fail-closed-sequence.svg" alt="Private RAG fail-closed sequence: a restricted operator question retrieves local chunks and builds a grounded prompt, but when the local backend is unavailable and LocalOnly is active, cloud eligibility is rejected, the request is denied, and local retrieval trace and route telemetry remain visible" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>Cloud fallback is not a recovery path for restricted RAG. The system returns a clear denial instead:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"decision"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Denied"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LocalOnly"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Restricted content must stay on edge and no eligible local backend is healthy."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The UI shows the retrieval trace and the route denial side by side, which makes the behavior legible: the system found useful context and correctly refused to send it to an ineligible backend.</p>

<h2 id="what-done-looks-like">What done looks like</h2>

<p>Demo 2 earns its number when:</p>

<ol>
  <li>Sample documents can be ingested with metadata and classification.</li>
  <li>Queries return grounded answers with deterministic citations.</li>
  <li>Restricted content routes only to local or edge backends.</li>
  <li>Cloud fallback is visibly denied for <code class="language-plaintext highlighter-rouge">LocalOnly</code>.</li>
  <li>Retrieval traces are visible in the dashboard.</li>
  <li>An evaluation script can run 10 known questions and produce pass/fail results.</li>
  <li>The demo runs without internet after models and packages are pre-cached.</li>
</ol>

<h2 id="related-posts">Related Posts</h2>

<p>This demo consumes the existing camera and lab posts as private knowledge sources, and it depends on the gateway from <a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a>, because the RAG assistant should not own fallback policy itself. The moment a component owns fallback policy, it starts making exceptions.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/tutorials/tutorial-build-rag-app">Tutorial: Build a RAG application with Foundry Local</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/how-to/how-to-generate-embeddings">Generate text embeddings with Foundry Local</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/what-is-foundry-local">What is Foundry Local?</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="foundry-local" /><category term="rag" /><category term="dotnet" /><category term="privacy" /><category term="vector-search" /><category term="observability" /><summary type="html"><![CDATA[Blog Demo 2 for AI on the Edge: document ingestion, local embeddings, vector search, citations, data classification, LocalOnly routing, and fail-closed cloud fallback.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/private-rag-that-cannot-leave-the-edge.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/private-rag-that-cannot-leave-the-edge.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One App, Many Places to Run AI</title><link href="https://jaredrhodes.com/blog/one-app-many-places-to-run-ai/" rel="alternate" type="text/html" title="One App, Many Places to Run AI" /><published>2026-07-20T09:00:00+00:00</published><updated>2026-07-20T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/one-app-many-places-to-run-ai</id><content type="html" xml:base="https://jaredrhodes.com/blog/one-app-many-places-to-run-ai/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li><a href="/https/jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/">AI on the Edge: Local AI Without Local Chaos</a></li>
<li class="series-nav-current" aria-current="true">One App, Many Places to Run AI</li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>One application asks one question and gets one useful answer. Where the model actually ran - the laptop, an edge cluster, a cloud endpoint, a Tenstorrent-backed server, or a mock backend - is none of the application’s business. Keeping that placement choice invisible to the app is the entire job of the <strong>AI on the Edge Gateway</strong>, and this first demo exists to make that abstraction obvious end to end.</p>

<h2 id="why-hardcoded-endpoints-stop-scaling">Why hardcoded endpoints stop scaling</h2>

<p>Hardcoding model endpoints is fine for a prototype and bad for a platform. Once an application knows too much about the model runtime, every placement decision turns into an app change:</p>

<ul>
  <li>Moving from a laptop runtime to an edge server changes code.</li>
  <li>Adding a cloud fallback changes code.</li>
  <li>Testing a specialized accelerator changes code.</li>
  <li>Denying cloud fallback for restricted data becomes app-specific retry logic.</li>
</ul>

<p>The app should express what it needs. The platform should decide where that request is allowed to run.</p>

<h2 id="what-the-gateway-looks-like">What the gateway looks like</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/model-routing-flow.svg" alt="Model routing flow: an app request enters the gateway, classification and policy metadata are evaluated, eligible backends are filtered by policy, capability, and health, then a backend is selected or the request is denied fail closed with route trace and metrics evidence" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>On the outside, the API surface is deliberately boring - it is the surface applications already speak:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /v1/chat/completions
POST /v1/embeddings
GET  /v1/models
GET  /healthz
GET  /readyz
GET  /metrics
POST /admin/route/explain
POST /admin/backends/{backendId}/enable
POST /admin/backends/{backendId}/disable
POST /admin/demo/faults
</code></pre></div></div>

<p>Routing those endpoints is a backend registry:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Backends"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foundry-local"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"FoundryLocal"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:5273/v1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"local-chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"local-embed"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"device"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"embeddings"</span><span class="p">,</span><span class="w"> </span><span class="s2">"streaming"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"local"</span><span class="p">,</span><span class="w"> </span><span class="s2">"offline"</span><span class="p">,</span><span class="w"> </span><span class="s2">"private"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"priority"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"azure-foundry"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OpenAICompatible"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://example.services.ai.azure.com/openai/v1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"apiKeySecretName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"azure-foundry-key"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"cloud-chat"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cloud"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"streaming"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"cloud"</span><span class="p">,</span><span class="w"> </span><span class="s2">"managed"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"priority"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mock-accelerator"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OpenAICompatible"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:5199/v1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"accelerator-chat"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"edge"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"streaming"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"local"</span><span class="p">,</span><span class="w"> </span><span class="s2">"accelerator"</span><span class="p">,</span><span class="w"> </span><span class="s2">"specialized-hardware"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"priority"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mock-cloud"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OpenAICompatible"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:5188/v1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"cloud-chat"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cloud"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chat"</span><span class="p">,</span><span class="w"> </span><span class="s2">"streaming"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"cloud"</span><span class="p">,</span><span class="w"> </span><span class="s2">"mock"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"priority"</span><span class="p">:</span><span class="w"> </span><span class="mi">40</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Every backend gets normalized into the same internal shape: health, supported model aliases, capabilities, location, tags, priority, observed latency, and current fault state. From there on, the router does not care whether a backend is a laptop runtime or a rack in the basement.</p>

<p>Then comes my favorite line in the whole codebase: the router evaluates policy before it evaluates convenience.</p>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/policy-decision-matrix.svg" alt="Routing policy decision matrix: request classification, requested policy, and backend health feed policy evaluation, which chooses a local route, permitted cloud route, or denied fail-closed result" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The policies are small enough to keep in your head:</p>

<table>
  <thead>
    <tr>
      <th>Policy</th>
      <th>Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LocalOnly</code></td>
      <td>Only device or edge backends are eligible. Cloud fallback is denied.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PreferLocal</code></td>
      <td>Prefer local, then Azure Local or edge, then cloud if allowed.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CloudAllowed</code></td>
      <td>Any healthy backend is eligible.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AcceleratorPreferred</code></td>
      <td>Prefer a backend tagged <code class="language-plaintext highlighter-rouge">accelerator</code> when the model family is supported.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">FallbackDisabled</code></td>
      <td>Fail closed if the selected backend is unavailable.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NoPromptLogging</code></td>
      <td>Emit metadata only and suppress prompt and response bodies.</td>
    </tr>
  </tbody>
</table>

<p>Foundry Local belongs in this first demo as the device-local runtime. Microsoft positions it as an on-device AI runtime and SDK, with an optional local server aimed at development and integration scenarios - which is a different job from being the shared multi-user server inference layer for the whole edge estate. For server-style edge inference, the demo keeps Azure Local, other OpenAI-compatible services, and accelerator endpoints behind the same registry.</p>

<h2 id="walking-through-the-demo">Walking through the demo</h2>

<p>Route explanation carries this demo. This is the sequence I rehearse until it gets boring:</p>

<ol>
  <li>Open the AI on the Edge Console.</li>
  <li>Ask: <code class="language-plaintext highlighter-rouge">Summarize the current edge site health and recommend the next action.</code></li>
  <li>Show the response streaming through the gateway.</li>
  <li>Open the route trace and show <code class="language-plaintext highlighter-rouge">selectedBackend: foundry-local</code>.</li>
  <li>Disable <code class="language-plaintext highlighter-rouge">foundry-local</code>.</li>
  <li>Ask again with <code class="language-plaintext highlighter-rouge">CloudAllowed</code>.</li>
  <li>Show fallback to <code class="language-plaintext highlighter-rouge">azure-foundry</code> or <code class="language-plaintext highlighter-rouge">mock-cloud</code>.</li>
  <li>Ask again with <code class="language-plaintext highlighter-rouge">LocalOnly</code>.</li>
  <li>Show the request is denied instead of silently falling back to cloud.</li>
  <li>Re-enable the local backend and show the metrics panel.</li>
</ol>

<p>The line I want the audience walking out with is simple: <strong>same app, same API, different placement decision.</strong></p>

<h2 id="borrowing-the-lab-instead-of-rebuilding-it">Borrowing the lab instead of rebuilding it</h2>

<p>The private cloud lab already supplies the edge environment and the Tenstorrent hardware lane, and the camera series already supplies an operational workload. Neither one needs rebuilding just to prove a gateway. Demo 1 can run entirely with a local runtime and deterministic mock backends.</p>

<p>The new implementation is the gateway foundation:</p>

<ul>
  <li>OpenAI-compatible request and response normalization.</li>
  <li>Backend registry and health checks.</li>
  <li>Route explanation endpoint.</li>
  <li>Policy evaluation before fallback.</li>
  <li>Prometheus metrics.</li>
  <li>Route decision events.</li>
  <li>Admin controls for enable, disable, and fault injection.</li>
  <li>A dashboard panel for backend health, latency, selected backend, and denial reasons.</li>
</ul>

<p>Before any of this depends on real cloud or real hardware, the implementation should include a mock local backend, the <code class="language-plaintext highlighter-rouge">mock-cloud</code> backend from the registry above, and a mock accelerator. Deterministic mocks are not a shortcut; they are what make the conference demo reliable.</p>

<h2 id="when-the-local-box-dies">When the local box dies</h2>

<p>The first failure worth showing is local backend loss:</p>

<table>
  <thead>
    <tr>
      <th>Situation</th>
      <th>Correct behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Local backend down, policy <code class="language-plaintext highlighter-rouge">CloudAllowed</code></td>
      <td>Route to an allowed cloud or mock-cloud backend.</td>
    </tr>
    <tr>
      <td>Local backend down, policy <code class="language-plaintext highlighter-rouge">LocalOnly</code></td>
      <td>Deny with a clear reason.</td>
    </tr>
    <tr>
      <td>Accelerator saturated, local backend healthy</td>
      <td>Route to another eligible edge backend.</td>
    </tr>
    <tr>
      <td>Backend returns malformed OpenAI-compatible payload</td>
      <td>Record adapter error and fallback only before streaming begins.</td>
    </tr>
  </tbody>
</table>

<p>And the rule underneath every row of that table: the gateway must never treat “cloud is healthy” as permission to send restricted content there.</p>

<h2 id="what-done-looks-like">What done looks like</h2>

<p>Demo 1 earns its number when:</p>

<ol>
  <li>One app can call <code class="language-plaintext highlighter-rouge">/v1/chat/completions</code> on the gateway.</li>
  <li>The gateway can route to local, cloud/mock-cloud, and accelerator/mock-accelerator backends.</li>
  <li>The dashboard shows selected backend, policy, denial reason, fallback reason, and latency.</li>
  <li>Disabling a backend changes the routing decision.</li>
  <li><code class="language-plaintext highlighter-rouge">LocalOnly</code> prevents cloud fallback.</li>
  <li>Metrics include request count, latency, selected backend, fallback count, and denied count.</li>
  <li><code class="language-plaintext highlighter-rouge">make demo-laptop</code> runs the whole demo without a cloud dependency.</li>
</ol>

<h2 id="related-posts">Related Posts</h2>

<p>This post is the platform foundation for the private RAG demo, the camera operations assistant, the failure lab, and the accelerator lane that follow. The existing <a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Tenstorrent buildout</a> is the hardware background; the existing <a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">camera control-plane post</a> is the workload background. Neither is required reading, but both are why this post stayed short.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/what-is-foundry-local">What is Foundry Local?</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-best-practice">Best practices and troubleshooting guide for Foundry Local CLI</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-sovereign-clouds/private/foundry-local/overview">What is Foundry Local on Azure Local?</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/endpoints">Endpoints for Microsoft Foundry Models</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/deployment-types">Understanding deployment types in Microsoft Foundry Models</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="foundry-local" /><category term="azure-local" /><category term="azure-arc" /><category term="dotnet" /><category term="tenstorrent" /><category term="observability" /><category term="model-routing" /><summary type="html"><![CDATA[Blog Demo 1 for AI on the Edge: my .NET gateway, OpenAI-compatible facade, backend registry, route explanations, target backends, and LocalOnly denial behavior.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/one-app-many-places-to-run-ai.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/one-app-many-places-to-run-ai.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI on the Edge: Local AI Without Local Chaos</title><link href="https://jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/" rel="alternate" type="text/html" title="AI on the Edge: Local AI Without Local Chaos" /><published>2026-07-13T09:00:00+00:00</published><updated>2026-07-13T09:00:00+00:00</updated><id>https://jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos</id><content type="html" xml:base="https://jaredrhodes.com/blog/ai-on-the-edge-local-ai-without-local-chaos/"><![CDATA[<nav class="series-nav" aria-label="Series">
<p><strong>AI on the Edge series</strong></p>
<p>This series builds the AI on the Edge demo system: cloud-governed, locally executed AI using a .NET gateway, policy-driven model routing, private RAG, camera fleet telemetry, Azure Arc operations, and accelerator-backed endpoints.</p>
<ol>
<li class="series-nav-current" aria-current="true">AI on the Edge: Local AI Without Local Chaos</li>
<li><a href="/https/jaredrhodes.com/blog/one-app-many-places-to-run-ai/">One App, Many Places to Run AI</a></li>
<li><a href="/https/jaredrhodes.com/blog/private-rag-that-cannot-leave-the-edge/">Private RAG That Cannot Leave the Edge</a></li>
<li><a href="/https/jaredrhodes.com/blog/from-camera-events-to-operator-guidance/">From Camera Events to Operator Guidance</a></li>
<li><a href="/https/jaredrhodes.com/blog/edge-ai-you-can-actually-operate/">Edge AI You Can Actually Operate</a></li>
<li><a href="/https/jaredrhodes.com/blog/when-the-edge-has-to-stand-alone/">When the Edge Has to Stand Alone</a></li>
<li><a href="/https/jaredrhodes.com/blog/specialized-hardware-without-an-app-rewrite/">Specialized Hardware Without an App Rewrite</a></li>
</ol>
<p>Background posts:</p>
<ul>
<li><a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</a></li>
<li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</a></li>
</ul>
<p>Project page:<br />
<a href="/https/jaredrhodes.com/projects/ai-on-the-edge/">AI on the Edge</a></p>
</nav>

<p>Years ago a vendor shut down the cloud service behind my home security cameras and left me holding hardware I owned but could no longer use. I salvaged what I could and kept the lesson: anything I actually depend on should run where I can reach it. That instinct is most of why local AI appeals to me - privacy, latency, cost control, disconnected operation, and the simple fact that some data already lives at the edge. The hard part was never getting one model to answer one prompt on one box. The hard part is making local AI behave like a platform instead of a pile of model servers hiding under desks.</p>

<p>That is the point of <strong>AI on the Edge</strong>. The project is a governable edge AI system where applications call one API, policy decides where inference is allowed to run, Azure provides the management plane, and the local environment keeps working when the network is not perfect.</p>

<p>The tagline is not decoration. It is the operating model:</p>

<blockquote>
  <p>Cloud-governed, locally executed.</p>
</blockquote>

<h2 id="why-local-ai-goes-sideways">Why local AI goes sideways</h2>

<p>Model sprawl never announces itself. A developer points an app at a local runtime. An operations team deploys a different endpoint on a Kubernetes node. A hardware experiment bolts on an accelerator-specific API. A cloud team wants a managed endpoint for approved workloads. Every one of those decisions is reasonable on its own. Stacked together, they become an unmanaged surface:</p>

<ul>
  <li>Applications hardcode model endpoints.</li>
  <li>Sensitive prompts can fall back to cloud by accident.</li>
  <li>Local model failures are invisible to operations teams.</li>
  <li>Hardware-specific demos turn into one-off branches.</li>
  <li>Edge environments have no consistent reset, smoke test, or dashboard story.</li>
</ul>

<p>AI on the Edge treats those as platform problems. The model runtime matters, but the project is really about routing, policy, observability, secrets, fallback, repeatability, and deployment.</p>

<h2 id="one-gateway-every-backend">One gateway, every backend</h2>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/program-architecture.svg" alt="AI on the Edge architecture: applications and workloads call an OpenAI-compatible gateway; routing and policy select local, Azure Local, cloud, accelerator, or mock execution targets while Azure Arc, Monitor, Grafana, Key Vault, and IoT Operations provide control-plane services" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The center of the system is a reusable .NET gateway that exposes an OpenAI-compatible API to the application. Behind that gateway sit a backend registry and a policy-driven router. Depending on the request, inference can land on a laptop-local runtime, an Azure Local deployment, an Azure AI Foundry model endpoint, a Tenstorrent-backed endpoint, or a deterministic mock backend that exists purely for conference safety.</p>

<p>The important part is that the application never picks the backend directly. It sends the request with metadata, and that metadata is the contract: the workload (chat, embeddings, RAG answer, incident summary), the classification (public, internal, restricted, secret), the policy (local only, prefer local, cloud allowed, accelerator preferred), and the operational context (latency target, streaming requirement, fallback rules).</p>

<p>For every request, the gateway emits a route decision event. That event records the selected backend, the denied backends, the policy, the classification, the fallback behavior, latency, token counts, and whether prompt bodies were logged, redacted, or suppressed. When somebody asks why an answer came from where it did, the answer lives in the telemetry, not in my memory.</p>

<p>Azure provides the control plane wrapped around that local execution:</p>

<ul>
  <li>Azure Arc brings the edge Kubernetes cluster into Azure management.</li>
  <li>GitOps applies the desired state.</li>
  <li>Azure Monitor, Managed Prometheus, and Grafana make behavior visible.</li>
  <li>Key Vault handles secrets and certificates where the Azure-governed path is active.</li>
  <li>Azure Policy and application policy events make denied routes explicit.</li>
  <li>Azure IoT Operations gives the camera workload a real edge data plane.</li>
</ul>

<h2 id="how-the-demos-stack-up">How the demos stack up</h2>

<p>The series maps straight onto how I am building the talk:</p>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/ai-on-the-edge/series-roadmap.svg" alt="AI on the Edge roadmap: prerequisite Tenstorrent lab and Azure IoT Operations camera posts feed gateway routing, private RAG, camera operations, Azure governance, failure lab, and accelerator milestones, which become the build guide, reference architecture, and 45-minute presentation flow" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<table>
  <thead>
    <tr>
      <th>Post</th>
      <th>Demo</th>
      <th>Milestone</th>
      <th>Audience moment</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>One App, Many Places to Run AI</td>
      <td>Blog Demo 1</td>
      <td>Gateway and router</td>
      <td>One prompt routes to different backends without app changes.</td>
    </tr>
    <tr>
      <td>Private RAG That Cannot Leave the Edge</td>
      <td>Blog Demo 2</td>
      <td>Local documents and citations</td>
      <td>Restricted data fails closed instead of falling back to cloud.</td>
    </tr>
    <tr>
      <td>From Camera Events to Operator Guidance</td>
      <td>Blog Demo 3</td>
      <td>IoT operations assistant</td>
      <td>Raw camera events become an incident summary and first actions.</td>
    </tr>
    <tr>
      <td>Edge AI You Can Actually Operate</td>
      <td>Blog Demo 4</td>
      <td>Azure governance</td>
      <td>Routing, failures, and policy decisions show up in Azure-backed dashboards.</td>
    </tr>
    <tr>
      <td>When the Edge Has to Stand Alone</td>
      <td>Blog Demo 5</td>
      <td>Failure lab</td>
      <td>Backend failures and cloud blocks produce visible, correct behavior.</td>
    </tr>
    <tr>
      <td>Specialized Hardware Without an App Rewrite</td>
      <td>Blog Demo 6</td>
      <td>Accelerator lane</td>
      <td>Tenstorrent or mock accelerator is just another governed backend.</td>
    </tr>
  </tbody>
</table>

<p>The final two posts turn the demo system into an implementation guide and an evergreen reference architecture.</p>

<h2 id="standing-on-work-i-already-published">Standing on work I already published</h2>

<p>The private cloud and physical lab are already covered in <a href="/https/jaredrhodes.com/blog/home-lab-tenstorrent-buildout-multi-cloud-edge/">Home Lab - Tenstorrent Buildout for Multi-Cloud Edge Demos</a>. That post owns the hardware story: basement studio, private cloud, Tenstorrent paths, NVIDIA systems, Proxmox, TrueNAS, Kubernetes, and multi-cloud demo intent.</p>

<p>The camera control plane is already covered in the three-part Azure IoT Operations series. Those posts own the camera details: outbound MQTT, the <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/&lt;channel&gt;</code> topic tree, TLS and X.509 on the AIO MQTT broker, data flows to Event Hubs, and the ONVIF connector bridge.</p>

<p>AI on the Edge builds on both instead of repeating either. The lab is the environment, the cameras are the workload, and the new thing is the AI platform that sits between them.</p>

<h2 id="what-i-am-actually-building-here">What I am actually building here</h2>

<p>The demo system is the new work:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.Gateway</code> for the OpenAI-compatible facade.</li>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.Routing</code> for backend selection, fallback, and policy.</li>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.KnowledgeAssistant</code> for private RAG over local documents.</li>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.OperationsAssistant</code> for camera event triage.</li>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.ControlDashboard</code> for health, route traces, privacy posture, and failure controls.</li>
  <li><code class="language-plaintext highlighter-rouge">AiOnTheEdge.DemoScenarios</code> for deterministic seed, reset, replay, and smoke tests.</li>
  <li>Azure and Kubernetes deployment assets for the governed edge path.</li>
</ul>

<p>One status note, stated here once so the whole series inherits it: these components are part of my private reference implementation for the talk. The build is in progress and the repo is not published, so the posts that follow present API surfaces, registries, and acceptance criteria as the design the demos target - not as downloadable software. Nobody will be cloning their way into this series, and pretending otherwise would just waste your afternoon.</p>

<p>The first version must run in laptop mode with mock or local backends. Azure-governed mode is the richer path, not the live-demo dependency.</p>

<h2 id="the-failure-that-proves-the-design">The failure that proves the design</h2>

<p>The failure this project protects against is quiet policy drift. A local backend goes down, a cloud endpoint happens to be healthy, and a restricted prompt silently leaves the edge because the only trick the application knows is retry.</p>

<p>That is unacceptable behavior for this architecture, full stop. Restricted data must fail closed. Cloud fallback must be a policy decision, not a retry side effect. And the audience should see the denial, the reason, and the telemetry - not take my word for any of it.</p>

<h2 id="what-done-looks-like">What done looks like</h2>

<p>By the end of the series, all of this had better be true:</p>

<ul>
  <li>A reader can understand why local AI still needs governance.</li>
  <li>One application can call one gateway and reach multiple backend types.</li>
  <li>Restricted RAG content never routes to cloud.</li>
  <li>Camera telemetry can drive an operator assistant without reworking the existing MQTT contract.</li>
  <li>Azure operations can see routing, failures, policy denials, and backend health.</li>
  <li>The demo runs in laptop mode with no cloud dependency yet can still be reset, seeded, and smoke-tested before a session.</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<p>Start with the Tenstorrent buildout if you want the hardware context. Start with the Azure IoT Operations camera series if you want the workload context. Start here if you want the AI platform and presentation system. Everything after this post either goes through the gateway or fails because of it.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/what-is-foundry-local">What is Foundry Local?</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/foundry-local/reference/reference-best-practice">Best practices and troubleshooting guide for Foundry Local CLI</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-sovereign-clouds/private/foundry-local/overview">What is Foundry Local on Azure Local?</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/azure-arc/kubernetes/overview">Azure Arc-enabled Kubernetes overview</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/iot-operations/overview-iot-operations">Azure IoT Operations overview</a></li>
  <li><a href="https://docs.tenstorrent.com/tools/index.html">Tenstorrent tools documentation</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="ai-on-the-edge" /><category term="ai-on-the-edge" /><category term="azure" /><category term="edge" /><category term="foundry-local" /><category term="azure-local" /><category term="azure-arc" /><category term="azure-iot-operations" /><category term="dotnet" /><category term="tenstorrent" /><category term="home-lab" /><summary type="html"><![CDATA[Why I built AI on the Edge: local execution, Azure governance, model routing, an IoT camera workload, my private cloud lab, and conference-ready demo modes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/ai-on-the-edge-local-ai-without-local-chaos.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/ai-on-the-edge-local-ai-without-local-chaos.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model</title><link href="https://jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/" rel="alternate" type="text/html" title="Cameras on Azure IoT Operations, Part 1: The Control Plane and Network Model" /><published>2026-06-19T12:00:00+00:00</published><updated>2026-06-19T12:00:00+00:00</updated><id>https://jaredrhodes.com/blog/azure-iot-operations-camera-control-plane</id><content type="html" xml:base="https://jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/"><![CDATA[<p>This is <strong>Part 1 of a three-part series</strong> on running a real camera fleet on <a href="https://learn.microsoft.com/azure/iot-operations/">Azure IoT Operations</a> (AIO):</p>

<ul>
  <li><strong>Part 1 (this post): the control plane and network model</strong> - what the system is, how the cameras work, and how the networks work.</li>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Part 2: swapping in the Azure IoT Operations MQTT broker</a> - TLS, X.509 camera identity, and topic authorization on Arc-enabled Kubernetes.</li>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Part 3: data flows, connectors, and the cloud</a> - forwarding telemetry to Event Hubs and onboarding ONVIF cameras as AIO assets.</li>
</ul>

<p>I built a central camera control plane in .NET 10, then made <strong>Azure IoT Operations an opt-in broker and edge data plane</strong> for it. The interesting part of that story is that AIO does not replace the design - it slots into an existing, opinionated one. So before the AIO specifics in Parts 2 and 3, this post covers the foundation AIO plugs into: the control plane, the two camera integration classes, the MQTT wire contract, and the network model.</p>

<h2 id="why-a-control-plane-not-a-pile-of-cameras">Why a control plane, not a pile of cameras</h2>

<p>The usual way people “network” cameras is to put a pile of cheap IP cameras on a LAN, port-forward an NVR, and hope. That model rots: every camera is a little server with a web UI, a Telnet port, and firmware from 2017, and every one is an inbound attack surface.</p>

<p>The control-plane model inverts that. A camera is treated like a managed IoT node:</p>

<ul>
  <li>It connects <strong>outbound</strong> to an MQTT broker.</li>
  <li>It <strong>publishes</strong> its status, inventory, and events.</li>
  <li>It <strong>subscribes</strong> for commands from a central server.</li>
</ul>

<p>There is <strong>no inbound SSH or Telnet to a camera</strong> in normal operation. The central server owns identity, configuration, status, commands, updates, health, and stream registration. The camera (or a per-site gateway) owns capture, health checks, watchdog behavior, and safe command execution. That split is the whole point: the control surface is a closed message contract, not a shell.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/control-plane-overview.svg" alt="Control-plane overview: izon-agents and site-gateways in per-property camera VLANs hold outbound MQTT connections to a broker in the central property, which the CameraNetwork.Controller ingests into PostgreSQL and answers with commands over the already-open connection" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The .NET solution behind this is a standard clean-architecture layout (<code class="language-plaintext highlighter-rouge">.NET 10 + Aspire</code>): <code class="language-plaintext highlighter-rouge">CameraNetwork.Contracts</code> holds the wire types, <code class="language-plaintext highlighter-rouge">CameraNetwork.Controller</code> is the Blazor Server dashboard + REST API + <code class="language-plaintext highlighter-rouge">/metrics</code>, and two lightweight Worker services run at the edge - <code class="language-plaintext highlighter-rouge">CameraNetwork.Agent</code> and <code class="language-plaintext highlighter-rouge">CameraNetwork.SiteGateway</code>. Those two are how the fleet splits into two integration classes. The solution is part of my private reference implementation and is not published, so treat these project names as documentation of the design rather than pointers to a cloneable repo.</p>

<h2 id="two-kinds-of-cameras-one-control-plane">Two kinds of cameras, one control plane</h2>

<p>Real fleets are never homogeneous. Some cameras can run our code; most cannot. The design absorbs that with two classes that look <strong>identical to the control plane</strong>:</p>

<ul>
  <li><strong>Class B - agent-managed.</strong> The camera runs our sidecar, the <em>izon-agent</em> (<code class="language-plaintext highlighter-rouge">CameraNetwork.Agent</code>), and speaks MQTT directly. This is for cameras you can get a process onto: iZON, Axis ACAP, OpenIPC, Thingino. The agent reports health, accepts the closed command set, watches the RTSP stream, and updates itself.</li>
  <li><strong>Class A - gateway-managed.</strong> A plain RTSP/ONVIF PoE camera (Amcrest, Reolink, TP-Link VIGI, and similar) that cannot run our code. A per-site <code class="language-plaintext highlighter-rouge">CameraNetwork.SiteGateway</code> speaks for it: it probes the camera and publishes availability, inventory, and status on the camera’s behalf, so a “dumb” camera shows up in the dashboard like any other.</li>
</ul>

<p>The Class A probe is real network work, not a TCP ping. It does an ICMP reachability check, an RTSP <code class="language-plaintext highlighter-rouge">OPTIONS</code> handshake, an ONVIF query (make, model, firmware, MAC, stream and snapshot URLs, plus WS-Discovery), and a validated JPEG snapshot pull. That probing sits behind an <code class="language-plaintext highlighter-rouge">ICameraProbe</code> seam, with a <code class="language-plaintext highlighter-rouge">NetworkCameraProbe</code> for real cameras and a <code class="language-plaintext highlighter-rouge">SimulatedCameraProbe</code> for dev and CI.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/camera-classes.svg" alt="Two camera integration classes: Class B cameras run the izon-agent and speak MQTT directly to the control bus; Class A cameras are probed by a site-gateway (ICMP, RTSP OPTIONS, ONVIF, JPEG) that publishes on their behalf - both reach the controller identically" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>The payoff: the controller code, the dashboard, the API, and the metrics never branch on camera class. A Class A camera fronted by a gateway and a Class B camera running the agent produce the same records. That uniformity is exactly what lets Part 3 add a <strong>third</strong> producer - an Azure IoT Operations connector - without the controller noticing.</p>

<h2 id="the-wire-contract-one-mqtt-topic-tree">The wire contract: one MQTT topic tree</h2>

<p>Everything rides on one topic tree, rooted at <code class="language-plaintext highlighter-rouge">cameras</code> and shaped <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/&lt;channel&gt;</code>. The controller subscribes to <code class="language-plaintext highlighter-rouge">cameras/#</code>; an agent publishes its own channels and subscribes only to its own <code class="language-plaintext highlighter-rouge">cmd</code> topic. The contract lives in code in <code class="language-plaintext highlighter-rouge">CameraNetwork.Contracts</code>, so the controller and the agents cannot drift apart.</p>

<table>
  <thead>
    <tr>
      <th>Topic</th>
      <th>Direction</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/availability</code></td>
      <td>agent to controller</td>
      <td>retained, mirrors the Last-Will</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/inventory</code></td>
      <td>agent to controller</td>
      <td>retained</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/status</code></td>
      <td>agent to controller</td>
      <td>heartbeat (the reported state)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/event</code></td>
      <td>agent to controller</td>
      <td>motion, tamper, RTSP loss, etc.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/metrics</code></td>
      <td>agent to controller</td>
      <td>optional</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/cmd</code></td>
      <td>controller to agent</td>
      <td>the only topic the agent subscribes to</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/cmd_ack</code></td>
      <td>agent to controller</td>
      <td>command result</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/log</code></td>
      <td>agent to controller</td>
      <td>on demand</td>
    </tr>
  </tbody>
</table>

<p>Two design choices matter here. First, <strong>availability is retained and backed by an MQTT Last-Will</strong>: the agent sets a retained Last-Will of <code class="language-plaintext highlighter-rouge">{"state":"offline"}</code> on connect, so an unexpected drop flips the camera offline with no active reporting. Second, <strong>the command set is closed</strong>. An agent rejects anything outside this list, so the channel can never become arbitrary remote code execution across the fleet:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>restart_rtsp   restart_network   reboot       identify
privacy_on     privacy_off       snapshot     get_status
get_logs       apply_config      update_agent rollback_agent
</code></pre></div></div>

<p>Acks come back as <code class="language-plaintext highlighter-rouge">accepted</code> (non-terminal), <code class="language-plaintext highlighter-rouge">succeeded</code>, <code class="language-plaintext highlighter-rouge">failed</code>, or <code class="language-plaintext highlighter-rouge">unsupported</code>. The closed set is the security model for the command channel - it is the reason a compromised broker session cannot tell a camera to do something arbitrary.</p>

<p>The controller reasons in terms of <strong>desired state</strong> versus <strong>reported state</strong>. An operator sets a desired agent version and config; the controller hashes the config into a stable 16-character <code class="language-plaintext highlighter-rouge">DesiredConfigHash</code>. Pushing <code class="language-plaintext highlighter-rouge">apply_config</code> carries that config and hash to the agent, which applies it and echoes the hash back in its status as <code class="language-plaintext highlighter-rouge">ReportedConfigHash</code>. Config drift is then just <code class="language-plaintext highlighter-rouge">DesiredConfigHash != ReportedConfigHash</code>, and an outdated agent is <code class="language-plaintext highlighter-rouge">DesiredAgentVersion != ReportedAgentVersion</code>. Both surface on the dashboard and in <code class="language-plaintext highlighter-rouge">/metrics</code>.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/wire-contract-ingest.svg" alt="The MQTT wire contract: an agent or gateway publishes status, availability, inventory, events and command acks to the broker; a single-reader MqttIngestService writes through an EF Core store to PostgreSQL; the operator dispatches commands via CommandDispatchService, and Prometheus scrapes metrics" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>This contract is the seam that makes the rest of the series possible. Because it is single-sourced and broker-agnostic, the <strong>broker underneath it can change without touching either end</strong> - which is exactly what Part 2 does.</p>

<h2 id="the-network-model">The network model</h2>

<p>The control plane assumes <strong>unique routed subnets per property</strong> over a <strong>site-to-site routed VPN</strong> (WireGuard or pfSense), not client NAT. You do not overlap <code class="language-plaintext highlighter-rouge">192.168.1.0/24</code> everywhere; each property gets its own space and the central peer holds routes to each remote camera subnet.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Central property:   LAN 10.10.0.0/16   Server VLAN 10.10.10.0/24   Camera VLAN 10.10.30.0/24
Remote property 1:  LAN 10.20.0.0/16                               Camera VLAN 10.20.30.0/24
Remote property 2:  LAN 10.30.0.0/16                               Camera VLAN 10.30.30.0/24
VPN transit:        10.255.0.0/24

camera-control.internal   10.10.10.20      mqtt.camera.internal   10.10.10.20
</code></pre></div></div>

<p>The camera VLANs are <strong>default-deny</strong>. Treat every old camera as compromised until proven otherwise, and only allow what is required:</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Destination</th>
      <th>Port</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Camera VLANs</td>
      <td>controller</td>
      <td>TCP 1883 or 8883</td>
      <td>MQTT control/status</td>
    </tr>
    <tr>
      <td>Camera VLANs</td>
      <td>controller</td>
      <td>TCP 443</td>
      <td>config/update API</td>
    </tr>
    <tr>
      <td>Camera VLANs</td>
      <td>DNS resolver</td>
      <td>TCP/UDP 53</td>
      <td>DNS (optional)</td>
    </tr>
    <tr>
      <td>Camera VLANs</td>
      <td>NTP resolver</td>
      <td>UDP 123</td>
      <td>time sync</td>
    </tr>
    <tr>
      <td>Camera VLANs</td>
      <td>Internet</td>
      <td><strong>deny</strong></td>
      <td>no cloud callbacks</td>
    </tr>
    <tr>
      <td>Camera VLANs</td>
      <td>normal LANs</td>
      <td><strong>deny</strong></td>
      <td>camera isolation</td>
    </tr>
    <tr>
      <td>NVR</td>
      <td>Camera VLANs</td>
      <td>TCP 554</td>
      <td>RTSP pull (central-only)</td>
    </tr>
  </tbody>
</table>

<p>The important property falls out of this directly: <strong>control commands never require inbound SSH or Telnet to a camera.</strong> The agent (or gateway) holds an outbound MQTT connection, and the controller publishes commands the camera receives over that already-open connection. Cameras get no Internet, no camera-to-camera traffic, and no path to the normal LAN. Vendor cloud domains stay blocked; old web and Telnet services stay firewalled.</p>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/network-model.svg" alt="The network model: remote-property camera VLANs reach the central controller and broker over a site-to-site VPN with outbound MQTT only, the central camera VLAN connects locally, the central NVR pulls RTSP on port 554, and camera VLANs are blocked from the Internet and normal LANs" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<blockquote>
  <p><strong>Procurement note.</strong> For client-, business-, or government-adjacent work, avoid Hikvision and Dahua (FCC Covered List) and prefer Axis, Hanwha, Bosch, i-PRO, TP-Link VIGI, Amcrest, Reolink, or UniFi.</p>
</blockquote>

<h2 id="where-azure-iot-operations-comes-in">Where Azure IoT Operations comes in</h2>

<p>Notice what the broker actually is in all of this: a trusted MQTT bus on a private routed VPN. The default deployment uses Eclipse Mosquitto, hardened with per-camera username/password and ACLs and reachable only from the controller’s IP. That works, and the rest of the series leaves it as the byte-identical default.</p>

<p>But the broker is also a <strong>seam</strong>. The agents, the gateway, and the controller all connect through one shared set of connection options, and the topics and JSON payloads are defined once in <code class="language-plaintext highlighter-rouge">CameraNetwork.Contracts</code>. That means the broker can be swapped for something with a richer edge story without changing a single line of camera or controller logic - and <strong>Azure IoT Operations</strong> is exactly that something:</p>

<ul>
  <li>AIO ships an enterprise-grade <strong>MQTT broker</strong> that runs on Arc-enabled Kubernetes at the edge. Part 2 swaps Mosquitto for it, with TLS and per-camera X.509 identity, and shows how one authorization rule reproduces the entire Mosquitto ACL.</li>
  <li>AIO ships <strong>data flows</strong> that route from that broker to the cloud. Part 3 forwards the same <code class="language-plaintext highlighter-rouge">cameras/#</code> tree to Azure Event Hubs with no application change, then onboards ONVIF cameras as <strong>AIO assets</strong> through a connector and a small bridge.</li>
</ul>

<p>The key idea, and the reason this is worth three posts: <strong>AIO is introduced as an opt-in parallel path.</strong> The control plane, the two camera classes, the wire contract, and the network model in this post all stay exactly as described. AIO changes what sits under the contract and what happens after the message reaches the broker - not the contract itself.</p>

<p><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Continue to Part 2: swapping in the Azure IoT Operations MQTT broker.</a></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/overview-iot-operations">Azure IoT Operations overview</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/overview-broker">Azure IoT Operations MQTT broker overview</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-arc/kubernetes/overview">Azure Arc-enabled Kubernetes overview</a></li>
  <li><a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html">MQTT v5 specification</a></li>
  <li><a href="https://www.onvif.org/profiles/">ONVIF specifications</a></li>
  <li><a href="https://www.fcc.gov/supplychain/coveredlist">FCC Covered List</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="azure-iot-operations" /><category term="azure-iot-operations" /><category term="aio" /><category term="azure-iot" /><category term="azure" /><category term="mqtt" /><category term="iot" /><category term="edge" /><category term="iot-edge" /><category term="cameras" /><category term="dotnet" /><category term="cloud-architecture" /><summary type="html"><![CDATA[How a .NET camera control plane is built for Azure IoT Operations - the two camera classes, the MQTT control bus, and the per-property network model AIO plugs into.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/azure-iot-operations-camera-control-plane.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/azure-iot-operations-camera-control-plane.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker</title><link href="https://jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/" rel="alternate" type="text/html" title="Cameras on Azure IoT Operations, Part 2: Swapping in the AIO MQTT Broker" /><published>2026-06-19T11:00:00+00:00</published><updated>2026-06-19T11:00:00+00:00</updated><id>https://jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet</id><content type="html" xml:base="https://jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/"><![CDATA[<p>This is <strong>Part 2 of a three-part series</strong> on running a real camera fleet on <a href="https://learn.microsoft.com/azure/iot-operations/">Azure IoT Operations</a> (AIO):</p>

<ul>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Part 1: the control plane and network model</a> - what the system is and how the cameras and networks work.</li>
  <li><strong>Part 2 (this post): swapping in the Azure IoT Operations MQTT broker</strong> - TLS, X.509 camera identity, and topic authorization.</li>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Part 3: data flows, connectors, and the cloud</a> - forwarding telemetry to Event Hubs and onboarding ONVIF cameras as AIO assets.</li>
</ul>

<p>In <a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Part 1</a> the broker was just “a trusted MQTT bus” - Eclipse Mosquitto by default. This post replaces it with the <strong>Azure IoT Operations MQTT broker</strong> without touching a single line of camera or controller logic, and gets stronger security for free in the process. The whole point of AIO here is that it gives you an enterprise MQTT broker that runs on Kubernetes at the edge, with cloud-managed identity, authorization, and (in Part 3) data flows - while the application keeps speaking the exact same topics and payloads.</p>

<h2 id="the-seam-that-makes-the-swap-possible">The seam that makes the swap possible</h2>

<p>The control plane has three MQTT clients: the controller’s ingest service, the Class B agent, and the Class A site gateway. All three connect through one shared project, <code class="language-plaintext highlighter-rouge">CameraNetwork.Mqtt</code>, which exposes a <code class="language-plaintext highlighter-rouge">MqttConnectionOptions</code> record and a single extension method:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Every client builds its options the same way:</span>
<span class="kt">var</span> <span class="n">options</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MqttClientOptionsBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">WithClientId</span><span class="p">(</span><span class="n">clientId</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="n">connection</span><span class="p">)</span>   <span class="c1">// CameraNetwork.Mqtt resolves profile -&gt; TLS / MQTT v5 / auth</span>
    <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MqttConnectionOptions</code> carries a <strong>profile</strong>, an <strong>auth</strong> mode, and the TLS and credential details. There are two profiles:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Mosquitto</code> (the default) - plain TCP on 1883, username/password, MQTT 3.1.1. Nothing in Part 1 changes.</li>
  <li><code class="language-plaintext highlighter-rouge">AzureIotOperations</code> - turns on <strong>TLS and MQTT v5</strong> automatically, with <code class="language-plaintext highlighter-rouge">Auth=X509</code> for clients outside the cluster or <code class="language-plaintext highlighter-rouge">Auth=Sat</code> (Kubernetes service-account token) for in-cluster components.</li>
</ul>

<p>Because the profile resolves the transport details, swapping brokers is a <strong>configuration change, not a code change</strong>. The MQTT library already does v5, TLS, and X.509 client certificates - there is no new client dependency to swap a fleet onto AIO.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/broker-seam.svg" alt="The broker seam: the controller ingest, izon-agent and site-gateway all build their MQTT options through CameraNetwork.Mqtt's .Apply(connection); the Mosquitto profile selects plain TCP on 1883, the AzureIotOperations profile selects the AIO broker on 8883 with TLS, X.509 and MQTT v5" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<h2 id="standing-up-azure-iot-operations">Standing up Azure IoT Operations</h2>

<p>AIO runs on an <strong>Azure Arc-enabled Kubernetes</strong> cluster. For a sandbox that is a single-node k3s box; in production it is whatever Arc-enabled cluster you run at the edge. The bring-up is a sequence of <code class="language-plaintext highlighter-rouge">az</code> commands (wrapped in a setup script in the private repo behind this series, not published here), and it is deliberately a <strong>parallel sandbox</strong> - it never touches the existing Docker Compose or TrueNAS deployment, which keep using Mosquitto.</p>

<p>The command shapes below match the AIO CLI as of June 2026. The schema-registry prerequisite in particular is version-sensitive, so re-check the deployment doc if <code class="language-plaintext highlighter-rouge">az iot ops create</code> rejects any arguments.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Arc-connect the cluster and enable the features AIO needs</span>
az connectedk8s connect <span class="nt">--name</span> cameranetwork-k3s <span class="nt">--resource-group</span> cameranetwork-aio <span class="nt">--location</span> eastus
az connectedk8s enable-features <span class="nt">--name</span> cameranetwork-k3s <span class="nt">--resource-group</span> cameranetwork-aio <span class="se">\</span>
  <span class="nt">--custom-locations-oid</span> <span class="s2">"</span><span class="nv">$CL_OID</span><span class="s2">"</span> <span class="nt">--features</span> cluster-connect custom-locations

<span class="c"># A storage account + schema registry are required before 'az iot ops create'</span>
az iot ops schema registry create <span class="nt">--name</span> cameranetwork-sr <span class="nt">--resource-group</span> cameranetwork-aio <span class="se">\</span>
  <span class="nt">--registry-namespace</span> cameranetwork-sr-ns <span class="nt">--sa-resource-id</span> <span class="s2">"</span><span class="nv">$SA_ID</span><span class="s2">"</span>

<span class="c"># Initialize and create the AIO instance (this is the broker, dataflows, and the rest)</span>
az iot ops init   <span class="nt">--cluster</span> cameranetwork-k3s <span class="nt">--resource-group</span> cameranetwork-aio
az iot ops create <span class="nt">--cluster</span> cameranetwork-k3s <span class="nt">--resource-group</span> cameranetwork-aio <span class="se">\</span>
  <span class="nt">--name</span> cameranetwork-aio <span class="nt">--sr-resource-id</span> <span class="s2">"</span><span class="nv">$SR_ID</span><span class="s2">"</span>
</code></pre></div></div>

<p>When this finishes you have an AIO instance with an MQTT broker. AIO installs a <strong>default in-cluster listener</strong> (<code class="language-plaintext highlighter-rouge">aio-broker:18883</code>, TLS + service-account-token auth) for its own components. We leave that one alone and add a <strong>second listener</strong> for the cameras, which live outside the cluster.</p>

<h2 id="a-listener-for-cameras-outside-the-cluster">A listener for cameras outside the cluster</h2>

<p>Cameras connect from remote properties over the VPN, so they need a listener exposed off the cluster. On k3s, a <code class="language-plaintext highlighter-rouge">LoadBalancer</code> service picks up the node IP out of the box. The listener terminates TLS and references the X.509 authentication and authorization policies we define next.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">mqttbroker.iotoperations.azure.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">BrokerListener</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">camera-external</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">azure-iot-operations</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">brokerRef</span><span class="pi">:</span> <span class="s">default</span>
  <span class="na">serviceType</span><span class="pi">:</span> <span class="s">LoadBalancer</span>
  <span class="na">serviceName</span><span class="pi">:</span> <span class="s">aio-broker-external</span>   <span class="c1"># do not clash with the default 'aio-broker' service</span>
  <span class="na">ports</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">port</span><span class="pi">:</span> <span class="m">8883</span>
      <span class="na">protocol</span><span class="pi">:</span> <span class="s">Mqtt</span>
      <span class="na">authenticationRef</span><span class="pi">:</span> <span class="s">camera-x509-authn</span>
      <span class="na">authorizationRef</span><span class="pi">:</span> <span class="s">camera-authz</span>
      <span class="na">tls</span><span class="pi">:</span>
        <span class="na">mode</span><span class="pi">:</span> <span class="s">Automatic</span>
        <span class="na">certManagerCertificateSpec</span><span class="pi">:</span>
          <span class="na">issuerRef</span><span class="pi">:</span>
            <span class="na">name</span><span class="pi">:</span> <span class="s">azure-iot-operations-aio-certificate-issuer</span>   <span class="c1"># the issuer AIO installs by default</span>
            <span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterIssuer</span>
            <span class="na">group</span><span class="pi">:</span> <span class="s">cert-manager.io</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tls.mode: Automatic</code> lets cert-manager mint the listener’s server certificate from AIO’s default cluster issuer. That is fine for a sandbox; to tighten it you add the external IP or DNS name as a SAN so clients can do strict server-cert validation instead of trusting the CA directly.</p>

<h2 id="per-camera-identity-with-x509">Per-camera identity with X.509</h2>

<p>This is where AIO earns its keep over plain Mosquitto. Each camera presents a <strong>client certificate</strong>, and the broker validates it against a CA we control. The repo’s cert helper generates a CA and a per-camera client cert whose <strong>common name equals the MQTT client id</strong>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./make-camera-cert.sh izon-remote1-garage-east     <span class="c"># Class B agent</span>
./make-camera-cert.sh site-gateway-remote2         <span class="c"># Class A gateway</span>
</code></pre></div></div>

<p>The CA certificate is imported into the cluster as a ConfigMap, and a <code class="language-plaintext highlighter-rouge">BrokerAuthentication</code> resource trusts it. The clever bit is the <strong>root-subject mapping</strong>: every certificate signed by our CA inherits the attribute <code class="language-plaintext highlighter-rouge">role: camera</code>, so the entire fleet shares one identity policy with no per-device wiring.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">mqttbroker.iotoperations.azure.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">BrokerAuthentication</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">camera-x509-authn</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">azure-iot-operations</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">authenticationMethods</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">method</span><span class="pi">:</span> <span class="s">X509</span>
      <span class="na">x509Settings</span><span class="pi">:</span>
        <span class="na">trustedClientCaCert</span><span class="pi">:</span> <span class="s">camera-client-ca</span>
        <span class="na">authorizationAttributes</span><span class="pi">:</span>
          <span class="na">cameras</span><span class="pi">:</span>
            <span class="na">subject</span><span class="pi">:</span> <span class="s">CN = CameraNetwork Camera CA</span>   <span class="c1"># must match the CA subject exactly</span>
            <span class="na">attributes</span><span class="pi">:</span>
              <span class="na">role</span><span class="pi">:</span> <span class="s">camera</span>
</code></pre></div></div>

<h2 id="one-rule-that-reproduces-the-whole-acl">One rule that reproduces the whole ACL</h2>

<p>With every camera certificate carrying <code class="language-plaintext highlighter-rouge">role: camera</code>, authorization is a single allow rule. AIO authorization policies are allow-only - anything not granted is denied - so this one rule lets the whole fleet connect and use the <code class="language-plaintext highlighter-rouge">cameras/#</code> topic tree, which is exactly the wire contract from Part 1.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">mqttbroker.iotoperations.azure.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">BrokerAuthorization</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">camera-authz</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">azure-iot-operations</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">authorizationPolicies</span><span class="pi">:</span>
    <span class="na">cache</span><span class="pi">:</span> <span class="s">Enabled</span>
    <span class="na">rules</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">principals</span><span class="pi">:</span>
          <span class="na">attributes</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">role</span><span class="pi">:</span> <span class="s">camera</span>
        <span class="na">brokerResources</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">method</span><span class="pi">:</span> <span class="s">Connect</span>
          <span class="pi">-</span> <span class="na">method</span><span class="pi">:</span> <span class="s">Publish</span>
            <span class="na">topics</span><span class="pi">:</span> <span class="pi">[</span> <span class="s2">"</span><span class="s">cameras/#"</span> <span class="pi">]</span>
          <span class="pi">-</span> <span class="na">method</span><span class="pi">:</span> <span class="s">Subscribe</span>
            <span class="na">topics</span><span class="pi">:</span> <span class="pi">[</span> <span class="s2">"</span><span class="s">cameras/#"</span> <span class="pi">]</span>
</code></pre></div></div>

<p>This is the <strong>AIO equivalent of the Mosquitto ACL</strong>. On Mosquitto you hand every camera a username/password and an ACL file entry; here you hand every camera a certificate from one CA and write one attribute-based rule. The trust anchor moves from a shared secret to a certificate authority, which is a real upgrade: revoking a camera is a CRL or re-issue operation, not a password edit pushed to a config file.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/x509-identity.svg" alt="Per-camera X.509 identity: each camera client certificate is signed by the Camera CA, which BrokerAuthentication trusts; a root-subject mapping stamps every certificate with the attribute role=camera, which BrokerAuthorization allows to Connect, Publish and Subscribe on the cameras topic tree at the external listener on 8883" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>If you later want <strong>per-camera isolation</strong> - a camera that can only touch its own channels - you give each certificate <code class="language-plaintext highlighter-rouge">site</code> and <code class="language-plaintext highlighter-rouge">camera</code> attributes in the authentication resource, then scope the authorization topics by token substitution, e.g. <code class="language-plaintext highlighter-rouge">cameras/{principal.attributes.site}/{principal.attributes.camera}/+</code>. That mirrors a per-camera Mosquitto ACL, but it is optional; the single fleet-wide rule is the starting point.</p>

<h2 id="pointing-a-component-at-aio">Pointing a component at AIO</h2>

<p>Nothing about the swap is code. You flip the profile and point at the external listener. A Class B agent’s settings:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Agent"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"SiteId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"remote1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"CameraId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"garage-east"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttProfile"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AzureIotOperations"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttHost"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;EXTERNAL-IP&gt;"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttPort"</span><span class="p">:</span><span class="w"> </span><span class="mi">8883</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttAuth"</span><span class="p">:</span><span class="w"> </span><span class="s2">"X509"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttClientCertPfxPath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/certs/izon-remote1-garage-east.pfx"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttCaCertPath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/certs/camera-ca.crt"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"MqttAllowUntrustedCertificates"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The controller is the same idea with its own config prefix (environment variables shown):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">Mqtt__Profile</span><span class="o">=</span>AzureIotOperations
<span class="nv">Mqtt__Host</span><span class="o">=</span>&lt;EXTERNAL-IP&gt;
<span class="nv">Mqtt__Port</span><span class="o">=</span>8883
<span class="nv">Mqtt__Auth</span><span class="o">=</span>X509
<span class="nv">Mqtt__ClientCertPfxPath</span><span class="o">=</span>/certs/camera-controller.pfx
<span class="nv">Mqtt__CaCertPath</span><span class="o">=</span>/certs/camera-ca.crt
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MqttProfile=AzureIotOperations</code> turns on TLS and MQTT v5 automatically. <code class="language-plaintext highlighter-rouge">MqttAllowUntrustedCertificates=true</code> skips server-cert validation and is a <strong>sandbox-only</strong> shortcut - drop it once the listener’s server cert carries the external IP as a SAN and rely on the CA file instead. The controller can also run inside the cluster with <code class="language-plaintext highlighter-rouge">Auth=Sat</code> and <code class="language-plaintext highlighter-rouge">Host=aio-broker</code>, using the default internal listener.</p>

<h2 id="verify-the-fleet-landed-on-the-broker">Verify the fleet landed on the broker</h2>

<p>Check the broker resources, then watch a real camera connect over TLS with its client certificate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Broker resources are healthy</span>
kubectl get brokerlistener,brokerauthentication,brokerauthorization <span class="nt">-n</span> azure-iot-operations
az iot ops check

<span class="c"># Subscribe from your workstation with the CA + a camera cert/key</span>
mosquitto_sub <span class="nt">-h</span> &lt;EXTERNAL-IP&gt; <span class="nt">-p</span> 8883 <span class="nt">-V</span> mqttv5 <span class="nt">-t</span> <span class="s1">'cameras/#'</span> <span class="nt">-v</span> <span class="se">\</span>
  <span class="nt">--cafile</span> certs/camera-ca.crt <span class="se">\</span>
  <span class="nt">--cert</span> certs/izon-remote1-garage-east.crt <span class="se">\</span>
  <span class="nt">--key</span> certs/izon-remote1-garage-east.key
</code></pre></div></div>

<p>Start the agent with the AIO config and you see its retained <code class="language-plaintext highlighter-rouge">availability</code> and <code class="language-plaintext highlighter-rouge">inventory</code>, then periodic <code class="language-plaintext highlighter-rouge">status</code>. Point the controller at the same listener and the camera appears on the dashboard exactly as it did on Mosquitto. Kill the agent and its <code class="language-plaintext highlighter-rouge">availability</code> flips to <code class="language-plaintext highlighter-rouge">offline</code> through the Last-Will - the retained-message and Last-Will-Testament behavior carries across brokers, which is the real test that the swap is transparent.</p>

<h2 id="why-this-matters">Why this matters</h2>

<p>The Mosquitto deployment trusts the private VPN and VLAN: the broker is never exposed to untrusted networks, hardening is per-camera username/password plus ACLs, and the firewall makes port 1883 reachable only from the controller. That is a perfectly good model on a trusted segment.</p>

<p>Moving to the AIO broker upgrades the trust model <strong>without changing the application</strong>:</p>

<ul>
  <li>Identity is a certificate from a CA you control, not a shared secret.</li>
  <li>TLS is on by the profile, so cameras can cross less-trusted segments.</li>
  <li>Authorization is attribute-based and cloud-managed, with one rule for the fleet and an obvious path to per-camera scoping.</li>
  <li>The broker is now a managed Kubernetes workload with health you can query (<code class="language-plaintext highlighter-rouge">az iot ops check</code>), not a single container.</li>
</ul>

<p>And critically, the message contract from Part 1 is untouched. That is what sets up Part 3: once the fleet is publishing to the AIO broker, AIO can route that traffic to the cloud and onboard new kinds of cameras as assets - again with no application change.</p>

<p><a href="/https/jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/">Continue to Part 3: data flows, connectors, and the cloud.</a></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/overview-broker">Azure IoT Operations MQTT broker overview</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/howto-configure-brokerlistener">Configure broker listeners in Azure IoT Operations</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/howto-configure-authentication">Configure broker authentication (X.509)</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/howto-configure-authorization">Configure broker authorization</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/deploy-iot-ops/howto-deploy-iot-operations">Deploy Azure IoT Operations to an Arc-enabled Kubernetes cluster</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-arc/kubernetes/overview">Azure Arc-enabled Kubernetes overview</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="azure-iot-operations" /><category term="azure-iot-operations" /><category term="aio" /><category term="azure-iot" /><category term="azure" /><category term="mqtt" /><category term="iot" /><category term="edge" /><category term="kubernetes" /><category term="azure-arc" /><category term="x509" /><category term="tls" /><category term="security" /><summary type="html"><![CDATA[Swap Eclipse Mosquitto for the Azure IoT Operations MQTT broker on Arc-enabled Kubernetes: a broker seam in .NET, TLS, per-camera X.509 identity, and one authorization rule that reproduces the whole ACL.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/azure-iot-operations-mqtt-broker-camera-fleet.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/azure-iot-operations-mqtt-broker-camera-fleet.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud</title><link href="https://jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/" rel="alternate" type="text/html" title="Cameras on Azure IoT Operations, Part 3: Data Flows, Connectors, and the Cloud" /><published>2026-06-19T10:00:00+00:00</published><updated>2026-06-19T10:00:00+00:00</updated><id>https://jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector</id><content type="html" xml:base="https://jaredrhodes.com/blog/azure-iot-operations-dataflows-onvif-connector/"><![CDATA[<p>This is <strong>Part 3 of a three-part series</strong> on running a real camera fleet on <a href="https://learn.microsoft.com/azure/iot-operations/">Azure IoT Operations</a> (AIO):</p>

<ul>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Part 1: the control plane and network model</a> - what the system is and how the cameras and networks work.</li>
  <li><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Part 2: swapping in the Azure IoT Operations MQTT broker</a> - TLS, X.509 camera identity, and topic authorization.</li>
  <li><strong>Part 3 (this post): data flows, connectors, and the cloud</strong> - forwarding telemetry to Event Hubs and onboarding ONVIF cameras as AIO assets.</li>
</ul>

<p><a href="/https/jaredrhodes.com/blog/azure-iot-operations-mqtt-broker-camera-fleet/">Part 2</a> put the fleet on the Azure IoT Operations MQTT broker. Now the broker is no longer just a message bus - it is an <strong>edge data plane</strong>, and two AIO features turn that into real leverage: <strong>data flows</strong> route telemetry to the cloud, and <strong>connectors</strong> bring in cameras that cannot speak our protocol at all. Both land without changing the control plane, which is the recurring theme of this series.</p>

<h2 id="forwarding-telemetry-to-the-cloud-for-free">Forwarding telemetry to the cloud, for free</h2>

<p>In <a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Part 1</a>, every producer publishes to one topic tree: <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/&lt;channel&gt;</code>. That single fact makes cloud egress almost trivial. An AIO <strong>data flow</strong> has a source and a destination; point the source at the local broker on the <code class="language-plaintext highlighter-rouge">cameras/#</code> tree - the exact tree everything already uses - and point the destination at Azure. No producer changes, no new topics, nothing republished.</p>

<p>The destination here is <strong>Azure Event Hubs</strong>, addressed through its Kafka surface, authenticated with the AIO instance’s <strong>managed identity</strong> (no connection strings on disk):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">connectivity.iotoperations.azure.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">DataflowEndpoint</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cameranetwork-eventhub</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">azure-iot-operations</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">endpointType</span><span class="pi">:</span> <span class="s">Kafka</span>
  <span class="na">kafkaSettings</span><span class="pi">:</span>
    <span class="na">host</span><span class="pi">:</span> <span class="s2">"</span><span class="s">&lt;EVENTHUB_NAMESPACE&gt;.servicebus.windows.net:9093"</span>
    <span class="na">authentication</span><span class="pi">:</span>
      <span class="na">method</span><span class="pi">:</span> <span class="s">SystemAssignedManagedIdentity</span>
      <span class="na">systemAssignedManagedIdentitySettings</span><span class="pi">:</span> <span class="pi">{}</span>
    <span class="na">tls</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s">Enabled</span>
</code></pre></div></div>

<p>The data flow itself wires the local broker to that endpoint. The source <code class="language-plaintext highlighter-rouge">endpointRef: default</code> is AIO’s built-in local MQTT broker (<code class="language-plaintext highlighter-rouge">aio-broker</code>), and one side of every data flow must be that local broker:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">connectivity.iotoperations.azure.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Dataflow</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cameras-to-eventhub</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">azure-iot-operations</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">profileRef</span><span class="pi">:</span> <span class="s">default</span>
  <span class="na">mode</span><span class="pi">:</span> <span class="s">Enabled</span>
  <span class="na">operations</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">operationType</span><span class="pi">:</span> <span class="s">Source</span>
      <span class="na">sourceSettings</span><span class="pi">:</span>
        <span class="na">endpointRef</span><span class="pi">:</span> <span class="s">default</span>          <span class="c1"># the local broker (host aio-broker)</span>
        <span class="na">dataSources</span><span class="pi">:</span> <span class="pi">[</span> <span class="s2">"</span><span class="s">cameras/#"</span> <span class="pi">]</span>   <span class="c1"># the same tree from Part 1</span>
    <span class="pi">-</span> <span class="na">operationType</span><span class="pi">:</span> <span class="s">Destination</span>
      <span class="na">destinationSettings</span><span class="pi">:</span>
        <span class="na">endpointRef</span><span class="pi">:</span> <span class="s">cameranetwork-eventhub</span>
        <span class="na">dataDestination</span><span class="pi">:</span> <span class="s">camera-telemetry</span>   <span class="c1"># the Event Hub / Kafka topic name</span>
</code></pre></div></div>

<p>Before applying it, you create the Event Hubs namespace and hub and grant the AIO managed identity the <strong>Azure Event Hubs Data Sender</strong> role - that role grant is what the managed-identity auth above relies on. Telemetry is forwarded as <strong>JSON pass-through</strong>, so no schema registry is needed; the payloads landing in Event Hubs are the unchanged <code class="language-plaintext highlighter-rouge">CameraNetwork.Contracts</code> JSON from Part 1.</p>

<figure class="diagram diagram--scroll">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/dataflow-eventhub.svg" alt="Cloud egress in one data flow: agents and gateways publish to the AIO MQTT broker at the Arc-enabled k3s edge, and a single data flow over the cameras topic tree forwards JSON pass-through to Azure Event Hubs via its Kafka surface with managed identity, fanning out to Fabric Real-Time Intelligence and Azure Data Explorer" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>As agents heartbeat, the Event Hubs “incoming messages” graph rises, carrying availability, inventory, status, and events for the whole fleet. From there, <strong>Fabric Real-Time Intelligence</strong> or <strong>Azure Data Explorer</strong> are the natural next stops for dashboards and historical analytics; those destinations add a schema-registry reference, but the edge side stays exactly as shown. The reason this is so cheap is structural: because Part 1 single-sourced the topic tree, cloud egress is one data flow over <code class="language-plaintext highlighter-rouge">cameras/#</code>, not an integration per producer.</p>

<h2 id="bringing-in-cameras-that-cannot-speak-the-protocol">Bringing in cameras that cannot speak the protocol</h2>

<p>Data flows handle the outbound story. The inbound story is connectors. AIO ships an <strong>ONVIF connector</strong> and a <strong>media connector</strong> that can talk to standards-based cameras directly - discover them, model them as assets, and publish their telemetry into the broker. That is a different path from the Class A site gateway in Part 1, and it is interesting precisely because it lets AIO itself onboard a camera.</p>

<p>The connectors are <strong>preview</strong> (per the Microsoft Learn docs linked at the bottom of this post, as retrieved in June 2026) and their custom resources are version- and install-specific, so they are deployed through the supported tooling rather than checked-in YAML. The flow is:</p>

<ol>
  <li>Register the camera as an <strong>Azure Device Registry device</strong> (its ONVIF endpoint plus credentials).</li>
  <li>Let discovery enumerate the camera’s capabilities and profiles.</li>
  <li>Create <strong>assets</strong> for the streams and snapshots you care about.</li>
</ol>

<p>At that point the connector is publishing asset telemetry into the AIO broker - but on <em>its</em> topic and in <em>its</em> shape, not on the <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/...</code> contract the controller understands. Something has to translate. That something is a small bridge.</p>

<h2 id="the-aiobridge-a-gateway-whose-probe-is-a-connector">The AioBridge: a gateway whose probe is a connector</h2>

<p><code class="language-plaintext highlighter-rouge">CameraNetwork.AioBridge</code> is an in-cluster Worker service. Conceptually it is <strong>a site gateway whose probe is the AIO connector</strong> instead of a direct ONVIF query. It subscribes to the connector’s telemetry, maps each asset to a <code class="language-plaintext highlighter-rouge">(site, camera)</code> pair, and <strong>republishes onto <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/...</code> using the same <code class="language-plaintext highlighter-rouge">CameraJson</code> serializer the rest of the fleet uses</strong>. To the controller, the result is indistinguishable from a Class A camera behind a gateway - which is the whole reason Part 1 insisted the controller never branch on camera class.</p>

<p>The bridge connects to AIO’s <strong>internal</strong> listener (<code class="language-plaintext highlighter-rouge">aio-broker:18883</code>, TLS plus a Kubernetes service-account token), so it never leaves the cluster. It gets its SAT through a projected volume and trusts the broker via the AIO CA bundle:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 50-aiobridge-deployment.yaml (trimmed)</span>
<span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">aio-bridge</span>
    <span class="c1"># image lives in a private registry; substitute your own build of the bridge</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">&lt;your-registry&gt;/cameranetwork-aiobridge:0.1.0</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__Host</span><span class="pi">,</span>  <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">aio-broker"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__Port</span><span class="pi">,</span>  <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">18883"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__UseTls</span><span class="pi">,</span> <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__CaFile</span><span class="pi">,</span> <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/var/run/certs/ca.crt"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__SatAuthFile</span><span class="pi">,</span> <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/var/run/secrets/tokens/broker-sat"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__ConnectorTelemetryTopic</span><span class="pi">,</span> <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">azure-iot-operations/data/#"</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">AioBridge__DefaultSiteId</span><span class="pi">,</span> <span class="nv">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">aio-edge"</span> <span class="pi">}</span>
<span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">broker-sat</span>
    <span class="na">projected</span><span class="pi">:</span>
      <span class="na">sources</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">serviceAccountToken</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">path</span><span class="pi">:</span> <span class="nv">broker-sat</span><span class="pi">,</span> <span class="nv">audience</span><span class="pi">:</span> <span class="nv">aio-internal</span><span class="pi">,</span> <span class="nv">expirationSeconds</span><span class="pi">:</span> <span class="nv">86400</span> <span class="pi">}</span>
</code></pre></div></div>

<p>Asset-to-camera identity is explicit so cameras do not collide: the preferred path is custom attributes on each asset (<code class="language-plaintext highlighter-rouge">cameraNetwork.site</code> and <code class="language-plaintext highlighter-rouge">cameraNetwork.camera</code>), with a fallback of a default site id plus a slug of the asset name. Connectors are preview, so the two things most likely to differ between installs - the connector’s telemetry topic and its payload field names - are isolated in one class, <code class="language-plaintext highlighter-rouge">ConfigurableAssetTelemetryMapper</code>, and covered by tests in the private reference implementation. Like the rest of the camera solution, the bridge itself is not published, so treat that class and its tests as design detail rather than downloadable code. If your connector publishes elsewhere or names fields differently, you adjust that single mapper, not the controller.</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/aiobridge-republish.svg" alt="The AioBridge: an ONVIF or RTSP camera is onboarded by the AIO ONVIF/media connector as an Azure Device Registry asset; CameraNetwork.AioBridge subscribes to the connector telemetry and republishes it on the cameras/{site}/{camera} tree via CameraJson, so the controller ingests it and it appears on the dashboard with no controller change" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<h2 id="commands-back-to-connector-fronted-cameras">Commands back to connector-fronted cameras</h2>

<p>A camera that only reports is half a control plane. The bridge also answers commands, behind a toggle (<code class="language-plaintext highlighter-rouge">AioBridge:HandleCommands</code>, on by default). It subscribes to <code class="language-plaintext highlighter-rouge">cameras/+/+/cmd</code> and answers <strong>only for cameras it has actually seen</strong> - it tracks the assets it has observed, so it never steals a command meant for a Class B agent that happens to share the topic space.</p>

<p>Command handling sits behind an <code class="language-plaintext highlighter-rouge">IBridgeCommandExecutor</code> seam, mirroring the site gateway’s policy. The default executor acks <code class="language-plaintext highlighter-rouge">get_status</code> as <code class="language-plaintext highlighter-rouge">succeeded</code> and acks everything else - including <code class="language-plaintext highlighter-rouge">snapshot</code> - as <code class="language-plaintext highlighter-rouge">unsupported</code>, until you plug in a real executor backed by media-connector capture or ONVIF control. The ack comes back on <code class="language-plaintext highlighter-rouge">cameras/&lt;site&gt;/&lt;camera&gt;/cmd_ack</code>, the same channel from Part 1. In the dashboard, you click get-status on an AIO camera and watch the <code class="language-plaintext highlighter-rouge">cmd_ack</code> arrive, exactly as you would for an agent-managed camera.</p>

<p>This is the closed command set from Part 1 doing its job again: the bridge cannot be told to do anything arbitrary, only to attempt actions from the fixed vocabulary, and it honestly reports <code class="language-plaintext highlighter-rouge">unsupported</code> for the ones it cannot yet perform.</p>

<h2 id="the-whole-picture">The whole picture</h2>

<p>Put the three posts together and the shape is a single contract with interchangeable parts underneath it:</p>

<figure class="diagram">
  <img src="/https/jaredrhodes.com/assets/diagrams/aio-cameras/whole-picture.svg" alt="The whole picture: three producer kinds - Class B izon-agent, Class A site-gateway, and the AIO connector plus AioBridge - all speak the cameras topic tree into the AIO MQTT broker; the broker feeds the controller's dashboard, API and metrics and an AIO data flow forwards to Event Hubs and on to Fabric RTI and ADX" loading="lazy" decoding="async" data-theme-filter="off" />
</figure>

<p>Three different kinds of cameras, one wire contract, one broker, one data flow to the cloud - and the controller treats all of them identically. Azure IoT Operations did not reshape the application; it slotted in underneath the contract (the broker, Part 2) and alongside it (the connectors and data flows, Part 3). That is the argument for AIO in a camera control plane: it is an enterprise-grade edge MQTT broker and cloud data plane that you can adopt <strong>incrementally</strong>, behind a seam, without rewriting the system that already works.</p>

<p>Start at <a href="/https/jaredrhodes.com/blog/azure-iot-operations-camera-control-plane/">Part 1</a> if you came in here first - the contract and the network model are what make all of this hold together.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/connect-to-cloud/overview-dataflow">Azure IoT Operations data flows overview</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/connect-to-cloud/howto-configure-kafka-endpoint">Configure a data flow endpoint for Azure Event Hubs</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/howto-use-onvif-connector">Connector for ONVIF (preview)</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/howto-use-media-connector">Media connector (preview)</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/overview-manage-assets">Azure Device Registry overview</a></li>
  <li><a href="https://learn.microsoft.com/azure/iot-operations/connect-to-cloud/howto-configure-fabric-real-time-intelligence-endpoint">Send data to Microsoft Fabric Real-Time Intelligence</a></li>
</ul>]]></content><author><name>Jared Rhodes</name></author><category term="azure-iot-operations" /><category term="azure-iot-operations" /><category term="aio" /><category term="azure-iot" /><category term="azure" /><category term="mqtt" /><category term="iot" /><category term="edge" /><category term="event-hubs" /><category term="dataflows" /><category term="onvif" /><category term="fabric" /><summary type="html"><![CDATA[Forward camera telemetry to the cloud with Azure IoT Operations data flows to Event Hubs, then onboard ONVIF cameras as AIO assets through a connector and a bridge - all with no change to the control plane.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jaredrhodes.com/assets/og/azure-iot-operations-dataflows-onvif-connector.png" /><media:content medium="image" url="https://jaredrhodes.com/assets/og/azure-iot-operations-dataflows-onvif-connector.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>