<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/" xmlns:discourse="http://discourse.org/rss/modules/discourse/">
<channel>
<title>Roque</title>
<link>https://j-roque.com/</link>
<description>Recent content on Roque</description>
<generator>Hugo -- gohugo.io</generator>
<language>en</language>
<managingEditor>joao.roque.94@gmail.com (João Roque)</managingEditor>
<webMaster>joao.roque.94@gmail.com (João Roque)</webMaster>
<copyright>© 2026 João Roque</copyright>
<lastBuildDate>Tue, 08 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://j-roque.com/index.xml" rel="self" type="application/rss+xml"/>
<item>
<title>Integrating with OIB: Board Gatekeeper and Eventing</title>
<link>https://j-roque.com/posts/20260908-oib/</link>
<pubDate>Tue, 08 Sep 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260908-oib/</guid>
<description>What ASMPT&amp;rsquo;s Operations Information Broker actually is, and why the SMT line asks the MES for permission.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260908-oib/featured.png" alt="Integrating with OIB: Board Gatekeeper and Eventing" /&gt;&lt;/p&gt;&lt;p&gt;Right now, on some SMT line, a printed circuit board is sitting motionless in a conveyor while the line waits — because a web service has not yet said whether that board may continue.&lt;/p&gt;
&lt;p&gt;That is &lt;em&gt;ASMPT WORKS&lt;/em&gt; OIB working exactly as designed.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Operations Information Broker&lt;/strong&gt; — OIB — is ASMPT&amp;rsquo;s integration platform for the WORKS software suite. It has been around since 2008, it is built on WCF web services, and it is the only sanctioned way into SIPLACE Pro, WORKS Setup Center, Line Control, Traceability, and the rest of the ASMPT stack. The native interfaces those products used to expose (COM, direct SQL, Secs-GeM, parsed XML files) are gone from public use.&lt;/p&gt;
&lt;p&gt;ASMPT&amp;rsquo;s own training decks list five things OIB lets your software do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;query for operational data&lt;/li&gt;
&lt;li&gt;create or modify data&lt;/li&gt;
&lt;li&gt;control the line&lt;/li&gt;
&lt;li&gt;be informed about line events&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;be requested by the line for individual decisions&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Four of those are ordinary. The fifth is the one this post is about.&lt;/p&gt;
&lt;p&gt;This post covers what OIB is and how CM has learned from it and has created turn key integration strategies.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;What OIB Actually Is
&lt;div id="what-oib-actually-is" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-oib-actually-is" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;OIB is not an MES and does not try to be one. It is a &lt;strong&gt;Service Oriented Architecture&lt;/strong&gt; layer whose only job is to unify and abstract the shop floor so that higher-level software stops caring about deployment topology. Its goal is to monitor, control and handle the logistics of an SMT line that runs ASMPT machines.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ASMPT Line"
src="https://image.j-roque.com/posts/20260908-oib/asmpt_asm_line.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It splits into two halves.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIB Core&lt;/strong&gt; is the infrastructure: the OIB database plus a set of core services.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;Core service&lt;/th&gt;
&lt;th style="text-align: left"&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Configuration Manager&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Owns and persists the OIB Factory Layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Service Locator&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Service registration and discovery — the yellow pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;WS Eventing&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Guaranteed-delivery event dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Display Service&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Messages and questions to line-side and station viewers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Central Settings&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Shared configuration, scoped per factory element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Factory Calendar&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Shifts, maintenance downtime, appointments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;User Manager&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Authentication and authorisation — local users, AD, or both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Health Check&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Polls registered services for availability; can e-mail admins&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each of those has an ASM Studio plugin in front of it — Factory Explorer, Service Manager, Factory Calendar, User Manager, Operation Manager, and a Test Box for poking endpoints by hand. Configuration is a GUI activity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIB Adapters&lt;/strong&gt; are the endpoints that expose actual business functionality:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;Adapter&lt;/th&gt;
&lt;th style="text-align: left"&gt;What it gives you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;SPI&lt;/code&gt; (SIPLACE Pro Interface)&lt;/td&gt;
&lt;td style="text-align: left"&gt;CRUD on SIPLACE Pro — components and shapes, placement lists, panels and sub-panels, setups, recipes, line and machine configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Optimizer&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Configure, start/stop, and read results of job optimisation; progress events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Line Control&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Start/continue/stop the line, block/unblock station conveyors, download recipes, read line and recipe status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Setup Center&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Read/write packaging units; events for feeder, table and material movement and quantity change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Traceability&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Per-board production result — where, when, which errors, which recipe and order, which packaging units&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Monitoring&lt;/code&gt; (OIS)&lt;/td&gt;
&lt;td style="text-align: left"&gt;Real-time machine state, pickup and reject rate, active recipe, feeder track info&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Board Gatekeeper&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Board-level GO/NOGO at a scan point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Material Manager&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Material flow events, including moisture sensitivity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Maintenance Data Interface&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Station and setup configuration with unique IDs, plus station health data every 15 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Core is typically installed once per factory on a central server, while an Adapter instance is installed for each installed instance of the software product it fronts — not a fixed rule of &amp;ldquo;one per line&amp;rdquo;. For Setup Center that instance happens to be per-line: a customer with ten lines running Setup Center has ten Setup Center Adapter instances, each registering independently. A different adapter could just as easily be one per factory or one per station, depending on how that product is deployed.&lt;/p&gt;
&lt;p&gt;In practice the split lands like this: the central server carries OIB Core, the OIB DB, Eventing, and the SPI Adapter; each line computer carries its own Setup Center Adapter, Line Control Adapter, Monitoring Adapter, Traceability, and Board Gatekeeper.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Factory Layout
&lt;div id="the-factory-layout" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-factory-layout" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Part of the OIB concept is a persisted model of the factory, following ISA-95:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;Enterprise
└── Site
    └── Area              (optional — lines can attach directly to sites)
        └── Production line
            └── Work cell (can nest)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A production line is a printer, some placement stations, an oven. A work cell is one of those stations. Areas are optional and work cells nest, which is the escape hatch for real factories that never match the diagram.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ASM Works"
src="https://image.j-roque.com/posts/20260908-oib/asm_works.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This layout lives in the OIB DB, is edited in WORKS Studio under Factory Explorer → Factory Layout, and can be read and written programmatically through the Configuration Manager. It is not decoration — discovery is built on top of it, and so are Central Settings and the Factory Calendar, both of which attach to individual factory elements.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Board Gatekeeper
&lt;div id="board-gatekeeper" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#board-gatekeeper" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Process control on an ASMPT line belongs to SIPLACE Pro Line Control. &lt;strong&gt;WORKS Board Gatekeeper&lt;/strong&gt; — BGK — extends it with something Line Control does not do: it lets an external system make a decision about a specific PCB, at a specific point in the line, &lt;em&gt;before&lt;/em&gt; that board is allowed to continue.&lt;/p&gt;
&lt;p&gt;Two capabilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;get informed about PCBs — optionally identified by barcode — transported anywhere in the factory line&lt;/li&gt;
&lt;li&gt;stop a PCB in the conveyor if requirements are not fulfilled&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;BGK is an agent sitting between barcode scanners and conveyor hardware on one side, and your MES on the other. You choose where the scan points are — line entry, line exit, into a station, out of a station.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ASM BGK"
src="https://image.j-roque.com/posts/20260908-oib/asm_bgk.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It runs in one of three modes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;Mode&lt;/th&gt;
&lt;th style="text-align: left"&gt;Conveyor stop&lt;/th&gt;
&lt;th style="text-align: left"&gt;MES informed&lt;/th&gt;
&lt;th style="text-align: left"&gt;Use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;No&lt;/td&gt;
&lt;td style="text-align: left"&gt;Yes&lt;/td&gt;
&lt;td style="text-align: left"&gt;Track boards, don&amp;rsquo;t gate them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Interlocking&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Yes&lt;/td&gt;
&lt;td style="text-align: left"&gt;Yes, and waits for confirmation&lt;/td&gt;
&lt;td style="text-align: left"&gt;Real gatekeeping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;No MES&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;No&lt;/td&gt;
&lt;td style="text-align: left"&gt;No&lt;/td&gt;
&lt;td style="text-align: left"&gt;Barcode whispering only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;Notification&lt;/code&gt; is tracking — you learn that a registered board entered a machine.
&lt;code&gt;Interlocking&lt;/code&gt; is control — nothing moves until you say so. Same contract, wildly different risk profile.&lt;/p&gt;
&lt;p&gt;Hardware integration is not BGK&amp;rsquo;s domain, you need conveyor and scanner hardware wired into its import interfaces, with the CogiScan Product Flow Controller available as an all-in-one box. And &lt;strong&gt;PCB validation is not BGK&amp;rsquo;s problem either&lt;/strong&gt;. BGK asks the question. Deciding whether a board may proceed is entirely the MES&amp;rsquo;s job.&lt;/p&gt;
&lt;h3 class="relative group"&gt;BoardRequest Is a Synchronous Veto
&lt;div id="boardrequest-is-a-synchronous-veto" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#boardrequest-is-a-synchronous-veto" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;BoardRequest&lt;/code&gt; is the whole reason Board Gatekeeper exists. The request is small:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;&lt;code&gt;BoardRequestData&lt;/code&gt;&lt;/th&gt;
&lt;th style="text-align: left"&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Board&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Barcode&lt;/code&gt; (empty string if nothing was scanned) and &lt;code&gt;BoardTime&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Position&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Where in the line this happened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Context&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;A GUID correlating Request / Released / Failed for the same board&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;Context&lt;/code&gt; is the field you build your tracking on. It is the only thing tying a &lt;code&gt;BoardRequest&lt;/code&gt; to the &lt;code&gt;BoardReleased&lt;/code&gt; or &lt;code&gt;BoardFailed&lt;/code&gt; that eventually follows it.&lt;/p&gt;
&lt;p&gt;The response is where the MES exercises control:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;&lt;code&gt;BoardRequestResult&lt;/code&gt;&lt;/th&gt;
&lt;th style="text-align: left"&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;RequestResult&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;The verdict — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Reason&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Free text, surfaced on error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;BoardPath&lt;/code&gt; / &lt;code&gt;BoardSide&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Which board and side the result applies to — &lt;code&gt;Top&lt;/code&gt;, &lt;code&gt;Bottom&lt;/code&gt;, &lt;code&gt;Undefined&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;OverridingBarcode&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Rewrite the barcode and whisper the new one down the line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;RecipeName&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Tell the stations which program to run (Process Lens only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;VIHResult&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Virtual Inkspot Handling — mark individual subpanels to be skipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;BoardCorrection&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Per-placement delta on x, y, theta, per subpanel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;AdditionalBoardData&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Extra board and subpanel data for BGK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And the verdict itself:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left"&gt;&lt;code&gt;RequestResult&lt;/code&gt;&lt;/th&gt;
&lt;th style="text-align: left"&gt;What the line does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Confirmed&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Board proceeds to the next processing step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Rejected&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Board is locked in the conveyor and must not enter the next step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;Internal_Error&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Conveyor stops, board must be removed from the line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left"&gt;&lt;code&gt;PassThrough&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left"&gt;Board passes through without being produced&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things about this table. First, &lt;code&gt;Internal_Error&lt;/code&gt; is not a soft failure — it is an operator walking to the line with a pair of gloves. Throwing an unhandled exception out of your validation logic is a production stop.&lt;/p&gt;
&lt;p&gt;Second, everything here is a &lt;em&gt;string&lt;/em&gt;. &lt;code&gt;RequestResult&lt;/code&gt; is the string representation of &lt;code&gt;BoardNotificationResultValues&lt;/code&gt;, and so is &lt;code&gt;BoardSide&lt;/code&gt;, and so are the enums on &lt;code&gt;Position&lt;/code&gt;. That was deliberate: serializing enums as strings means adding a new enum member in a later interface version does not break software built against the old one. It is a small, unfashionable decision, and it is precisely what lets one adapter serve five interface versions at once.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;OIB Eventing
&lt;div id="oib-eventing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#oib-eventing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Board Gatekeeper is request/response. Nearly everything else in OIB flows through Eventing — it is the central nervous system of the platform, and every adapter publishes into it.&lt;/p&gt;
&lt;p&gt;OIB draws a sharp line between two kinds of adapter-initiated communication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simple eventing&lt;/strong&gt; is for data of minor importance. Your client opens its own web service, the adapter calls it directly, and if a message is lost nobody notices. The canonical example is the Optimizer Adapter reporting progress on an optimization run. A dropped progress update is a progress bar that jumps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Safe eventing&lt;/strong&gt; — what OIB actually means by &lt;em&gt;OIB Eventing&lt;/em&gt; — is for mission-critical data that is not allowed to get lost while the client is down. The Setup Center packaging unit consumption and movement. Miss one of those and your MES is planning production against material that no longer exists, which the docs describe, accurately, as causing &amp;ldquo;painful production problems&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Subscriptions"
src="https://image.j-roque.com/posts/20260908-oib/asm_eventing.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Connect IoT
&lt;div id="connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;OIB is complex: many adapters, many configurations. It requires the user to create a .net framework application and integrate with each different type of adapter. This easily leads to bespoke applications that exist and spread inside your shopfloor.&lt;/p&gt;
&lt;p&gt;With CM MES Connect IoT this is all seamless. Let&amp;rsquo;s build a simple integration using OIB &lt;code&gt;Board Gate Keeper&lt;/code&gt; for interlocking on Track-In and use &lt;code&gt;Eventing&lt;/code&gt; to perform a trackout.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Setup
&lt;div id="setup" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#setup" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the setup we can configure the all the typical configuration of OIB Core access, like the sdk, configuration manager and address, the lines and sites we want to handle and the lines we want to ignore.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Setup"
src="https://image.j-roque.com/posts/20260908-oib/workflow_setup.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the task &lt;code&gt;Extensions Setup&lt;/code&gt; we can configure all the OIB extensions we want to use for our implementation.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Board Gate Keeper Setup"
src="https://image.j-roque.com/posts/20260908-oib/setup_bgk.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Here, you will have access to features like selecting where the Board Gate Keeper should be registered in the ISA95 tree and what are the port ranges it should use.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Eventing"
src="https://image.j-roque.com/posts/20260908-oib/setup_eventing.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For eventing we can set where in the ISA 95 tree to listen to. Crucially, we also can choose when we consider an event &lt;em&gt;stale&lt;/em&gt; to be discarded. Important in reconnect cases.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Material In
&lt;div id="material-in" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#material-in" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Sending the information to the MES querying if a particular panel can enter a machine via board gate keeper is very transparent. We use the &lt;code&gt;Gate Keeper Board Request&lt;/code&gt; task to receive a request. We can then perform transformations and call the MES to signal we want to track-in a particular panel. The MES is already very robust in making sure that we only produce what we should produce and that everyting is in the proper conditions to produce.&lt;/p&gt;
&lt;p&gt;If the MES finds any problem in the request it will reply back with an error. We will catch that error and reply back to the OIB, with a requestResult of Error and an error message that will be displayed in the HMI of the machine. If it&amp;rsquo;s ok, we will simply reply back with a requestResult &lt;em&gt;Confirmed&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Material In"
src="https://image.j-roque.com/posts/20260908-oib/material_in.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Material Out
&lt;div id="material-out" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#material-out" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;For material out we won&amp;rsquo;t apply interlocking.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;It&amp;rsquo;s true that we could with the OIB &lt;em&gt;Traceability&lt;/em&gt; module Board Produced Request, but this is not a common request.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We will just be notified that the panel has finished by eventing and we will track-out the material in the MES.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Material Out"
src="https://image.j-roque.com/posts/20260908-oib/material_out.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Modelling OIB with Connect IoT
&lt;div id="modelling-oib-with-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#modelling-oib-with-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In reality these kind of integrations are fairly uncommon.&lt;/p&gt;
&lt;p&gt;The Electronics industry template, ships OIB support out of the box. It covers a plethora of adapters and MES scenarios &lt;a
href="https://help.criticalmanufacturing.com/userguide/industrytemplates/smt/equipmentintegration/oib/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For example, mapping the same scenario with the Electronics industry template (EIT) is just filling in a smart table.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="EIT Integration"
src="https://image.j-roque.com/posts/20260908-oib/EID_Integration.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The workflow may seem complex, but it implements a full coverage of OIB use cases and adapters. It also supports different ways you can use OIB for all these MES actions. It also is resilient and robust and has already been battle tested against naive implementations of these integrations.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OIB Workflow"
src="https://image.j-roque.com/posts/20260908-oib/EIT_oib_Integration.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The EIT comes out of the box with typical integrations for Electronics ready to use, from Hermes, OIB, IPC-CFX, Fuji-Nexim, etc.&lt;/p&gt;
&lt;p&gt;We can then leverage those integrations as they are or use extendable controllers to adapt them to our particular use case &lt;a
href="https://j-roque.com/posts/20250730-iot-extensibility-iv/"
target="_blank"
&gt;here&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;OIB is a complex protocol, it&amp;rsquo;s industry and vendor specific. But with CM MES Connect IoT and Industry Template it becomes as simple as configuring a table. It buys you the peace of mind of having a resilient, turn key solution to one of the hardest nuts to crack on an electronics shopfloor.&lt;/p&gt;
&lt;p&gt;Right now, on some SMT line, a printed circuit board is sitting motionless in a conveyor while the line waits — because a web service has not yet said whether that board may continue. The MES receives this requests validates all is well, the recipe is correct, the line setup is ok, the machine is the correct one, the lot is as expected and it has the required raw materials and replies back with an ok, and the board can continue.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260908-oib/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260908-oib/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Turbo Charging your Code Tasks</title>
<link>https://j-roque.com/posts/20260817-codetaskutilities/</link>
<pubDate>Mon, 17 Aug 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260817-codetaskutilities/</guid>
<description>How you can side load utilities to your code task.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260817-codetaskutilities/featured.png" alt="Turbo Charging your Code Tasks" /&gt;&lt;/p&gt;&lt;p&gt;In previous blog posts we talked about how we could fully &lt;a
href="https://j-roque.com/posts/20260702-codetasktocustomtask/"
target="_blank"
&gt;migrate a code task&lt;/a&gt; into a customization task and then have it reuseable across the system. Today, we are focusing on a little known middle road approach. We keep the flexibility of a code task and we expand it be injecting more helpful utilities.&lt;/p&gt;
&lt;p&gt;No more fifty lines of helper logic copy-pasted in a Code Task, and then copy-pasted across dozens of other code tasks across your workflows. There&amp;rsquo;s a better way, and it&amp;rsquo;s been sitting in plain sight in the task library API the whole time.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Utilities"
src="https://image.j-roque.com/posts/20260817-codetaskutilities/code_utilities.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;The Problem With Code Tasks
&lt;div id="the-problem-with-code-tasks" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-problem-with-code-tasks" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;Code Task&lt;/code&gt; is one of the most powerful tools in Connect IoT. It gives you an &lt;em&gt;escape hatch&lt;/em&gt; from low code, drops you into a Typescript editor inside the workflow designer, and lets you write whatever logic you need against the &lt;code&gt;framework&lt;/code&gt; object that&amp;rsquo;s handed to you. It&amp;rsquo;s flexible, it&amp;rsquo;s fast to iterate on, it&amp;rsquo;s update cycle is managed by CM, and it&amp;rsquo;s exactly what you reach for when a low code task or converter would be overkill for a one-off transformation.&lt;/p&gt;
&lt;p&gt;The problem is that a Code Task is, by design, a hermetically sealed blob of text sitting inside a workflow. It has no &lt;code&gt;import&lt;/code&gt;, no &lt;code&gt;require&lt;/code&gt;, no access to anything outside of what the runtime injects into it. If you need the same parsing routine, the same MES query pattern, or the same binary conversion helper in five different Code Tasks across your project, you either retype it five times or you paste it and hope nobody edits one copy without the other four. Neither option ages well, and neither is testable in isolation.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Reusable Library Through a Task
&lt;div id="reusable-library-through-a-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reusable-library-through-a-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The trick is to stop thinking about the problem as &amp;ldquo;how do I share code between Code Tasks&amp;rdquo; and start thinking about it as &amp;ldquo;how do I get a library object registered somewhere every Code Task can reach.&amp;rdquo; Connect IoT already has that somewhere: the &lt;code&gt;Task.Library&lt;/code&gt;, injected via the framework&amp;rsquo;s DI container into any task that asks for it.&lt;/p&gt;
&lt;p&gt;A regular low code &lt;code&gt;Task&lt;/code&gt; can request this library and register an implementation against it. Once registered, that implementation is available to every Code Task in the same controller runtime, addressed by a simple string id. This is exactly the pattern used in the &lt;a
href="https://github.com/criticalmanufacturing/mes-common-library/tree/11.3/dev/features/IoTCodeUtilities/IoTCodeUtilities.IoT/src/controller-engine-custom-code-utilities-tasks"
target="_blank"
&gt;&lt;code&gt;controller-engine-custom-code-utilities-tasks&lt;/code&gt;&lt;/a&gt; package from CM&amp;rsquo;s &lt;code&gt;mes-common-library&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the shape of it, taken directly from the &lt;code&gt;customCodeUtilitiesAPI&lt;/code&gt; task:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import { Task, TaskBase, TYPES, DI } from &amp;#34;@criticalmanufacturing/connect-iot-controller-engine&amp;#34;;
import { ID, CustomUtilitiesUtilApi } from &amp;#34;./customCodeUtilitiesAPI.task.util.api&amp;#34;;
@Task.Task()
export class CustomCodeUtilitiesAPITask extends TaskBase {
@DI.Inject(TYPES.Task.Library)
    public taskCodeExecutionLibs: Task.Library;
public override async onBeforeInit(): Promise&amp;lt;void&amp;gt; {
        if (this.taskCodeExecutionLibs != null) {
            if (this.taskCodeExecutionLibs.implementations[ID] == null) {
                this.taskCodeExecutionLibs.addImplementation(ID, new CustomUtilitiesUtilApi());
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three moving parts, and none of them are complicated:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@DI.Inject(TYPES.Task.Library)&lt;/code&gt; pulls the shared &lt;code&gt;Task.Library&lt;/code&gt; out of the container. It&amp;rsquo;s the same library instance for every task running in that controller.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;onBeforeInit&lt;/code&gt; runs once, before the task starts doing anything with inputs and outputs, which makes it the right moment to register something global.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addImplementation(ID, instance)&lt;/code&gt; drops a plain class instance into the library under a string key, guarded by a null check so dragging the task twice into a process doesn&amp;rsquo;t clobber an already-registered implementation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Drop this task anywhere in your process — it doesn&amp;rsquo;t even need inputs wired up, it just needs to run once — and from that point on, every Code Task in the same controller can reach into &lt;code&gt;libs[ID]&lt;/code&gt; and call methods on a real, testable, versioned class instead of an inline copy-paste block.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A Code Task with no imports can still call into a fully unit-tested TypeScript class. You just have to register it first.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Making It Discoverable: IntelliSense in the Template
&lt;div id="making-it-discoverable-intellisense-in-the-template" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-it-discoverable-intellisense-in-the-template" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Registering the implementation solves the runtime half of the problem, but it leaves a rough edge: how does the person writing the Code Task know that &lt;code&gt;this.framework.customCodeUtilitiesAPI&lt;/code&gt; even exists, let alone what methods it exposes? Nobody wants to reverse-engineer a library from a compiled &lt;code&gt;.js&lt;/code&gt; file while sitting in a workflow designer.&lt;/p&gt;
&lt;p&gt;This is where the &lt;code&gt;templates/&lt;/code&gt; folder in the package earns its keep. Each task ships a &lt;code&gt;task_*.json&lt;/code&gt; definition that wires a &lt;code&gt;beforeInit&lt;/code&gt; trigger to a small script:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;tasks&amp;#34;: [
    {
      &amp;#34;name&amp;#34;: &amp;#34;customCodeUtilitiesAPI&amp;#34;,
      &amp;#34;scripts&amp;#34;: {
        &amp;#34;injectUtilitiesHTML&amp;#34;: &amp;#34;${script(./scripts/customCodeUtilitiesAPI/injectUtilitiesHTML.ts)}&amp;#34;
      },
      &amp;#34;triggers&amp;#34;: {
        &amp;#34;beforeInit&amp;#34;: [
          { &amp;#34;type&amp;#34;: &amp;#34;Reference&amp;#34;, &amp;#34;script&amp;#34;: &amp;#34;injectUtilitiesHTML&amp;#34; }
        ]
      }
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That script runs in the designer itself, not in the controller runtime, and its only job is to hand the Code Task editor a &lt;code&gt;.d.ts&lt;/code&gt; snippet describing the shape of the library it just registered:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;export function injectUtilitiesHTML(): IoTATLScriptContextTest {
    return {
        _execute: async function () {
            const ID: string = &amp;#34;customCodeUtilitiesAPI&amp;#34;
            const UTIL_API_DTS_CONTENT: string = `
export interface customUtilitiesAPI {
    getObjectById(framework: any, id: string, type: string, levelsToLoad?: number, typeIsTypeId?: boolean, settings?: SystemApiUtilsSettings): Promise&amp;lt;any&amp;gt;;
    getObjectByName(framework: any, name: string, type: string, levelsToLoad?: number, typeIsTypeId?: boolean, settings?: SystemApiUtilsSettings): Promise&amp;lt;any&amp;gt;;
    loadAttributes(framework: any, entity: any, specificAttributes?: string[], settings?: SystemApiUtilsSettings): Promise&amp;lt;any&amp;gt;;
    executeQuery(framework: any, queryObject: any, parameterCollection?: any, settings?: SystemApiUtilsSettings): Promise&amp;lt;any&amp;gt;;
    setInstanceSystemState(framework: any, instanceId: string, newState?: System.LBOS.Cmf.Foundation.BusinessObjects.AutomationSystemState,
        newCommunicationState?: System.LBOS.Cmf.Foundation.BusinessObjects.AutomationCommunicationState, settings?: SystemApiUtilsSettings): Promise&amp;lt;void&amp;gt;;
}`;
            const UTIL_API_CLASS_NAME: string = &amp;#34;customUtilitiesAPI&amp;#34;;
this.service?.container.library.addFields(
                { name: ID, type: UTIL_API_CLASS_NAME }
            );
            this.service?.container.library.addDefinitions(
                UTIL_API_DTS_CONTENT
            );
        },
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two calls do all the work: &lt;code&gt;addFields&lt;/code&gt; tells the editor&amp;rsquo;s code completion that a field named &lt;code&gt;customCodeUtilitiesAPI&lt;/code&gt; exists and has type &lt;code&gt;customUtilitiesAPI&lt;/code&gt;, and &lt;code&gt;addDefinitions&lt;/code&gt; feeds it the actual interface declaration for that type. Every one of the three example tasks follows this exact recipe, hand-writing an interface that mirrors the real class&amp;rsquo;s public methods.&lt;/p&gt;
&lt;p&gt;This is the part that&amp;rsquo;s easy to overlook and easy to get out of sync: the &lt;code&gt;.d.ts&lt;/code&gt; string is a manually maintained shadow of the actual implementation class. It buys full IntelliSense — method names, parameter hints, return types — in a Code Task editor that has no module system to speak of, but it means every time you touch the real class you also owe the template a matching update.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Three Working Examples
&lt;div id="three-working-examples" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#three-working-examples" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The repository ships three of these registration tasks, and each one is a good template for a &lt;strong&gt;different flavor of shared logic&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;API helpers: &lt;code&gt;customCodeUtilitiesAPI&lt;/code&gt;
&lt;div id="api-helpers-customcodeutilitiesapi" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#api-helpers-customcodeutilitiesapi" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CustomUtilitiesUtilApi&lt;/code&gt; wraps the repetitive parts of talking to MES from a Code Task, resolving objects by id or name, loading attributes, executing queries, flipping automation states, behind retry logic that already exists elsewhere in the framework (&lt;code&gt;Utilities.ExecuteWithSystemErrorRetry&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async getObjectByName(framework: any, name: string, type: string, levelsToLoad?: number,
    typeIsTypeId?: boolean, settings?: SystemApiUtilsSettings): Promise&amp;lt;any&amp;gt; {
    settings = settings || SystemApiUtilsDefaults;
    typeIsTypeId = typeIsTypeId || false;
    levelsToLoad = levelsToLoad || 0;
    let typeName = type;
if (typeIsTypeId === true) {
        typeName = await (this.resolveSystemTypeName(framework, type, settings));
    }
const input = new (await System.LBOS.Cmf.Foundation.BusinessOrchestration.GenericServiceManagement.InputObjects.GetObjectByNameInput)();
    input.Name = name;
    input.LevelsToLoad = levelsToLoad;
    input.Type = typeName;
const res = await Utilities.ExecuteWithSystemErrorRetry(framework.logger, settings.maxRetries, settings.sleepBetweenRetries, async () =&amp;gt; {
        return (await framework.system.call(input));
    });
return (res.Instance);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that &lt;code&gt;framework&lt;/code&gt; is still passed in as a parameter rather than captured somewhere. The class doesn&amp;rsquo;t own a framework reference, the caller hands it one, which means the exact same instance works no matter which controller or which Code Task calls it. It also keeps the class trivially mockable in unit tests, since &lt;code&gt;framework&lt;/code&gt; is just an argument.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Stateful mapping: &lt;code&gt;customCodeUtilitiesFramework&lt;/code&gt;
&lt;div id="stateful-mapping-customcodeutilitiesframework" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#stateful-mapping-customcodeutilitiesframework" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CustomCodeUtilitiesFramework&lt;/code&gt; is a step up in complexity — it resolves MES Smart Tables, caches the results in memory keyed by a hash of the lookup context, and persists that cache to the controller&amp;rsquo;s data store so it survives a restart:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async resolveSmartTable(framework: any, contextTableKeys: Map&amp;lt;string, any&amp;gt;,
    contextResolveValues: Map&amp;lt;string, any&amp;gt;,
    mappingTablePersistedName: string,
    configurationTable: string,
    onlyFirstRow: boolean = false,
    skipCache: boolean = false): Promise&amp;lt;any&amp;gt; {
    // ...builds a hash from contextTableKeys, checks the in-memory cache,
    // and falls back to a ResolveSmartTableInput call against MES on a miss
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the case that really justifies sideloading. Nobody wants to reimplement cache invalidation and persistence semantics inline in a Code Task editor, and definitely not five times. Registering this once as a library implementation means the caching behavior is defined, tested, and versioned in exactly one place.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Stateless conversion: &lt;code&gt;customCodeUtilitiesObjectTranslator&lt;/code&gt;
&lt;div id="stateless-conversion-customcodeutilitiesobjecttranslator" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#stateless-conversion-customcodeutilitiesobjecttranslator" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CustomUtilitiesUtilObjectTranslator&lt;/code&gt; is the simplest of the three — no MES calls, no framework dependency at all, just pure functions for ASCII/binary/hex/decimal conversion and non-printable character handling, the kind of thing that shows up constantly when talking to raw protocols like SECS/GEM or serial equipment:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public asciiToHex(input: string): string {
    let result = &amp;#39;&amp;#39;;
    for (let i = 0; i &amp;lt; input.length; i&amp;#43;&amp;#43;) {
        result &amp;#43;= input.charCodeAt(i).toString(16);
    }
    return result;
}
public hexToAscii(input: string): string {
    return Buffer.from(input, &amp;#39;hex&amp;#39;).toString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Small, deterministic, easy to unit test in complete isolation and exactly the kind of helper that otherwise gets retyped slightly wrong in a different Code Task every time someone needs it.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why Bother
&lt;div id="why-bother" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-bother" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;You could argue this is a lot of ceremony for something you could just paste into a Code Task. The payoff shows up the moment you have more than one Code Task that needs the logic, or the moment you need to change it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One source of truth.&lt;/strong&gt; Fix a bug in &lt;code&gt;resolveSmartTable&lt;/code&gt; once, and every Code Task referencing it gets the fix on the next deploy. No hunting through the process designer for every pasted copy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real unit tests.&lt;/strong&gt; These are plain TypeScript classes with injected dependencies, not inline strings inside a workflow. The package&amp;rsquo;s own &lt;a
href="https://github.com/criticalmanufacturing/mes-common-library/tree/11.3/dev/features/IoTCodeUtilities/IoTCodeUtilities.IoT/src/controller-engine-custom-code-utilities-tasks/test/unit"
target="_blank"
&gt;&lt;code&gt;test/unit&lt;/code&gt;&lt;/a&gt; folder does exactly this, spinning up the task through &lt;code&gt;EngineTestSuite.createTasks&lt;/code&gt; and asserting the implementation lands in the library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicit versioning.&lt;/strong&gt; The library ships and versions like any other task library package, so you know exactly which behavior a given deployment is running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero friction for the consumer.&lt;/strong&gt; From inside a Code Task, using the utility is just a lookup by id. No build step, no bundling concerns baked into the workflow itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Code Tasks are meant to be an &lt;em&gt;escape hatch&lt;/em&gt;, not a place to build a mini framework by copy-paste. The moment you notice the same block of logic showing up in a second Code Task, that&amp;rsquo;s the signal to pull it into a &lt;code&gt;Task.Library&lt;/code&gt; implementation instead. It costs you one small registration task and a class definition, and it buys you testability, a single point of maintenance, and a Code Task editor that stays exactly as short as it should be.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260817-codetaskutilities/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260817-codetaskutilities/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Reports, Rebuilt: SSRS to Stimulsoft, and the ClickHouse Pipeline in Between</title>
<link>https://j-roque.com/posts/20260730-reports/</link>
<pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260730-reports/</guid>
<description>A tour of the new MES Data Platform architecture for reporting — Housekeeper, Kafka, ClickHouse, Data Sets, Analytics Views, Stimulsoft — and why SQL Server ODS still has to work even after ClickHouse takes over.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260730-reports/featured.png" alt="Reports, Rebuilt: SSRS to Stimulsoft, and the ClickHouse Pipeline in Between" /&gt;&lt;/p&gt;&lt;p&gt;Replacing a reporting stack is never really about reporting. It&amp;rsquo;s about admitting that the database keeping your factory running should never be the same database answering &amp;ldquo;how many units did we scrap last Tuesday.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;This is the first of a couple of posts on the new MES reporting architecture. This one covers the ground work, where the data comes from and how it gets to ClickHouse. The next one goes further into &lt;a
href="https://www.stimulsoft.com/en/products/reports-js"
target="_blank"
&gt;Stimulsoft&lt;/a&gt; itself.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The old way: three databases and a very literal pipeline
&lt;div id="the-old-way-three-databases-and-a-very-literal-pipeline" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-old-way-three-databases-and-a-very-literal-pipeline" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The previous architecture is easy to describe because it&amp;rsquo;s exactly what it sounds like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;Online&lt;/strong&gt; database for transactions&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;ODS&lt;/strong&gt; (Operational Data Store) that mirrors it for querying&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;DWH&lt;/strong&gt; (Data Warehouse) for aggregates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Old Architecture"
src="https://image.j-roque.com/posts/20260730-reports/old-architecture.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;All three on SQL Server, ideally on separate instances so a bad report can&amp;rsquo;t slow down the shop floor.&lt;/p&gt;
&lt;p&gt;Moving data between them was a SQL Server Agent job with four steps: check &lt;code&gt;AlwaysOn&lt;/code&gt; status, generate the insert/update statements for anything new or changed, apply them, sleep, repeat. The DWH side did the same idea at a different scale — 65 steps to build dimensions, aggregations, and run the SSAS cube process. &lt;strong&gt;SQL Server Reporting Services&lt;/strong&gt; (&lt;a
href="https://learn.microsoft.com/en-us/sql/reporting-services/create-deploy-and-manage-mobile-and-paginated-reports?view=sql-server-ver17"
target="_blank"
&gt;SSRS&lt;/a&gt;) sat on top, rendering paginated, server-defined reports; &lt;strong&gt;SQL Server Analysis Services&lt;/strong&gt; (&lt;a
href="https://learn.microsoft.com/en-us/analysis-services/ssas-overview?view=sql-analysis-services-2025"
target="_blank"
&gt;SSAS&lt;/a&gt;) handled the cube processing that made those reports fast.&lt;/p&gt;
&lt;p&gt;It worked. It also meant every report was, transitively, a SQL Server problem — and every performance complaint eventually became a &amp;ldquo;which of these 65 steps is stuck&amp;rdquo; investigation.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The new way: an event-driven pipeline built on ClickHouse
&lt;div id="the-new-way-an-event-driven-pipeline-built-on-clickhouse" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-new-way-an-event-driven-pipeline-built-on-clickhouse" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The replacement is a full-on modern &lt;strong&gt;Data Platform&lt;/strong&gt;: event-driven, columnar, and designed so reports never touch a transactional table directly.&lt;/p&gt;
&lt;p&gt;The components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data Manager&lt;/strong&gt; — exposes ClickHouse data via &lt;strong&gt;OData&lt;/strong&gt;. This is what Stimulsoft will use for report building.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Housekeeper&lt;/strong&gt; — replicates Online/ODS data into ClickHouse and runs the CDM (Canonical Data Manager) builder that populates the analytics-ready tables.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kafka&lt;/strong&gt; — every stage of replication is a message on a topic. Nothing here is bespoke queuing; it&amp;rsquo;s stock Kafka.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoTEventProcessor&lt;/strong&gt; — fans-out messages from a shared staging topic to per-event destination topics.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ClickHouse&lt;/strong&gt; — column-oriented, denormalized, built for the queries, reports actually run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aggregation Engine&lt;/strong&gt; — &lt;a
href="https://docs.dagster.io/"
target="_blank"
&gt;Dagster&lt;/a&gt; schedules, &lt;a
href="https://docs.getdbt.com/docs/introduction?version=2.0"
target="_blank"
&gt;dbt&lt;/a&gt; executes the SQL models that turn CDM into DWH.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cube&lt;/strong&gt; — exposes DWH data via GraphQL and REST, feeding Grafana, Power BI, or whatever else. &lt;a
href="https://docs.cube.dev/docs/introduction"
target="_blank"
&gt;Cube&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two databases worth knowing the names of: &lt;strong&gt;CDM&lt;/strong&gt; (ClickHouse&amp;rsquo;s operational layer, roughly analogous to the old ODS) and &lt;strong&gt;DWH&lt;/strong&gt; (still the aggregate layer, now populated exclusively from CDM rather than SQL Server directly).&lt;/p&gt;
&lt;h2 class="relative group"&gt;The replication path, traced end to end
&lt;div id="the-replication-path-traced-end-to-end" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-replication-path-traced-end-to-end" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Every change in MES still starts life as a row in the &lt;code&gt;OutBoxQueue&lt;/code&gt; table, keyed by what the system calls a &lt;strong&gt;SHID&lt;/strong&gt; (ServiceHistoryId) — the identifier for a change on MES. This table exists on both Online and ODS, and it&amp;rsquo;s the seam between the transactional world and everything downstream.&lt;/p&gt;
&lt;p&gt;From there, two distinct paths diverge:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ODS replication.&lt;/strong&gt; Housekeeper reads new SHIDs from the outbox and publishes them to a Kafka topic. It then turns around and &lt;em&gt;consumes its own message&lt;/em&gt;, builds the full change payload, and republishes to a &lt;code&gt;replication&lt;/code&gt; topic — which a consumer picks up and writes into ClickHouse ODS. Two topics doing what looks like one job is intentional: the first says &amp;ldquo;something changed, here&amp;rsquo;s where&amp;rdquo;; the second says &amp;ldquo;here&amp;rsquo;s the fully assembled row, go write it.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CDM replication.&lt;/strong&gt; Same starting point — Housekeeper reads the SHID — but instead of a dedicated replication topic, the message lands on a shared staging topic named &lt;code&gt;{systemname}_dp_dataplatform_raw&lt;/code&gt;. This &amp;ldquo;bucket&amp;rdquo; topic isn&amp;rsquo;t a Kafka concept, just a naming convention: it&amp;rsquo;s where &lt;em&gt;every&lt;/em&gt; CDM-bound change lands, including messages published directly by external systems calling the Post Event API. IoTEventProcessor consumes the bucket, if there&amp;rsquo;s any defined it executes data platform low code workflows, and fans each message out to a destination topic shaped &lt;code&gt;{systemname}_{type}_{eventname}_raw&lt;/code&gt;. Housekeeper subscribes to all of them and writes the result into ClickHouse CDM.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;img
src="https://image.j-roque.com/posts/20260730-reports/housekeeper_services.png"
alt="Ingestion Diagram"
loading="lazy"
decoding="async"
style="background-color:#fff; padding:1rem; border-radius:0.5rem;"
/&gt;
&lt;/div&gt;
&lt;p&gt;The practical upshot: if replication breaks, ODS and CDM fail differently, and you troubleshoot them differently. ODS has one predictable replication topic to inspect. CDM has as many topics as you have event types.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If IoTEventProcessor isn&amp;rsquo;t healthy, CDM replication silently stops fanning out, and nothing in the ODS job logs will tell you that.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Sanity checks that cost nothing
&lt;div id="sanity-checks-that-cost-nothing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sanity-checks-that-cost-nothing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Two habits pay for themselves immediately.&lt;/p&gt;
&lt;p&gt;First: SQL Server and ClickHouse should have matching row counts for the same logical table. If they don&amp;rsquo;t, you have your answer before you&amp;rsquo;ve written a single diagnostic query.&lt;/p&gt;
&lt;p&gt;Second: every ClickHouse row carries replication metadata SQL Server never had — &lt;code&gt;_created_at&lt;/code&gt;, &lt;code&gt;_SysProperties_EventId&lt;/code&gt;, &lt;code&gt;_SysProperties_EnqueueTime&lt;/code&gt;, &lt;code&gt;_AppProperties_ApplicationName&lt;/code&gt;, &lt;code&gt;_AppProperties_EventTime&lt;/code&gt;. When Housekeeper logs an error against a specific event ID, that ID is your thread back through Kafka to the exact message that broke — no need to guess which change caused it.&lt;/p&gt;
&lt;h2 class="relative group"&gt;What actually changed in the tables
&lt;div id="what-actually-changed-in-the-tables" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-actually-changed-in-the-tables" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The move to ClickHouse isn&amp;rsquo;t just a faster engine under the same schema, the schema itself is different in a way that matters for anyone writing queries against it.&lt;/p&gt;
&lt;p&gt;SQL Server&amp;rsquo;s dynamic model gives every entity type seven tables: &lt;code&gt;T_[name]&lt;/code&gt;, &lt;code&gt;T_[name]History&lt;/code&gt;, &lt;code&gt;T_[name]Attribute&lt;/code&gt;, &lt;code&gt;T_[name]AttributeHistory&lt;/code&gt;, &lt;code&gt;T_[name]OperationAttribute&lt;/code&gt;, &lt;code&gt;T_[name]State&lt;/code&gt;, &lt;code&gt;T_[name]StateHistory&lt;/code&gt;. Normalized, foreign-keyed, correct for a system of record.&lt;/p&gt;
&lt;p&gt;ClickHouse collapses most of that into two tables: &lt;code&gt;[Schema]_T_[name]&lt;/code&gt; and &lt;code&gt;[Schema]_T_[name]History&lt;/code&gt;. Attributes live in a JSON column (&lt;code&gt;_Attributes&lt;/code&gt;) instead of a separate table. State model information lives directly in columns like &lt;code&gt;MainStateModelId&lt;/code&gt; and &lt;code&gt;MainStateModelState&lt;/code&gt;. Foreign keys to other entities — &lt;code&gt;Step&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Facility&lt;/code&gt; — get embedded as JSON snapshots instead of requiring a join.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s denormalization done deliberately, not sloppily — the design goal is that a report can get 80% of what it needs about a related entity without a join, and reach for the join only when it genuinely needs deeper detail than the snapshot carries.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Data Sets and Analytics Views: the new access layer
&lt;div id="data-sets-and-analytics-views-the-new-access-layer" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#data-sets-and-analytics-views-the-new-access-layer" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Two concepts sit between raw ClickHouse tables and an actual report, and the mental model shift here trips people coming from SSRS.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;Data Set&lt;/strong&gt; is a SQL &lt;code&gt;SELECT&lt;/code&gt; against the ClickHouse model, exposed as a tabular, exportable object over &lt;strong&gt;OData&lt;/strong&gt; — consumable from Stimulsoft, Excel, Power BI, or a Jupyter notebook, anything that speaks OData. A dataset for material data collections should include all materials; the caller filters down to the one they care about.&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;Analytics View&lt;/strong&gt; is the visualization layer on top — the JSON definition of a Stimulsoft report, build kpis or grafana dashboards, consuming one or more Data Sets, defining layout and presentation. Data Sets are the access layer; Analytics Views are the presentation layer. Keep that boundary clean and reports stay maintainable; blur it and you end up debugging report logic that&amp;rsquo;s actually a data problem, or vice versa.&lt;/p&gt;
&lt;p&gt;The performance guidance for Data Sets is the same instinct that governs the ClickHouse schema itself: broad enough to cover everything a given analysis needs, focused enough that you&amp;rsquo;re not dragging in unrelated data. Too narrow and you&amp;rsquo;re stitching datasets together at query time; too wide and every column becomes weight the browser has to carry.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Stimulsoft vs. SSRS: where the trade-off actually lands
&lt;div id="stimulsoft-vs-ssrs-where-the-trade-off-actually-lands" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#stimulsoft-vs-ssrs-where-the-trade-off-actually-lands" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;SSRS renders server-side — the server pulls data, builds the report, and ships a finished document. &lt;strong&gt;Stimulsoft Reports.JS&lt;/strong&gt;, is embedded in the MES UI, renders entirely client-side in the browser, fetching data itself over OData through Data Manager. No server-side rendering component to install or scale; but every byte of report data now travels to the browser, and every layout calculation happens on the client&amp;rsquo;s hardware.&lt;/p&gt;
&lt;p&gt;That trade produces a specific, sharp set of constraints:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What you can do:&lt;/strong&gt; open and edit report layouts directly in the browser, design and preview interactively, use Stimulsoft&amp;rsquo;s own expression syntax and built-in functions for dynamic values, apply conditional formatting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What you can&amp;rsquo;t do:&lt;/strong&gt; attach event scripts — no &lt;code&gt;BeforePrint&lt;/code&gt;, no &lt;code&gt;GetValue&lt;/code&gt;, nothing resembling the scripting hooks a desktop report designer would give you. No complex server-side processing; if a report needs a heavy aggregation, that aggregation belongs in the Data Set&amp;rsquo;s SQL, not in the report layer. No access to security or system-level settings from inside the designer, by design.&lt;/p&gt;
&lt;p&gt;The honest way to frame this: SSRS gives you rigid layout with real server-side headroom. Stimulsoft gives you pixel-level design freedom with a hard ceiling set by whatever machine happens to be running the browser. Large or heavily nested reports will degrade, and there&amp;rsquo;s no server tier waiting to absorb the load. Pagination and deliberately narrow reports aren&amp;rsquo;t a workaround here — they&amp;rsquo;re the only lever you have.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating a Report
&lt;div id="creating-a-report" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-report" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The demo query set makes the OData filtering model concrete. Here&amp;rsquo;s a Data Set joining data collection instances to their reading points in ClickHouse:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT
    JSONExtractString(i.Material, &amp;#39;Name&amp;#39;) AS Material,
    JSONExtractString(p.SourceEntity, &amp;#39;Name&amp;#39;) AS DataCollectionInstance,
    JSONExtractString(p.TargetEntity, &amp;#39;Name&amp;#39;) AS ParameterName,
    p.Value
FROM CoreDataModel_T_DataCollectionInstance AS i
JOIN CoreDataModel_T_DataCollectionPoint AS p
    ON i.DataCollectionInstanceId = JSONExtractInt(p.SourceEntity, &amp;#39;Id&amp;#39;)
WHERE i._OperationHistory_OperationName = &amp;#39;PerformImmediate&amp;#39;
ORDER BY i.CreatedOn ASC;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note &lt;code&gt;JSONExtractString&lt;/code&gt; pulling the material name straight out of the embedded JSON snapshot — no join to the material table required, exactly the payoff the denormalized schema is there for.&lt;/p&gt;
&lt;p&gt;A second dataset covers genealogy consumption events. Both are useful on their own, but a report showing &amp;ldquo;everything that happened to this material&amp;rdquo; needs them side by side — and naively joining two differently-shaped event tables produces a table with half its columns null on every row. The fix is a &lt;code&gt;UNION ALL&lt;/code&gt; with each dataset reshaped into &lt;code&gt;(Key, Value)&lt;/code&gt; pairs via &lt;code&gt;ARRAY JOIN&lt;/code&gt;, so heterogeneous events share a uniform shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT
    JSONExtractString(i.Material, &amp;#39;Name&amp;#39;)     AS Material,
    &amp;#39;DataCollection&amp;#39;                          AS EventType,
    i.CreatedOn                               AS CreatedOn,
    JSONExtractString(p.TargetEntity, &amp;#39;Name&amp;#39;) AS Key,
    toString(p.Value)                         AS Value
FROM CoreDataModel_T_DataCollectionInstance AS i
JOIN CoreDataModel_T_DataCollectionPoint AS p
    ON i.DataCollectionInstanceId = JSONExtractInt(p.SourceEntity, &amp;#39;Id&amp;#39;)
UNION ALL
SELECT
    JSONExtractString(g.DescMaterial, &amp;#39;Name&amp;#39;) AS Material,
    &amp;#39;Assemble&amp;#39;                                AS EventType,
    g.CreatedOn                               AS CreatedOn,
    kv.1                                      AS Key,
    kv.2                                       AS Value
FROM CoreDataModel_T_Genealogy AS g
ARRAY JOIN
    [
        (&amp;#39;ConsumableMaterial&amp;#39;, JSONExtractString(g.AscMaterial, &amp;#39;Name&amp;#39;)),
        (&amp;#39;AssembledQty&amp;#39;,       toString(g.AscAssembledPrimaryQty))
    ] AS kv
WHERE g.Operation ILIKE &amp;#39;%Assemble%&amp;#39;
ORDER BY CreatedOn ASC;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One Data Set, one Stimulsoft data source, one HTTP call to Data Manager. Inside the report, each table applies its own client-side filter on &lt;code&gt;EventType&lt;/code&gt; — &lt;code&gt;data collection&lt;/code&gt; in one band, &lt;code&gt;Assemble&lt;/code&gt; in another — instead of firing a second call for a second dataset.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Create Custom Datasets"
src="https://image.j-roque.com/posts/20260730-reports/create_customdatasets.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We created our queries and turned them into datasets that the MES knows. Now they are fully available to be used on building our Stimulsoft report.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260730-reports/create_report.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;In the report we have full access to all the default datasets and also to our new custom datasets. We can see that what is happening behind the scenes is that we are doing OData requests to the data-manager.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have a new tool not just for designing reports. We have a totally revamped architecture to change what used to take minutes into seconds a totally reshape of the way to interact with the system.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260730-reports/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260730-reports/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>The Integration Engine, Three Layers Deep</title>
<link>https://j-roque.com/posts/20260729-integration-engine/</link>
<pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260729-integration-engine/</guid>
<description>A practitioner&amp;rsquo;s tour of the Integration Entry, Package, and Engine model behind CM MES integrations — plus the batch/parent gotchas and the atomicity tax nobody mentions until it bites.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260729-integration-engine/featured.png" alt="The Integration Engine, Three Layers Deep" /&gt;&lt;/p&gt;&lt;p&gt;If your integration layer can&amp;rsquo;t survive the ERP going down for maintenance, you don&amp;rsquo;t have an integration layer you have a dependency.&lt;/p&gt;
&lt;p&gt;A recap of blog &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250429_integration_entries/"
target="_blank"
&gt;A Closer Look at Integration Entries&lt;/a&gt; and &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250619_low_code_integration/"
target="_blank"
&gt;Integration Entries, Part II: Low Code&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The synchronous trap
&lt;div id="the-synchronous-trap" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-synchronous-trap" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Most integration problems start the same way: someone wires a &lt;strong&gt;synchronous&lt;/strong&gt; call between two systems, it works fine in the demo, and six months later a single SAP outage takes down production reporting. The request-response model is simple to reason about, which is exactly why people reach for it first. It&amp;rsquo;s also the &lt;strong&gt;wrong default&lt;/strong&gt; for anything that isn&amp;rsquo;t trivially fast and trivially reliable.&lt;/p&gt;
&lt;p&gt;The alternative is &lt;strong&gt;asynchronous&lt;/strong&gt; processing: the caller writes a message somewhere, gets an immediate acknowledgment, and a background worker does the actual work on its own schedule. This buys you three things that matter in a shop-floor context — &lt;strong&gt;long-running tasks don&amp;rsquo;t block the caller&lt;/strong&gt;, the &lt;strong&gt;receiving side scales independently&lt;/strong&gt;, and the &lt;strong&gt;two systems are no longer tightly coupled&lt;/strong&gt; to each other&amp;rsquo;s uptime.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Sync Async"
src="https://image.j-roque.com/posts/20260729-integration-engine/sync-async.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;That last one is the one people underrate. If MES can hand a message to a queue and walk away, an ERP outage stops being a P1.&lt;/p&gt;
&lt;p&gt;CM MES bakes this pattern in as the &lt;strong&gt;Integration Engine&lt;/strong&gt;, and it&amp;rsquo;s the mechanism behind nearly every ERP, PLM, or asset-management integration you&amp;rsquo;ll build on the platform.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Overview Integration Engine"
src="https://image.j-roque.com/posts/20260729-integration-engine/overview-integrationengine.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Three components, one job
&lt;div id="three-components-one-job" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#three-components-one-job" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The framework splits cleanly into three concepts, and once they click, the rest of the system reads like plumbing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Integration Entry&lt;/strong&gt; — the message itself. Conceptually the same thing as a message sitting in a RabbitMQ queue: metadata plus a payload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration Package&lt;/strong&gt; — the component that knows what to &lt;em&gt;do&lt;/em&gt; with a message once it&amp;rsquo;s picked up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration Engine&lt;/strong&gt; — the orchestrator that pulls entries, figures out which package should handle each one, and dispatches them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Inbound entries (ERP → MES) get created through the standard &lt;code&gt;CreateObject&lt;/code&gt; API or a custom API that does validation up front. Outbound entries (MES → ERP) are typically created by DEE Actions reacting to something that just happened on the shop floor. Either direction, the entry lands in the same store and gets picked up the same way — which is the whole point of decoupling.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Integration Framework"
src="https://image.j-roque.com/posts/20260729-integration-engine/integration-framework.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/administration/system-integrations/"
title="Integration Engine"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/administration/system-integrations/"
title="Integration Engine"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;p&gt;The &lt;strong&gt;Integration Entry&lt;/strong&gt; represents a message to be processed and is the core entity type of the integration entity framework. It carries a small, deliberate set of properties:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Unique identifier — GUID or a naming convention, your call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MessageType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;What kind of message this is; drives package resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SourceSystem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lookup (IntegrationSystem)&lt;/td&gt;
&lt;td&gt;Where the message came from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TargetSystem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lookup (IntegrationSystem)&lt;/td&gt;
&lt;td&gt;Where it&amp;rsquo;s going&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SystemState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Enum&lt;/td&gt;
&lt;td&gt;Current processing status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IsRetriable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Boolean&lt;/td&gt;
&lt;td&gt;Whether a failed entry is eligible for retry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The payload itself lives in a separate but tightly-coupled entity, &lt;code&gt;IntegrationMessage&lt;/code&gt;. It&amp;rsquo;s rarely handled on its own but it matters in one very specific operational way, which we&amp;rsquo;ll get to.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;An Integration Entry is just a message on a queue that happens to be an entity type. Don&amp;rsquo;t over-model it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;The state machine you&amp;rsquo;re actually debugging
&lt;div id="the-state-machine-youre-actually-debugging" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-state-machine-youre-actually-debugging" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Every entry moves through a state machine: &lt;code&gt;Received → Processing → Processed&lt;/code&gt;, with &lt;code&gt;Failed&lt;/code&gt; and &lt;code&gt;Rejected&lt;/code&gt; as the off-ramps.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Received&lt;/code&gt; is the signal that tells the orchestrator &amp;ldquo;this one&amp;rsquo;s up for grabs.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Processing&lt;/code&gt; exists mostly for Automation Job scenarios (more on that below) — for a standard DEE-Action-backed integration, you&amp;rsquo;ll rarely see it linger there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Failed entries with &lt;code&gt;IsRetriable&lt;/code&gt; set get swept up by a timer, &lt;code&gt;RetryIntegrationEntry&lt;/code&gt;, which runs every five minutes by default and flips them back to &lt;code&gt;Received&lt;/code&gt;. This is the detail that trips people up in support tickets: setting &lt;code&gt;IsRetriable&lt;/code&gt; to true does &lt;strong&gt;not&lt;/strong&gt; immediately requeue the entry. You&amp;rsquo;re waiting on the next timer tick, not triggering an event.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="State Model"
src="https://image.j-roque.com/posts/20260729-integration-engine/statemodel-integrationentry.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Packages: the part that does the work
&lt;div id="packages-the-part-that-does-the-work" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#packages-the-part-that-does-the-work" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Out of the box you get two packages.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GenericIntegrationHandler&lt;/code&gt; bridges the framework to MES orchestration — you point it at a DEE Action per message type, and that action does the actual work. &lt;code&gt;SapIntegrationHandler&lt;/code&gt; connects directly to an SAP instance.&lt;/p&gt;
&lt;p&gt;In practice, the generic handler covers the overwhelming majority of real deployments, SAP included, because most SAP integrations go through middleware rather than a direct connection.&lt;/p&gt;
&lt;p&gt;Resolution — which package handles which entry — is configured in the &lt;code&gt;IntegrationHandlerResolution&lt;/code&gt; smart table, keyed by &lt;code&gt;SourceSystem&lt;/code&gt;, &lt;code&gt;TargetSystem&lt;/code&gt;, and &lt;code&gt;MessageType&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Integration Handler Resolution"
src="https://image.j-roque.com/posts/20260729-integration-engine/integration-handlerresolution.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For the generic handler you supply a DEE Action name, and optionally an error-handling action that fires immediately on failure. That second field is more useful than it sounds: if you know a given error class is transient (ERP temporarily unreachable, say), the error action can flip the entry to retry automatically instead of waiting for someone to notice it sitting in &lt;code&gt;Failed&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If the generic handler genuinely doesn&amp;rsquo;t fit — you need a persistent connection to an external message broker, for instance — you can build a custom package as an assembly with five kinds of classes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Package&lt;/code&gt; (registration and active/inactive state)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Monitor&lt;/code&gt; (watches connectivity to the third-party system)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Handler&lt;/code&gt; (the actual business logic), and &lt;code&gt;Sender&lt;/code&gt;/&lt;code&gt;Receiver&lt;/code&gt; (workers that talk to the external environment). Worth knowing: in the generic package&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Sender&lt;/code&gt; and &lt;code&gt;Receiver&lt;/code&gt; are essentially empty stubs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All the real work sits in the &lt;code&gt;Handler&lt;/code&gt;, which just looks up and invokes a DEE Action. That&amp;rsquo;s not a limitation — it&amp;rsquo;s the reason the generic handler is flexible enough to not need replacing.&lt;/p&gt;
&lt;h2 class="relative group"&gt;What the orchestrator actually does on every tick
&lt;div id="what-the-orchestrator-actually-does-on-every-tick" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-the-orchestrator-actually-does-on-every-tick" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Integration SEQ Diagram"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250429_integration_entries/integration_seq_diagram.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;On host startup, the Integration Scheduler launches, loads active packages into an in-memory registry (so resolution doesn&amp;rsquo;t hit the database on every message), and starts the orchestrator loop. Each cycle:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pull the next &lt;code&gt;Received&lt;/code&gt; entry (or batch) via &lt;code&gt;T_PullNextIntegrationEntry&lt;/code&gt; stored procedure.&lt;/li&gt;
&lt;li&gt;Calculate the route — which package handles this entry.&lt;/li&gt;
&lt;li&gt;Dispatch to that package&amp;rsquo;s &lt;code&gt;Handler&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write the result and flip the state to &lt;code&gt;Processed&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The pull step is the interesting part, because it has to work correctly across multiple hosts pulling from the same table at once:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;-- conceptual shape of what T_PullNextIntegrationEntry does
SELECT TOP (@BatchSize) *
FROM IntegrationEntry
WITH (READPAST, UPDLOCK)
WHERE SystemState = &amp;#39;Received&amp;#39;
ORDER BY CreationDate ASC&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;READPAST&lt;/code&gt; skips rows another transaction already has locked instead of blocking on them; &lt;code&gt;UPDLOCK&lt;/code&gt; claims the rows this transaction just read so a second host can&amp;rsquo;t grab the same entries a moment later. That&amp;rsquo;s the entirety of the cross-host coordination story — no distributed lock manager, just SQL doing what SQL is good at.&lt;/p&gt;
&lt;h2 class="relative group"&gt;BatchId and ParentIntegrationEntryId: read the fine print
&lt;div id="batchid-and-parentintegrationentryid-read-the-fine-print" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#batchid-and-parentintegrationentryid-read-the-fine-print" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Two properties change how entries get pulled, and both have a sharp edge that isn&amp;rsquo;t obvious from the property description alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BatchId&lt;/strong&gt; groups entries so they&amp;rsquo;re pulled together and processed sequentially by the same host — occupying a single processing slot, not one slot each. But the grouping guarantee only applies to entries that already exist when the pull happens. If Host A has already locked two entries with &lt;code&gt;BatchId = B&lt;/code&gt;, and a third entry with the same &lt;code&gt;BatchId&lt;/code&gt; gets created before Host A finishes, does Host B risk picking it up? The answer yes, that&amp;rsquo;s possible. The batching guarantee is about entries present at pull time, not a standing claim on the batch identifier.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Batch Processing"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250429_integration_entries/integration_batch_id.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ParentIntegrationEntryId&lt;/strong&gt; creates an ordering dependency — entry B won&amp;rsquo;t become eligible until entry A (its parent) is processed. What it does &lt;em&gt;not&lt;/em&gt; do is act as a circuit breaker. If the parent fails permanently after exhausting its retries, the framework does not automatically prevent the child from being picked up indefinitely — it just never becomes eligible while the parent is actively &lt;code&gt;Failed&lt;/code&gt;-and-retriable-or-in-flight. Getting this wrong means silently stuck message chains that look healthy in every dashboard until someone goes looking.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Parent Child Processing"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250429_integration_entries/integration_parent_id.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;ParentIntegrationEntryId&lt;/code&gt; orders execution. It does not guarantee the chain completes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Configuration knobs, and the one you shouldn&amp;rsquo;t touch first
&lt;div id="configuration-knobs-and-the-one-you-shouldnt-touch-first" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#configuration-knobs-and-the-one-you-shouldnt-touch-first" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler enabled&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Cmf/System/Configuration/Integration/IntegrationSchedulerIsActive/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic package enabled&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Cmf/System/Configuration/Integration/GenericIntegration/IsActive/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling interval&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Cmf/System/Configuration/Integration/PollingInterval/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max retries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Cmf/System/Configuration/Integration/MaxNumberOfRetries/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel requests&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Cmf/System/Configuration/Integration/NumberOfParallelRequests/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 (per host)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;NumberOfParallelRequests&lt;/code&gt; is the one people reach for first when throughput feels slow, and it&amp;rsquo;s the one that deserves the most respect. It&amp;rsquo;s per host, not per system — two hosts at the default gives you ten concurrent processing slots cluster-wide. Batched entries still only consume one slot regardless of how many messages are in the batch. Bump this number without load-testing first and you&amp;rsquo;ll find your bottleneck somewhere less convenient, like the ERP-side API you&amp;rsquo;re now hammering five times harder.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Low-code: drag, drop, and a Kafka topic you didn&amp;rsquo;t ask for
&lt;div id="low-code-drag-drop-and-a-kafka-topic-you-didnt-ask-for" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#low-code-drag-drop-and-a-kafka-topic-you-didnt-ask-for" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The newer addition to this story is building integrations without writing a DEE Action at all — using an &lt;code&gt;AutomationController&lt;/code&gt; workflow instead. The canonical example, and a good one: importing a production order from an ERP.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Lifecycle"
src="https://image.j-roque.com/posts/20260729-integration-engine/po-integration-lifecycle.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;plantCode&amp;#34;: &amp;#34;PLT01&amp;#34;,
  &amp;#34;partNumber&amp;#34;: &amp;#34;PN-4471-B&amp;#34;,
  &amp;#34;dueDate&amp;#34;: &amp;#34;2026-08-15T00:00:00Z&amp;#34;,
  &amp;#34;quantity&amp;#34;: 500,
  &amp;#34;unitOfMeasure&amp;#34;: &amp;#34;EA&amp;#34;,
  &amp;#34;priority&amp;#34;: 2
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You register this shape as an &lt;code&gt;IoTEventDefinition&lt;/code&gt; (scoped to &lt;strong&gt;Enterprise Integration&lt;/strong&gt; — you can import the JSON directly and let the system infer property types instead of typing each one by hand), build an &lt;code&gt;AutomationController&lt;/code&gt; workflow that consumes it, and instead of pointing &lt;code&gt;IntegrationHandlerResolution&lt;/code&gt; at a DEE Action, you point it at the event definition. The workflow itself — load the facility, load the product, build the material object, call &lt;code&gt;CreateObject&lt;/code&gt; — reads almost exactly like the equivalent DEE Action would, just assembled visually.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="PO IoT Event"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250429_integration_entries/po-event.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="PO Handler Resolution"
src="https://image.j-roque.com/posts/20260729-integration-engine/po-handlerresolution.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can build a workflow that is going to consume the message.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Low Code Workflow"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250619_low_code_integration/automation_controller_workflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Under the hood it&amp;rsquo;s genuinely more moving parts than the DEE Action path: the message goes to a dedicated Kafka topic per event definition, the Connect IoT manager consumes it and creates an Automation Job, a second message round-trips through RabbitMQ, and &lt;em&gt;then&lt;/em&gt; the workflow executes.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Tech Diagram"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250619_low_code_integration/low_code_tech_diagram.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Job Running"
src="https://devblog.criticalmanufacturing.com/blogPosts/posts/20250619_low_code_integration/integration_entry_processed.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;All of that complexity buys you real things — faster prototyping, workflows non-developers can read and modify, components reused across multiple integrations. It is not, however, free.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The atomicity tax nobody mentions in the demo
&lt;div id="the-atomicity-tax-nobody-mentions-in-the-demo" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-atomicity-tax-nobody-mentions-in-the-demo" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the part that belongs in bold above the fold, and doesn&amp;rsquo;t get enough airtime: &lt;strong&gt;an Automation Job workflow is not a database transaction.&lt;/strong&gt; Each task executes independently. If your workflow creates a material in one task and a production order in the next, and the second task fails, you now have a material with no production order and no automatic rollback of the first step.&lt;/p&gt;
&lt;p&gt;A DEE Action wrapped in a single MES transaction doesn&amp;rsquo;t have this problem, it either fully commits or fully rolls back. A multi-step low-code workflow can leave you in a state that&amp;rsquo;s inconsistent by construction, and the framework will not warn you about it.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t a reason to avoid low-code integrations.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a reason to design them assuming partial failure is normal, not exceptional — idempotent steps, compensating actions, or simply keeping the workflow to a single state-changing call.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Low-code doesn&amp;rsquo;t remove the need for transactional thinking. It just moves where you have to do it yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With the creation of Business Workflows you are now also able to create DEEs with Low Code. This maintains the transactionality and offers a low code solution.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The generic handler plus a DEE Action will get you through the overwhelming majority of integration requirements, and there&amp;rsquo;s no shame in that being the boring, correct answer. Low-code earns its place for prototypes, cross-functional handoffs, and genuinely simple single-step imports, but it is still not a wholesale replacement for the traditional path, and not in domains where a partially-completed workflow can be a compliance problem rather than an inconvenience.&lt;/p&gt;
&lt;p&gt;Read the two source posts if you haven&amp;rsquo;t — &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250429_integration_entries/"
target="_blank"
&gt;Integration Entries&lt;/a&gt; and &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250619_low_code_integration/"
target="_blank"
&gt;Low-Code Integrations&lt;/a&gt; go deeper on the wiring than I have room for here. But the batch-locking edge case and the atomicity gap are the two things worth carrying into your next design review, because they&amp;rsquo;re exactly the kind of detail that only shows up once you&amp;rsquo;re already in production.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This blog post was based on a talk by Ricardo Cunha in 2025-10-16 @CM-Portugal&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260729-integration-engine/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260729-integration-engine/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Blueprint vs. Building: How MES's Data Model Actually Works</title>
<link>https://j-roque.com/posts/20260728-mes-dynamic-model/</link>
<pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260728-mes-dynamic-model/</guid>
<description>MES doesn&amp;rsquo;t let you extend the data model by writing code — it lets you describe what you want and generates the rest at runtime. Here&amp;rsquo;s how that actually works, table by table.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260728-mes-dynamic-model/featured.png" alt="Blueprint vs. Building: How MES's Data Model Actually Works" /&gt;&lt;/p&gt;&lt;p&gt;Most enterprise software gives you two options when the out-of-the-box data model doesn&amp;rsquo;t fit: fork the code, or live without the feature. CM MES was built to reject that choice entirely.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The MES &lt;strong&gt;dynamic model&lt;/strong&gt; is a the mechanism that lets you add a brand-new business entity to the system by filling in a wizard, not by shipping a deployment. It&amp;rsquo;s one of those pieces of architecture that&amp;rsquo;s easy to take for granted until you&amp;rsquo;re asked to explain &lt;em&gt;why&lt;/em&gt; it works.&lt;/p&gt;
&lt;p&gt;This post covers the static/dynamic split, what happens under the hood when you create and generate an entity, the different ways MES lets you attach data to that entity, and when to reach for a table instead.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why the split exists
&lt;div id="why-the-split-exists" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-the-split-exists" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Before MES existed in its current form, the requirement on the table was blunt: the system needed to be extensible &lt;strong&gt;by configuring metadata, not by writing code&lt;/strong&gt;. Every other application on the market made you choose between using what the vendor shipped, or hand-rolling a new set of database tables, access layers, and UI screens from scratch every time the business needed a new concept.&lt;/p&gt;
&lt;p&gt;That flexibility is dangerous on its own. Opening the door to &lt;code&gt;add whatever entity you want&lt;/code&gt; may end up with every site running a system that only superficially resembles every other site, nothing comparable, nothing maintainable centrally. So the second requirement showed up right behind the first: whatever gets built has to stay &lt;strong&gt;consistent&lt;/strong&gt;. A custom entity created by customization needs to be structurally comparable to a base entity shipped by the product.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Flexibility without consistency isn&amp;rsquo;t extensibility, it&amp;rsquo;s just fragmentation with extra steps.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Put those two requirements together and you get controlled &lt;strong&gt;scalability&lt;/strong&gt;: customers can extend the system in a thousand different directions, but every one of those directions is built on the same underlying structure, with the same meta-structure.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The blueprint and the building
&lt;div id="the-blueprint-and-the-building" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-blueprint-and-the-building" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The solution was to split the data model into two halves that depend on each other but are governed by completely different rules.&lt;/p&gt;
&lt;p&gt;The &lt;a
href="https://developer.criticalmanufacturing.com/explore/reference/datadictionary/models/static-model/"
target="_blank"
&gt;static model&lt;/a&gt; is the metadata layer — &lt;strong&gt;the blueprint&lt;/strong&gt;. It defines entity types, their properties, and their relationships, and it lives in tables like &lt;code&gt;T_EntityType&lt;/code&gt; and &lt;code&gt;T_EntityTypeProperty&lt;/code&gt;. It also happens to be where MES stores everything the framework needs to operate that isn&amp;rsquo;t business data: security, transaction history, state transitions, query execution structures, DEE actions. Critically, the static model &lt;strong&gt;cannot be extended&lt;/strong&gt;. If you want to add something new to it, you implement it explicitly, new tables, new stored procedures to read and write it, the works. Nothing about it is automatic.&lt;/p&gt;
&lt;p&gt;The &lt;a
href="https://developer.criticalmanufacturing.com/explore/reference/datadictionary/models/dynamic-model/"
target="_blank"
&gt;dynamic model&lt;/a&gt; is the data layer — &lt;strong&gt;the building&lt;/strong&gt;. It&amp;rsquo;s where actual business information lives: materials, production orders, steps, flows, and anything a custom entity type produces.&lt;/p&gt;
&lt;p&gt;On the database, it splits into two schemas:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CoreDataModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;System-level, out-of-the-box entities, generic tables, and smart tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserDataModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anything created through customization — custom entities, custom generic tables, custom smart tables&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you&amp;rsquo;re staring at a database and trying to figure out what&amp;rsquo;s &lt;strong&gt;core product&lt;/strong&gt; versus what a &lt;strong&gt;customization project&lt;/strong&gt; created, the schema is explicit.&lt;/p&gt;
&lt;p&gt;If you crack open the online database and browse alphabetically, the dynamic model — &lt;code&gt;CoreDataModel&lt;/code&gt; and &lt;code&gt;UserDataModel&lt;/code&gt; combined — is roughly &lt;strong&gt;90% of the entire MES data model&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The static model is a comparatively small, fixed core that exists purely to describe how to build everything else.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The runtime generation engine
&lt;div id="the-runtime-generation-engine" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-runtime-generation-engine" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the part that actually does the work. Since the static model is fixed and explicit, and the dynamic model is neither, something has to translate one into the other. That something is the runtime generation engine, and it&amp;rsquo;s arguably the single most important mechanism in the whole platform.&lt;/p&gt;
&lt;p&gt;Create a new entity type through &lt;strong&gt;System &amp;gt; Administration &amp;gt; Entity Types&lt;/strong&gt;, and the wizard calls &lt;code&gt;CreateEntityType&lt;/code&gt;, which does exactly one thing: it writes a row into &lt;code&gt;T_EntityType&lt;/code&gt; and rows into &lt;code&gt;T_EntityTypeProperty&lt;/code&gt;. That&amp;rsquo;s it. No tables, no DLLs, nothing else exists yet.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT *
FROM [dbo].[T_EntityType]
WHERE [Name] = &amp;#39;ACustomEntity&amp;#39;
SELECT etp.*
FROM [dbo].[T_EntityTypeProperty] etp
INNER JOIN [dbo].[T_EntityType] et ON etp.EntityTypeId = et.EntityTypeId
WHERE et.[Name] = &amp;#39;ACustomEntity&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="New Entity"
src="https://image.j-roque.com/posts/20260728-mes-dynamic-model/new-entity.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;That gap between defining the entity and generating it isn&amp;rsquo;t an oversight — it&amp;rsquo;s deliberate. Once the schema is generated, properties are locked. You can&amp;rsquo;t rename or remove them. So the system gives you a window to iterate on the definition before it becomes permanent.&lt;/p&gt;
&lt;p&gt;When you&amp;rsquo;re happy with the definition, you hit &lt;strong&gt;Generate Schema&lt;/strong&gt;, and two things fire: &lt;code&gt;GenerateEntitySchema&lt;/code&gt;, a SQL procedure that builds the main table, the history table, and — depending on the entity&amp;rsquo;s configuration — relation tables, effective version tables, and attribute tables; and a second step that generates the C# assembly.&lt;/p&gt;
&lt;p&gt;That second part matters because a database table by itself is useless to the application layer. The generator produces a &lt;code&gt;CMF.&amp;lt;Tenant&amp;gt;.BusinessObjects.&amp;lt;EntityName&amp;gt;.dll&lt;/code&gt;, dropped straight into the application host, with the same method surface as every out-of-the-box entity&amp;rsquo;s DLL.&lt;/p&gt;
&lt;p&gt;This will generate two new tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;T_[EntityName] - which stores all the instances of that entity in the system. The structure of the table is created based on the metadata defined for the table in the T_EntityTypeProperty table.&lt;/li&gt;
&lt;li&gt;T_[EntityName]History - stores the complete changes history of the T_[EntityName] table. It will have a similar structure but additionally it will have the DatabaseOperation, the ServiceHistoryId and the OperationHistorySeq&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Entity Tables Generated"
src="https://image.j-roque.com/posts/20260728-mes-dynamic-model/new-entity-tables.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;A custom entity and a CM MES entity &lt;code&gt;Area&lt;/code&gt; share the exact same shape at the code level they were built by the same machine.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Tenant name casing has to match exactly between where the assembly is built and where it runs. A mismatched case on an otherwise-identical tenant name is a real, reported failure mode.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Anatomy of an entity
&lt;div id="anatomy-of-an-entity" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#anatomy-of-an-entity" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Once an entity exists, MES gives you &lt;strong&gt;five&lt;/strong&gt; different places to hang data off it, and picking the wrong one is the most common way to paint yourself into a corner.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Stored where&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Property&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Column on the main table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Entity.Load&lt;/code&gt; / &lt;code&gt;Entity.Save&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native, fastest, can reference other entities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom Property&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Column on the main table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Entity.LoadAttributes&lt;/code&gt; / &lt;code&gt;SaveAttributes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requires a schema regeneration every time one is added&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attribute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate attribute table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Entity.LoadAttributes&lt;/code&gt; / &lt;code&gt;SaveAttributes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scalar types only, can be an array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operation Attribute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate attribute table, written by operations&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OperationAttributeCollection&lt;/code&gt; on the orchestration call&lt;/td&gt;
&lt;td&gt;Not visible on the entity page — only in history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Main table (primary) or &lt;code&gt;T_[Entity]State&lt;/code&gt; (secondary)&lt;/td&gt;
&lt;td&gt;Standard state APIs&lt;/td&gt;
&lt;td&gt;Only the &lt;em&gt;first&lt;/em&gt; state model lives on the main table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Properties&lt;/strong&gt; are the default and the fast path — direct columns, and &lt;code&gt;Entity.Load&lt;/code&gt; with zero levels fetches only them. Bump the load level and you start pulling in every referenced entity behind them, which is the single easiest way to accidentally drag your whole database into memory. Load in collection, and keep lazy-loading to a minimum — every level you add is a database round trip you didn&amp;rsquo;t need. More information on &lt;a
href="https://developer.criticalmanufacturing.com/explore/best-practices/performance/"
target="_blank"
&gt;developer portal&lt;/a&gt; and in a &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250630_performance_analysis/"
target="_blank"
&gt;blog post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Attributes&lt;/strong&gt; live in a dedicated table and can&amp;rsquo;t reference other entities — scalar types only. They can also be arrays, which is a feature almost nobody in the field seems to use. Each array element is its own database row:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;/* Get attributes for Site and Materials */
SELECT *
FROM CoreDataModel.T_SiteAttribute
SELECT *
FROM CoreDataModel.T_MaterialAttribute&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;rsquo;s worth sitting with for a second: if an attribute array holds 100,000 values, loading it means fetching 100,000 rows. It&amp;rsquo;s a legitimate tool, but not one to reach for without thinking about the ceiling on cardinality first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Custom Properties&lt;/strong&gt; are the odd one out — stored as columns on the main table like a native property, but accessed through the attribute API like an attribute. The entire justification for their existence is performance: pull a heavily-used attribute onto the main table so it&amp;rsquo;s a column scan instead of a join. The catch is that adding one means regenerating the schema, and the payoff isn&amp;rsquo;t guaranteed — if the main table is already wide, or the entity already sees heavy traffic, bringing more columns onto it can make things worse, not better. This needs to be measured case by case, not assumed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Operation Attributes&lt;/strong&gt; are the one most people haven&amp;rsquo;t touched, and probably should more. They&amp;rsquo;re not tied to the entity — they&amp;rsquo;re tied to a specific operation performed on it, and they only show up in history, never on the entity&amp;rsquo;s own page.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;/* Only for all Operation attributes - Each operation writes on top of the last */
SELECT TOP (1000) *
FROM [CoreDataModel].[T_MaterialOperationAttribute]
/* Generic for all attributes (Operation attributes are attributes too) */
SELECT TOP (1000) *
FROM [CoreDataModel].T_MaterialAttributeHistory&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The canonical example is a track-out with a recorded loss: a &lt;code&gt;LossReason&lt;/code&gt; array and a &lt;code&gt;PrimaryQuantityLoss&lt;/code&gt; array, matched by index — position 0 of one array corresponds to position 0 of the other. That&amp;rsquo;s how a single operation reports &amp;ldquo;this much scrap, for this reason, and this much scrap, for that other reason&amp;rdquo; without inventing a new entity for it.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Operation Attribute"
src="https://image.j-roque.com/posts/20260728-mes-dynamic-model/operation_attribute.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Finally, &lt;strong&gt;State Models&lt;/strong&gt;. Every entity&amp;rsquo;s primary state lives directly on the main table — &lt;code&gt;MainStateModelId&lt;/code&gt; and &lt;code&gt;MainStateModelStateId&lt;/code&gt; are just columns:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT MainStateModelId, MainStateModelStateId, *
FROM [CoreDataModel].[T_Resource]
WHERE ResourceId = 2509290318110000004
SELECT Name, *
FROM dbo.T_StateModel
WHERE StateModelId = 1805111618120000001&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;T_[Entity]State&lt;/code&gt; table only fills up once you attach a &lt;em&gt;second&lt;/em&gt; state model to the same entity. Query &lt;code&gt;T_ResourceState&lt;/code&gt; on a resource that only has its one universal state model, and you&amp;rsquo;ll find it empty — not a bug, just a table that only exists for the overflow case.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Access, localization, and validation
&lt;div id="access-localization-and-validation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#access-localization-and-validation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Every property carries a bitmask access level — &lt;strong&gt;hidden&lt;/strong&gt;, &lt;strong&gt;read-only&lt;/strong&gt;, or &lt;strong&gt;editable&lt;/strong&gt;, independently configurable at &lt;strong&gt;create&lt;/strong&gt;, &lt;strong&gt;view&lt;/strong&gt;, and &lt;strong&gt;update&lt;/strong&gt; time, and independently at the entity level versus the template level. That last part is a genuinely useful trick: lock a property down at the entity level while leaving it editable at the template level, and you&amp;rsquo;ve built yourself an administrator override without writing a line of custom code.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The access level is stored as a raw integer (e.g 530) on the database, which is exactly as unreadable as it sounds if you&amp;rsquo;re building custom entities by code rather than through the UI — there&amp;rsquo;s an &lt;a
href="https://j-roque.com/posts/20260728-mes-dynamic-model/AccessLevelHelper.xlsx"&gt;AccessLevelHelper&lt;/a&gt; spreadsheet for translating the bit flags into something you can reason about.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two more small but underused levers: a property can carry a &lt;strong&gt;localized message&lt;/strong&gt; to decouple its internal name from what the operator actually sees, and a &lt;strong&gt;validation rule&lt;/strong&gt; (regex or range) can be attached directly at the property level, which cuts down on the number of custom Dynamic Execution Engine actions you&amp;rsquo;d otherwise write just to reject bad input.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Relations are entities too
&lt;div id="relations-are-entities-too" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#relations-are-entities-too" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Relations aren&amp;rsquo;t a separate concept bolted onto entities, they &lt;em&gt;are&lt;/em&gt; entity types, just ones with a &lt;strong&gt;source&lt;/strong&gt; and a &lt;strong&gt;target&lt;/strong&gt; instead of a flat property list. There are two main scenarios that push you toward a relation instead of a plain property:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A genuine &lt;strong&gt;N:N&lt;/strong&gt; relationship — &lt;code&gt;EmployeeCertification&lt;/code&gt;, where one employee holds many certifications and one certification belongs to many employees.&lt;/li&gt;
&lt;li&gt;A relationship that needs to carry &lt;strong&gt;more information than a single property can hold&lt;/strong&gt; — &lt;code&gt;MaterialContainer&lt;/code&gt;, where it&amp;rsquo;s not enough to know &lt;em&gt;which&lt;/em&gt; container a material sits in; you also need to know &lt;em&gt;where in it&lt;/em&gt; (slot 9, say).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The one question that actually matters when you design a relation is: &lt;strong&gt;what happens to the relation when one side of it terminates?&lt;/strong&gt; The default rule is straightforward — terminate the source, and the relation terminates with it; terminate the target, and the termination operation should fail. For &lt;code&gt;MaterialContainer&lt;/code&gt;: terminate the material, the relation is meaningless and dies with it; terminate the container while materials still reference it, and that termination should be rejected, because those materials would be left pointing at nothing.&lt;/p&gt;
&lt;p&gt;Like most default rules, there are exceptions carved out in business logic, for example &lt;code&gt;MaterialProductionOrder&lt;/code&gt; allows both sides to terminate independently, because a production order closing shouldn&amp;rsquo;t force the physical materials still sitting on the line out of existence.&lt;/p&gt;
&lt;p&gt;If we have a use case where &lt;em&gt;at production order close, report to the ERP the sum of scrap quantity per step&lt;/em&gt;. We would create a custom relation with information that includes the Production Order, the Step and the Scrap information.&lt;/p&gt;
&lt;p&gt;The instinctive answer is to make &lt;strong&gt;Step&lt;/strong&gt; the source, since you&amp;rsquo;re aggregating by step&amp;hellip;&lt;/p&gt;
&lt;p&gt;But if you see it from a terminate perspective: terminate the step, and every production order that used it would have to stop — clearly not acceptable. Terminate the production order, and you just lose the scrap counter, which is fine, because the production order no longer existing means the counter is irrelevant anyway. So &lt;strong&gt;Production Order is the source, Step is the target&lt;/strong&gt;, the relation dies cleanly when the thing it&amp;rsquo;s describing (a specific order&amp;rsquo;s run) goes away, and survives the thing it&amp;rsquo;s merely referencing (a step definition that other orders still use).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you&amp;rsquo;re not sure which side of a relation is the source, ask what should happen when each side terminates. The answer is a good rule of thumb.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Relations also carry a &lt;code&gt;LockType&lt;/code&gt; — &lt;code&gt;None&lt;/code&gt;, &lt;code&gt;LockSource&lt;/code&gt;, &lt;code&gt;LockTarget&lt;/code&gt;, or &lt;code&gt;LockBoth&lt;/code&gt; — which forces a row lock on the relevant entity&amp;rsquo;s main table every time the relation is created or updated. That&amp;rsquo;s a real DB lock, so it needs a real justification.&lt;/p&gt;
&lt;p&gt;The textbook case is a sub-resource relation, where you need the parent resource&amp;rsquo;s state update to wait for the child relation update to finish, because state propagation logic depends on it.&lt;/p&gt;
&lt;p&gt;Reach for it deliberately, not by default.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Recap - Generated tables per Entity
&lt;div id="recap---generated-tables-per-entity" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#recap---generated-tables-per-entity" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A closer look at the other generated tables for a more complex Entity configuration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;T_[EntityName]Attribute&lt;/code&gt; &amp;amp; &lt;code&gt;T_[EntityName]AttributeHistory&lt;/code&gt;- Store current and historical values of instance attributes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;T_[EntityName]OperationAttribute&lt;/code&gt;- Stores values for operation attributes performed on the entity.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;T_[EntityName]EffectiveVersionHistory&lt;/code&gt;- Stores the history for the effective version.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;T_[EntityName]State&lt;/code&gt; &amp;amp; &lt;code&gt;T_[EntityName]StateHistory&lt;/code&gt; - Store the current and historical states across different state models. Its only populated when an entity has more than one StateModel.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Entity limitations worth knowing up front
&lt;div id="entity-limitations-worth-knowing-up-front" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#entity-limitations-worth-knowing-up-front" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A short list of hard constraints that are easy to discover the expensive way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Properties and custom properties &lt;strong&gt;cannot be deleted&lt;/strong&gt; once the schema is generated — you&amp;rsquo;d be dropping live database columns.&lt;/li&gt;
&lt;li&gt;Properties can only be &lt;strong&gt;added before&lt;/strong&gt; the first schema generation.&lt;/li&gt;
&lt;li&gt;Custom properties &lt;strong&gt;can be added after&lt;/strong&gt; generation, but each addition requires a schema regeneration.&lt;/li&gt;
&lt;li&gt;Adding a mandatory custom property to an entity that already has instances requires a default value, or the existing rows would have no way to satisfy the constraint.&lt;/li&gt;
&lt;li&gt;Attributes and operation attributes are the most forgiving of the five, they&amp;rsquo;re new rows in an attribute table, addable at any point in the entity&amp;rsquo;s life.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Smart and generic tables
&lt;div id="smart-and-generic-tables" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#smart-and-generic-tables" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Entities aren&amp;rsquo;t the only way to store business data. When you just need a lookup or a temporary mapping, generic and smart tables exist specifically so you don&amp;rsquo;t reach for a full entity type by default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generic tables&lt;/strong&gt; are the simple case: general-purpose key/value storage with a &lt;code&gt;T_GT_&lt;/code&gt; prefix, a main table and a history table, and nothing more exotic than that. Keys are mandatory, resolution is a straight lookup, and if an entity referenced by a key gets terminated, the row disappears with it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Smart tables&lt;/strong&gt; (&lt;code&gt;T_ST_&lt;/code&gt; prefix) add three things generic tables don&amp;rsquo;t have: &lt;strong&gt;validation rules&lt;/strong&gt; that run before and after a value changes, support for &lt;strong&gt;more than one value per key&lt;/strong&gt;, and &lt;strong&gt;precedence keys&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Precedence keys define a priority order for resolving a lookup from most specific to most generic, evaluated top-down until something matches.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see an example: a table keyed by &lt;code&gt;Step&lt;/code&gt; (mandatory), &lt;code&gt;Flow&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, and &lt;code&gt;Material&lt;/code&gt;. Building the precedence order out loud forces you to reason about which keys generalize which:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Step + Flow + Material&lt;/code&gt; — most specific&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Step + Material&lt;/code&gt; — same specificity of business meaning, one less dependency&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Step + Flow + Product&lt;/code&gt; — abstract away Material into Product&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Step + Product&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Step + Flow&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Step&lt;/code&gt; — most generic, always resolves&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The rule for building that list: every combination should use all the keys that matter to it, and any combination that would resolve identically to another (&lt;code&gt;Step + Product + Material&lt;/code&gt; next to &lt;code&gt;Step + Material&lt;/code&gt; — since a material only ever belongs to one product) should be dropped as redundant noise.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/business/resolvingsmarttables/?h=resolve&amp;#43;smart&amp;#43;tables"
target="_blank"
&gt;Resolution&lt;/a&gt; itself comes in two flavors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Standard&lt;/strong&gt; requires every precedence key to match the input exactly.
&lt;strong&gt;Partial&lt;/strong&gt; loosens that — a row still matches if a key was left out of the input entirely, or if the key column is null and the input passed null for it too.&lt;/p&gt;
&lt;p&gt;That distinction is what lets a smart table serve both a fully-specified lookup and a fallback default from the same structure.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Generic Table&lt;/th&gt;
&lt;th&gt;Smart Table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolution&lt;/td&gt;
&lt;td&gt;One fixed key combination, all keys mandatory&lt;/td&gt;
&lt;td&gt;Multiple precedence combinations, evaluated top-down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Pre/post validation rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple values per key&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial / null matching&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change control&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If none of those extra capabilities are in play, a generic table is genuinely a smart table with exactly one precedence key using every column — there&amp;rsquo;s no reason to pay for the complexity you don&amp;rsquo;t need.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Overriding the framework&amp;rsquo;s own tables
&lt;div id="overriding-the-frameworks-own-tables" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overriding-the-frameworks-own-tables" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;ContextResolution&lt;/code&gt; generic table is how the product itself decides which smart table governs a given resolution context — and it&amp;rsquo;s user-overridable.&lt;/p&gt;
&lt;p&gt;Point it at your own smart table, and you own the resolution logic. Do that for a step-related context and the custom table must carry a &lt;code&gt;Step&lt;/code&gt; column typed against the &lt;code&gt;Step&lt;/code&gt; entity; same pattern for &lt;code&gt;Resource&lt;/code&gt;. The one discipline worth holding onto here: before building a custom smart table that overrides a step- or resource-based context, go look at what precedence keys the out-of-the-box table already uses. Ship a replacement that&amp;rsquo;s missing functionality the standard one had, and the client will absolutely notice a few months later.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Strip away the SQL and the wizard screens, and the whole dynamic model is one idea, applied consistently: describe what you want in the static model, and let the runtime generation engine build the database objects, the assemblies, and the application-layer plumbing for you. Static is the blueprint. Dynamic is the data. Everything else — entities, custom properties, attributes, relations, smart tables — is a variation on how much structure you&amp;rsquo;re willing to ask the generator to build on your behalf.&lt;/p&gt;
&lt;p&gt;The part that&amp;rsquo;s easy to miss from the outside is that this isn&amp;rsquo;t just a code-generation convenience. It&amp;rsquo;s the mechanism that lets a base product entity and a customer&amp;rsquo;s custom entity be structurally indistinguishable at the framework level — which is the entire reason the platform can be extended without slowly turning into an unmaintainable pile of one-off systems per customer.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This blog post was based on a talk by Marcelo Gomes in 2025-10-02 @CM-Portugal&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260728-mes-dynamic-model/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260728-mes-dynamic-model/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>MES Security in Three Layers: Features, Objects, and the One Everyone Skips</title>
<link>https://j-roque.com/posts/20260727-securitymes/</link>
<pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260727-securitymes/</guid>
<description>Feature Level Security hides your buttons. Object Level Security segregates your data. Service Level Security protects your API. Most MES projects stop at the first one, and that&amp;rsquo;s a problem.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260727-securitymes/featured.png" alt="MES Security in Three Layers: Features, Objects, and the One Everyone Skips" /&gt;&lt;/p&gt;&lt;p&gt;You can hide every button an operator shouldn&amp;rsquo;t touch. You can build a role tree that mirrors your org chart down to the last supervisor. And none of it matters if the API sitting underneath your GUI doesn&amp;rsquo;t check permissions on its own. A locked front door means nothing when the window next to it is wide open.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the uncomfortable truth about security in &lt;strong&gt;Manufacturing Execution System&lt;/strong&gt; (MES) deployments: there are three independent security levels, and in my experience almost everyone configures exactly one of them, missing out on a whole set of features.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Critical Manufacturing MES ships with three security levels — &lt;strong&gt;Feature&lt;/strong&gt;, &lt;strong&gt;Object&lt;/strong&gt;, and &lt;strong&gt;Service&lt;/strong&gt; — that can each be turned on or off independently. They protect different things: what a user sees, what data a user can touch, and what a user can actually execute at the API level. This post walks through all three, how permissions flow from users to roles to features and data groups, and why skipping the third layer is closer to leaving a vulnerability in production than a configuration choice.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Users, Roles, and the MES Scope
&lt;div id="users-roles-and-the-mes-scope" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#users-roles-and-the-mes-scope" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Everything in this system starts with &lt;strong&gt;Role Based Access Control&lt;/strong&gt; (RBAC). A &lt;code&gt;user&lt;/code&gt; — human or system — never gets permissions directly. Permissions are assigned to &lt;code&gt;roles&lt;/code&gt;, and users (or other roles) become members of those roles.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A role is just a logical grouping: Operator, Supervisor, Quality Engineer, whatever your org needs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The part that trips people up: when a user belongs to multiple roles, they get the &lt;strong&gt;union&lt;/strong&gt; of all permissions, not the intersection.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;Role R1&lt;/code&gt; has no access to Track-In and &lt;code&gt;Role R2&lt;/code&gt; does, a user in both roles can Track-In. &lt;strong&gt;Permissions only ever add up&lt;/strong&gt;. There&amp;rsquo;s no &amp;ldquo;most restrictive role wins&amp;rdquo; mode — design your role tree with that in mind, because it&amp;rsquo;s easy to accidentally grant more than you intended by combining roles casually.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;One role deserves special mention: &lt;code&gt;MES&lt;/code&gt;. It&amp;rsquo;s used as an OAuth scope, and if a user isn&amp;rsquo;t a member of it (or of a role that inherits from it), they cannot log in at all — they&amp;rsquo;ll hit an error about missing required scopes. This scope is what lets the authorization system tell MES sessions apart from other client types, like companion apps hitting the same backend.
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MES Role Error"
src="https://image.j-roque.com/posts/20260727-securitymes/MESRoleMissingError.png"
&gt;&lt;/figure&gt;
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MES OAuth Scope"
src="https://image.j-roque.com/posts/20260727-securitymes/MESOAuthScope.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Roles also carry responsibilities beyond access control — ownership of Certifications, approval on Change Sets, release authority on Material Hold Reasons. It&amp;rsquo;s worth remembering that &amp;ldquo;role&amp;rdquo; in this system isn&amp;rsquo;t purely an access-control primitive; it&amp;rsquo;s also a business-process concept.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Three Levels, Independently Switchable
&lt;div id="three-levels-independently-switchable" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#three-levels-independently-switchable" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Enforced at&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;th&gt;Adoption&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feature&lt;/td&gt;
&lt;td&gt;GUI&lt;/td&gt;
&lt;td&gt;Show/hide/disable buttons and menus&lt;/td&gt;
&lt;td&gt;Nearly universal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;td&gt;SQL / data layer&lt;/td&gt;
&lt;td&gt;Data segregation, read/write control per entity instance&lt;/td&gt;
&lt;td&gt;Occasional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service&lt;/td&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Restrict which backend services can be invoked&lt;/td&gt;
&lt;td&gt;Rare — and that&amp;rsquo;s the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;They&amp;rsquo;re &lt;strong&gt;not mutually exclusive&lt;/strong&gt;, and in a properly hardened deployment you&amp;rsquo;d run all three simultaneously. Most projects run one.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Feature Level Security
&lt;div id="feature-level-security" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#feature-level-security" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is the one everybody already knows. It controls which GUI elements a user can see and interact with — a menu, a button, a whole page. It&amp;rsquo;s enforced at the &lt;strong&gt;GUI level&lt;/strong&gt; and cached on the client for responsiveness, which makes it cheap: negligible performance impact, easiest of the three to maintain.&lt;/p&gt;
&lt;p&gt;One default worth knowing before you flip the switch: &lt;strong&gt;no features are assigned to any role out of the box&lt;/strong&gt;. Enable Feature Level Security without preparing your roles first, and every non-admin user logs in to a mostly blank interface. You&amp;rsquo;re not removing access from a permissive baseline, you&amp;rsquo;re building access up from nothing.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Aggregation vs. Inheritance
&lt;div id="aggregation-vs-inheritance" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#aggregation-vs-inheritance" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Say &lt;code&gt;Role 1&lt;/code&gt; has access to the Administration menu and &lt;code&gt;Role 2&lt;/code&gt; has access to the Automation menu. If Alice belongs to both roles directly, she sees both menus — that&amp;rsquo;s aggregation. Permissions from every assigned role get combined.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Role Aggregation Alice"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-aggregation-alice.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Role Aggregation"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-aggregation.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Inheritance is the alternative: instead of putting Alice in both roles, you make &lt;code&gt;Role 2&lt;/code&gt; a member of &lt;code&gt;Role 1&lt;/code&gt;. Now anyone in &lt;code&gt;Role 1&lt;/code&gt; inherits everything &lt;code&gt;Role 2&lt;/code&gt; grants, cascading down the tree. A role like &lt;code&gt;Role C&lt;/code&gt; with access to just &lt;code&gt;Feature 6&lt;/code&gt; can sit at the bottom of a chain, and every ancestor role picks up that permission automatically.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Role Inheritance"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-inheritance.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Both patterns are valid, but they carry different long-term costs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Aggregation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Straightforward, flexible, easy to grasp for small systems&lt;/td&gt;
&lt;td&gt;Permission bloat over time, no way to deny/override, requires very granular roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inheritance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mirrors real org structure, permissions defined once, less duplication&lt;/td&gt;
&lt;td&gt;Deep trees get hard to visualize, unintended inheritance, hard to troubleshoot &amp;ldquo;why does this user have this?&amp;rdquo;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;Use inheritance to model your org chart. Use aggregation to combine independent concerns — like a functional role and a data-access role. Don&amp;rsquo;t use aggregation as a substitute for a role tree you were too lazy to design.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Least privilege, descriptive naming, shallow and well-documented trees — none of this is novel advice, but it&amp;rsquo;s advice people skip under deadline pressure, and it&amp;rsquo;s the difference between a permission model you can reason about and one where nobody remembers why &lt;code&gt;Role_Temp_QA_2&lt;/code&gt; has write access to Production Orders.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Feature Configuration Options Worth Knowing
&lt;div id="feature-configuration-options-worth-knowing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#feature-configuration-options-worth-knowing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Whenever you add a GUI element or a custom &lt;code&gt;EntityType&lt;/code&gt;, you need a matching feature, an &lt;code&gt;&amp;lt;EntityType&amp;gt;.Show&lt;/code&gt; feature, for instance or that entity simply won&amp;rsquo;t be visible, ever, to anyone. This is easy to forget when you&amp;rsquo;re working under an admin account during development, and it tends to surface only when you deploy to a customer and start setting real permissions. By then it&amp;rsquo;s an unpleasant discovery.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Feature Show"
src="https://image.j-roque.com/posts/20260727-securitymes/FeatureShow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;A few less-obvious feature settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Feature Group / Module&lt;/strong&gt; — organizational metadata. Neither is mandatory, but with a features list that keeps growing, Module in particular gives you a filterable category when assigning permissions. Use it.
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Feature Module"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-show-module.png"
&gt;&lt;/figure&gt;
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Feature Group"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-show-group.png"
&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Force Signature&lt;/strong&gt; — requires an e-signature to complete the transaction. Not applicable everywhere, but relevant for regulated actions.
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Force Sign"
src="https://image.j-roque.com/posts/20260727-securitymes/feature-show-forcesign.png"
&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Require Comment&lt;/strong&gt; — same idea, for a mandatory comment field.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Writable&lt;/strong&gt; — this is the interesting one, because it&amp;rsquo;s Feature and Object Level Security meeting in the middle. With &lt;code&gt;Writable = No&lt;/code&gt; (the default), a feature the user has access to stays visible even against an object they can&amp;rsquo;t modify, they&amp;rsquo;ll click it and get an error. With &lt;code&gt;Writable = Yes&lt;/code&gt;, MES is smarter: it disables the control up front instead of letting the user hit a wall after clicking.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Object Level Security
&lt;div id="object-level-security" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#object-level-security" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is where you stop asking &amp;ldquo;can this user use this feature&amp;rdquo; and start asking &amp;ldquo;can this user touch this specific piece of data.&amp;rdquo; It works through &lt;code&gt;Data Groups&lt;/code&gt;, free-text tags you define, then assign to entity instances (Products, Materials, and others).&lt;/p&gt;
&lt;p&gt;Two scenarios make the case for this immediately:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Customer segregation&lt;/strong&gt;: a contract manufacturer running multiple customers through one MES instance tags each customer&amp;rsquo;s Products, BOMs, and Recipes with a customer-specific Data Group, so the team working Customer X&amp;rsquo;s orders can&amp;rsquo;t see Customer Y&amp;rsquo;s data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regulatory restriction&lt;/strong&gt;: a manufacturer building both commercial and military-grade products tags military materials as &lt;code&gt;Military_Restricted&lt;/code&gt;, and only assigns that Data Group to roles held by cleared personnel. This isn&amp;rsquo;t a nice-to-have — for something like ITAR, it&amp;rsquo;s the compliance control.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unlike Feature Level Security&amp;rsquo;s binary on/off, Object Level Security has three states:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Access Type&lt;/th&gt;
&lt;th&gt;See the object&lt;/th&gt;
&lt;th&gt;Modify the object&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No-Access&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read-Access&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write-Access&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Aggregation and inheritance behave the same way they do for features, with one clarifying detail: &lt;strong&gt;Write always beats Read&lt;/strong&gt;. If a user gets Read from one role and Write from another on the same Data Group, they end up with Write. Same principle as before — permissions only ever grow — just more visible here because the levels are ordered.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Read Write"
src="https://image.j-roque.com/posts/20260727-securitymes/material_read_write_dg.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;It&amp;rsquo;s Enforced in SQL, Not Just the GUI
&lt;div id="its-enforced-in-sql-not-just-the-gui" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#its-enforced-in-sql-not-just-the-gui" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This is the detail that separates Object Level Security from a cosmetic filter. When it&amp;rsquo;s active, MES modifies the query itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;-- Object Level Security: Off
SELECT &amp;lt;...&amp;gt; FROM [V_Material] WHERE &amp;lt;...&amp;gt;
-- Object Level Security: On, no data groups assigned to the user/role
SELECT &amp;lt;...&amp;gt; FROM [V_Material] WHERE &amp;lt;...&amp;gt; AND DataGroupId IS NULL
-- Object Level Security: On, three data groups assigned
SELECT &amp;lt;...&amp;gt; FROM [V_Material]
WHERE &amp;lt;...&amp;gt; AND (DataGroupId IS NULL
  OR DataGroupId IN (2507040316140000002, 2507040316140000003, 2507040316140000004))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A query built by MES automatically restricts to what the user&amp;rsquo;s Data Groups permit, which means an ad-hoc query built inside MES can&amp;rsquo;t leak data the GUI wouldn&amp;rsquo;t show you either. That&amp;rsquo;s the good news.&lt;/p&gt;
&lt;p&gt;The flip side of it: more Data Groups means a longer &lt;code&gt;IN&lt;/code&gt; clause, and that has a real, measurable cost on database performance. Not catastrophic, but not free, factor it in before layering on dozens of Data Groups per role.&lt;/p&gt;
&lt;p&gt;On the GUI side, write access is enforced through a &lt;code&gt;LockType&lt;/code&gt; value returned in the payload:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;LockType&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Full access to the object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Read-only access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;No access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And critically, this isn&amp;rsquo;t only a GUI convenience, it&amp;rsquo;s backed server-side. If a user without write access to a Material tries to bypass the UI with a direct API call, the request fails with a &lt;code&gt;UserDoesNotHaveSecurityLevelToAccessObjectCmfException&lt;/code&gt;. The framework enforces this through utilities like &lt;code&gt;ValidateServiceConditions&lt;/code&gt; and &lt;code&gt;ValidateEntityCollectionConditions&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Here&amp;rsquo;s the part that should worry you: that server-side check only fires if the service calls it. &lt;strong&gt;Custom services have to call &lt;code&gt;ValidateServiceConditions&lt;/code&gt; (or the collection equivalent) explicitly&lt;/strong&gt;, nothing natively enforces it. Skip that call in your custom code, and Object Level Security silently stops applying to anything that service touches, regardless of what Data Group is configured.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Datagroup DC"
src="https://image.j-roque.com/posts/20260727-securitymes/material_dc_dg.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Combine Functional Roles with Data Access Roles
&lt;div id="combine-functional-roles-with-data-access-roles" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#combine-functional-roles-with-data-access-roles" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The practical pattern that keeps this maintainable: don&amp;rsquo;t build one role per person or per combination. Build a &lt;code&gt;functional&lt;/code&gt; role (Operator, Engineer) that grants features, and a separate &lt;code&gt;data access&lt;/code&gt; role (BusinessUnitA, BusinessUnitB) that grants Data Groups, then assign both to each user.&lt;/p&gt;
&lt;p&gt;Alice as Operator + BusinessUnitA gets Track-In/Track-Out privileges scoped to Business Unit A&amp;rsquo;s objects. Charlie as Engineer + BusinessUnitA gets Create-Flow/Create-Step privileges on the same data scope. You&amp;rsquo;re not duplicating a &amp;ldquo;BusinessUnitA-Operator&amp;rdquo; and &amp;ldquo;BusinessUnitA-Engineer&amp;rdquo; role for every combination, you&amp;rsquo;re composing two independent, reusable dimensions. It&amp;rsquo;s more roles up front, and it pays for itself the moment you add a third business unit or a third job function.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Service Level Security
&lt;div id="service-level-security" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#service-level-security" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is the layer that restricts which backend services — &lt;strong&gt;API calls&lt;/strong&gt; — a user&amp;rsquo;s roles can invoke, independent of what they can see in the GUI. It&amp;rsquo;s also, in my experience, the layer that&amp;rsquo;s rarely configured.&lt;/p&gt;
&lt;p&gt;Generic services need extra context to mean anything. Granting access to &lt;code&gt;CreateObject&lt;/code&gt; (a generic service to create system objects) alone would let a user create &lt;em&gt;any&lt;/em&gt; kind of object. The system enforces you to then also select the &lt;code&gt;EntityType&lt;/code&gt; (and/or &lt;code&gt;SystemType&lt;/code&gt;) it applies to e.g &lt;code&gt;CreateObject&lt;/code&gt; + &lt;code&gt;QueryObject&lt;/code&gt;, if you want a role that can only create Query objects and nothing else.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Generic Server Level Security"
src="https://image.j-roque.com/posts/20260727-securitymes/generic-servicelevelsecurity.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Enabling is more complex, not just a checkbox: like Feature Level Security, &lt;strong&gt;no services are assigned to any role by default&lt;/strong&gt;, and logging in requires several API calls just to bootstrap the session (&lt;code&gt;GetApplicationBootInformation&lt;/code&gt; among them). Enable this without prepping roles first, and nobody — save the administrator — logs in.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Where the GUI and the API Disagree
&lt;div id="where-the-gui-and-the-api-disagree" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#where-the-gui-and-the-api-disagree" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Two failure modes make the risk concrete:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A feature is unassigned but the service is assigned (or Service Level Security is simply off).&lt;/strong&gt; A user without the &lt;code&gt;Product.Create&lt;/code&gt; feature can&amp;rsquo;t create a Product through the GUI, but if they call the API &lt;code&gt;CreateObjectVersion&lt;/code&gt; service for Product directly, they can create as many Products as they like via API. The GUI restriction was frontend only the whole time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A feature is assigned but the service isn&amp;rsquo;t.&lt;/strong&gt; A user with the &lt;code&gt;Material.ChangeFlowAndStep&lt;/code&gt; feature sees the option in the GUI, clicks it, and the action fails because &lt;code&gt;ChangeMaterialFlowAndStep&lt;/code&gt; isn&amp;rsquo;t authorized at the service level. Annoying for the user, but at least it fails safely.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first case is the one that should keep you up at night. Feature Level Security only ever protects the GUI. It was never designed to be a substitute for actual API authorization, and if Service Level Security is off, there&amp;rsquo;s nothing standing between &amp;ldquo;the button isn&amp;rsquo;t visible&amp;rdquo; and &amp;ldquo;the endpoint will do whatever you ask it to.&amp;rdquo;&lt;/p&gt;
&lt;h3 class="relative group"&gt;Why This Isn&amp;rsquo;t Theoretical
&lt;div id="why-this-isnt-theoretical" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-this-isnt-theoretical" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This gets uncomfortable fast once you factor in what MES lets an authenticated caller do. If Service Level Security isn&amp;rsquo;t enabled anyone hitting the API directly effectively has admin-level API access, GUI restrictions notwithstanding. Combine that with &lt;strong&gt;Dynamic Execution Engine&lt;/strong&gt; (DEEs Actions), which allow C# code execution through endpoints like &lt;code&gt;ExecuteAction&lt;/code&gt;, and you&amp;rsquo;ve got a path from &amp;ldquo;unauthenticated feature gap&amp;rdquo; to &amp;ldquo;arbitrary command execution on the application server.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Validation for Service Level Security happens entirely server-side, and only for the specific service being invoked — not transitively. Dispatch-and-Track-In with an attached Data Collection succeeds even without explicit permission on the Data Collection service, because the Data Collection handling happens inside the Dispatch service call, not as a separate invocation. That&amp;rsquo;s actually good news operationally: you don&amp;rsquo;t need to permission every service that could theoretically be touched, just the ones directly invoked by the GUI paths you care about.&lt;/p&gt;
&lt;h3 class="relative group"&gt;On Performance
&lt;div id="on-performance" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#on-performance" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Worth addressing directly, because it&amp;rsquo;s the first objection anyone raises: Feature and Service Level Security run against cached, in-memory checks a few extra conditionals per call. Negligible. Object Level Security is the one with a real cost, because it changes the shape of the SQL being executed. Enable it deliberately, not reflexively.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Feature Level Security tells a user what they can see.&lt;/p&gt;
&lt;p&gt;Object Level Security tells them what data they can touch.&lt;/p&gt;
&lt;p&gt;Service Level Security tells them what they can actually &lt;em&gt;do&lt;/em&gt; — and it&amp;rsquo;s the layer that closes the loop between &amp;ldquo;hidden in the GUI&amp;rdquo; and &amp;ldquo;actually not possible.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Configuring only the first two is building a system that looks secure in a demo and isn&amp;rsquo;t secure against anyone who opens the network tab. Of course, the MES is not exposed to the internet and is walled garden, but it should not be an excuse for not using the tools offered to improve your system security.&lt;/p&gt;
&lt;p&gt;If you take one thing from this: audit every custom service in your codebase for &lt;code&gt;ValidateServiceConditions&lt;/code&gt; or &lt;code&gt;ValidateEntityCollectionConditions&lt;/code&gt; calls before you audit anything else. It&amp;rsquo;s a five-minute grep that tells you whether your Object Level Security is real or decorative and it&amp;rsquo;s usually the fastest way to find out you have work to do.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This blog post was based on a talk by Hugo Almeida in 2025-09-18 @CM-Portugal&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260727-securitymes/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260727-securitymes/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Understanding Dynamic Execution Extensions (DEEs): How They Really Work</title>
<link>https://j-roque.com/posts/20260724-howdodeeswork/</link>
<pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260724-howdodeeswork/</guid>
<description>Most engineers learn to create a DEE long before they understand the execution pipeline underneath it. This is what that pipeline actually looks like — and why it matters once a project gets big.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260724-howdodeeswork/featured.png" alt="Understanding Dynamic Execution Extensions (DEEs): How They Really Work" /&gt;&lt;/p&gt;&lt;p&gt;Most developers learn how to create a &lt;strong&gt;Dynamic Execution Extension&lt;/strong&gt; (&lt;code&gt;DEE&lt;/code&gt;) long before they understand how the execution pipeline underneath it actually works. That gap doesn&amp;rsquo;t matter on a small project. It matters enormously once a project grows: it starts showing up as performance issues, duplicated logic, Action Groups picked by guesswork, and debugging sessions that end with someone staring at a stack trace error they&amp;rsquo;ve seen a dozen times but never actually understood.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This blog post is about what happens behind the scenes: why DEEs exist, how the CM MES product code actually finds and triggers them, how to choose between &lt;code&gt;Pre&lt;/code&gt; and &lt;code&gt;Post&lt;/code&gt;, &lt;code&gt;Operation&lt;/code&gt; and &lt;code&gt;Orchestration&lt;/code&gt;, and the handful of best practices that separate a maintainable implementation from a production incident waiting to happen.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why DEEs Exist in the First Place
&lt;div id="why-dees-exist-in-the-first-place" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-dees-exist-in-the-first-place" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;When Critical Manufacturing built its MES, the team made a deliberate choice not to ship a closed system — the kind where the only way to add logic is to copy the product source and modify it. That approach might work in the short term on a fully managed CM Project, but it&amp;rsquo;s not sustainable: you lose upgrades, you duplicate everything you touch, and you end up maintaining a fork of the product forever.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DEEs&lt;/code&gt; — Dynamic Execution Extensions — are one of the main answers to that problem. They give implementation teams, and power users, a way to extend and adapt the product without touching product code and without depending on Critical Manufacturing for every new requirement. A client with enough in-house knowledge can build their own logic and become genuinely independent. That&amp;rsquo;s the point of the mechanism, and it&amp;rsquo;s worth keeping in mind every time you&amp;rsquo;re deciding &lt;em&gt;how&lt;/em&gt; to use it — because a mechanism built for extensibility can just as easily be used to build something unmaintainable.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Where DEEs Fit in the Request Pipeline
&lt;div id="where-dees-fit-in-the-request-pipeline" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#where-dees-fit-in-the-request-pipeline" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Strip away the specifics and a standard MES method call is simple: a request comes in, it flows through the method, and a result comes out. &lt;code&gt;DEEs&lt;/code&gt; insert themselves at exactly two points around that flow. Before the method runs, the pipeline checks whether there&amp;rsquo;s anything registered to execute first, if so, runs it.&lt;/p&gt;
&lt;p&gt;The standard method flow executes.&lt;/p&gt;
&lt;p&gt;Then, after the method completes, the pipeline checks again for anything registered to run afterward, executes it, and only then returns the output.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the entire mental model.&lt;/p&gt;
&lt;p&gt;Everything else — naming conventions, Action Groups, Pre versus Post — is just detail on top of this one idea: a hook before, a hook after, and the standard method in between.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;img
src="https://image.j-roque.com/posts/20250821-howdodeeswork/MESRequest.png"
alt="MES Request"
loading="lazy"
decoding="async"
style="background-color:#fff; padding:1rem; border-radius:0.5rem;"
/&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;div class="not-prose"&gt;
&lt;img
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEEsHook.png"
alt="DEE Hooks"
loading="lazy"
decoding="async"
style="background-color:#fff; padding:1rem; border-radius:0.5rem;"
/&gt;
&lt;/div&gt;
&lt;h2 class="relative group"&gt;Naming Your Action Group: The Convention Nobody Tells You About
&lt;div id="naming-your-action-group-the-convention-nobody-tells-you-about" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#naming-your-action-group-the-convention-nobody-tells-you-about" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Action Group name isn&amp;rsquo;t &lt;strong&gt;arbitrary&lt;/strong&gt;, and it isn&amp;rsquo;t something you have to reverse-engineer from the product source. It follows a simple and fixed pattern and is described in the documentation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{NamespaceSuffix}.{ClassName}.{MethodName}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Take the &lt;code&gt;CreateMaterial&lt;/code&gt; method as an example. Its .NET namespace is &lt;code&gt;Cmf.Navigo.BusinessOrchestration.MaterialManagement&lt;/code&gt;, its class is &lt;code&gt;MaterialOrchestration&lt;/code&gt;, and the method is &lt;code&gt;CreateMaterial&lt;/code&gt;. Strip the namespace down to its suffix — &lt;code&gt;MaterialManagement&lt;/code&gt; — and combine it with the class and method names, and you get:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MaterialManagement.MaterialOrchestration.CreateMaterial&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Append &lt;code&gt;.Pre&lt;/code&gt; or &lt;code&gt;.Post&lt;/code&gt; depending on where you want your logic to run, and that&amp;rsquo;s your Action Group. It&amp;rsquo;s a mechanical process, not magic, it&amp;rsquo;s true because of retro-compatibility, a handful of older methods don&amp;rsquo;t follow this convention to the letter, but those are documented exceptions.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t have to know this by heart, there&amp;rsquo;s a reference for exactly this: the &lt;a
href="https://developer.criticalmanufacturing.com/reference/api-extensionpoints/index.html"
target="_blank"
&gt;Extension Points documentation&lt;/a&gt; lists every extension point available in MES. It&amp;rsquo;s a resource that, according to the training session feedback, far fewer developers know about it than they should.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Magic Behind the Curtain: StartMethod and EndMethod
&lt;div id="the-magic-behind-the-curtain-startmethod-and-endmethod" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-magic-behind-the-curtain-startmethod-and-endmethod" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Knowing the naming convention answers &lt;em&gt;what&lt;/em&gt; your Action Group is called. It doesn&amp;rsquo;t answer how the product code actually finds and executes your DEE. That happens inside two utility calls that wrap the standard method body:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;namespace Cmf.Navigo.BusinessOrchestration.MaterialManagement
{
    internal partial class MaterialOrchestration : IMaterialOrchestration
    {
        public CreateMaterialOutput CreateMaterial(CreateMaterialInput createMaterialInput)
        {
            _utilities.StartMethod(objectTypeName, &amp;#34;CreateMaterial&amp;#34;,
                new KeyValuePair&amp;lt;String, Object&amp;gt;(nameof(CreateMaterialInput), createMaterialInput));
// Method code…
_utilities.EndMethod(createMaterialOutput.Material.EntityType.Id,
                createMaterialOutput.Material.Id,
                new KeyValuePair&amp;lt;String, Object&amp;gt;(&amp;#34;CreateMaterialInput&amp;#34;, createMaterialInput));
return createMaterialOutput;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;StartMethod&lt;/code&gt; builds the Action Group name and appends &lt;code&gt;.Pre&lt;/code&gt;; &lt;code&gt;EndMethod&lt;/code&gt; builds the same name and appends &lt;code&gt;.Post&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Each call checks whether any DEE is registered against that resolved name, and if so, executes it. That&amp;rsquo;s the whole mechanism, there&amp;rsquo;s no separate dispatcher, no background listener. It&amp;rsquo;s these two calls, wrapping the method body, doing the lookup and triggering the DEEs synchronously as part of the same call stack.&lt;/p&gt;
&lt;h2 class="relative group"&gt;When Multiple DEEs Share an Action Group
&lt;div id="when-multiple-dees-share-an-action-group" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#when-multiple-dees-share-an-action-group" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;There&amp;rsquo;s nothing stopping you from registering as many DEEs as you want against the same Action Group, and in real implementations that can happen, a single &lt;code&gt;TrackOut.Post&lt;/code&gt;, for example, might have several DEEs attached to handle different pieces of logic.&lt;/p&gt;
&lt;p&gt;When that happens, execution runs in a &lt;strong&gt;cascading sequence, lowest order to highest&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That guarantee only holds if you actually set distinct orders. Leave two DEEs at the same order — which happens more often than you&amp;rsquo;d think, especially when master data gets bulk-loaded — and the execution order between them is undefined. You don&amp;rsquo;t get to choose which one runs first, and if one depends on state the other mutates, that&amp;rsquo;s a real bug waiting for the wrong day to show up.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second, subtler trap: a DEE&amp;rsquo;s position is relative to the Action Group, not absolute. The same DEE that runs third in one Action Group can run first in a different one it&amp;rsquo;s also attached to. If that DEE assumes something was already done by &amp;ldquo;the DEEs before it,&amp;rdquo; that assumption silently breaks the moment it&amp;rsquo;s reused somewhere else.&lt;/p&gt;
&lt;p&gt;And then there&amp;rsquo;s the &lt;strong&gt;plain performance math&lt;/strong&gt;. If ten DEEs are attached to &lt;code&gt;TrackOut.Post&lt;/code&gt; and each takes a 300 milliseconds to run, you&amp;rsquo;ve added three seconds to every track-out — permanently, not as a one-off cost. On a line producing a unit every couple of seconds, adding seconds to track-out isn&amp;rsquo;t a rounding error, it&amp;rsquo;s a bottleneck that turns into a production down event.&lt;/p&gt;
&lt;p&gt;The reflex response, merge all ten DEEs&amp;rsquo; code into one, doesn&amp;rsquo;t help a lot. The code is the same code, running the same instructions; consolidating it into a single DEE buys you little in runtime and costs you readability and debuggability.&lt;/p&gt;
&lt;p&gt;Try and reduce chaining, either vertically (in the same action group) or horizontally (hooking both in the service and in operations called by the service). Be simple and obvious, smaller, single-purpose DEEs are easier to understand, easier to debug, and easier to reorder safely later.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE Hierarchy"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEEHierarchy.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Pre or Post? Fail Fast, Then Act
&lt;div id="pre-or-post-fail-fast-then-act" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pre-or-post-fail-fast-then-act" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The decision between &lt;code&gt;Pre&lt;/code&gt; and &lt;code&gt;Post&lt;/code&gt; usually comes down to one question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Does this logic depend on the operation having already happened, or does it need to stop the operation from happening at all?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Take rework. If a DEE needs to send a material to a rework flow &lt;em&gt;after&lt;/em&gt; it&amp;rsquo;s been processed, it has to run on &lt;code&gt;Post&lt;/code&gt;. It wouldn&amp;rsquo;t make sense to send the material to rework before the track-out that triggers the rework in the first place.&lt;/p&gt;
&lt;p&gt;Now flip it: you want to block a track-out entirely if the material is missing a required attachment. That validation has to run on &lt;code&gt;Pre&lt;/code&gt;. You could technically validate after the track-out and roll everything back on failure, but that wastes a full operation&amp;rsquo;s worth of resources and time just to fail. The entire point of a validation is to &lt;strong&gt;fail fast&lt;/strong&gt;, before you&amp;rsquo;ve done any work you&amp;rsquo;ll have to undo.&lt;/p&gt;
&lt;p&gt;That generalizes cleanly: validations belong at &lt;code&gt;Pre&lt;/code&gt;. If you find yourself putting a process validation on &lt;code&gt;Post&lt;/code&gt;, that&amp;rsquo;s worth a second look.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All the validations that you do should be at the pre of any Action Group.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Operation or Orchestration? It depends&amp;hellip;
&lt;div id="operation-or-orchestration-it-depends" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#operation-or-orchestration-it-depends" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is the question that generates the most contention in practice.&lt;/p&gt;
&lt;p&gt;The mechanical difference is straightforward.&lt;/p&gt;
&lt;p&gt;An &lt;code&gt;Operation&lt;/code&gt; — for example &lt;code&gt;BusinessObjects.MaterialCollection.TrackIn&lt;/code&gt; — does only the essential, object-level work, and it&amp;rsquo;s &lt;em&gt;guaranteed&lt;/em&gt; to run every time that operation executes, no matter what is the service calling it.&lt;/p&gt;
&lt;p&gt;An &lt;code&gt;Orchestration&lt;/code&gt; — for example &lt;code&gt;MaterialManagement.MaterialManagementOrchestration.ComplexTrackInMaterials&lt;/code&gt; — wraps several operations together and triggers everything around them: data collections, checklists, and whatever else the UI action is supposed to kick off. Orchestrations are usually what UI buttons call through their underlying services; they are &lt;em&gt;not&lt;/em&gt; guaranteed to run if a lower-level operation is invoked directly, by a different orchestration, or by another DEE.&lt;/p&gt;
&lt;p&gt;The rule of thumb: If you want a system wide impact default to &lt;code&gt;Operation&lt;/code&gt;, if you want to pinpoint the change to particular API call or behavior choose the specific &lt;code&gt;Orchestrations&lt;/code&gt; you want to impact. Take not that when making system wide extensions, they will impact the whole system and as such you need to be extra carefully validating this. Also, it&amp;rsquo;s important to understand what is the granularity of context needed for your extensibility, that may also impact this decision.&lt;/p&gt;
&lt;p&gt;Where it gets genuinely contested is &lt;em&gt;where inside the orchestration lifecycle&lt;/em&gt; to hook in when orchestration-level context is actually needed. Hooking at end of the orchestration as the requirement allows — triggering on &lt;code&gt;Post&lt;/code&gt; at the orchestration level rather than reaching into an operation in the middle of it can ensuring that the standard way the API works will be kept as most as possible.&lt;/p&gt;
&lt;p&gt;The counterargument, is that hooking into an operation mid-orchestration and then manipulating an object there is a well-known way to hit &lt;code&gt;&amp;quot;the object has changed&amp;quot;&lt;/code&gt; errors, because the DEE mutates something that isn&amp;rsquo;t part of the input being passed back up the call chain, and the reference the orchestration expects gets silently invalidated. Debugging that is its own kind of forensic exercise: reconstructing which operation, inside which orchestration, touched what, in what order.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a performance angle: calling an orchestration &lt;em&gt;from inside&lt;/em&gt; a DEE — instead of calling the underlying operation directly — adds the orchestration&amp;rsquo;s overhead every time that DEE runs. The trade-off cuts both ways: skip the orchestration and call the operation directly, and you might silently lose whatever the orchestration was responsible for triggering. A concrete example from the session — calling the &lt;code&gt;TrackIn&lt;/code&gt; operation directly, instead of going through the orchestration, means the data collections and checklist instances that the UI flow normally opens simply never get created.&lt;/p&gt;
&lt;p&gt;None of this resolves into a single rule. It depends on what you&amp;rsquo;re trying to guarantee, and what you&amp;rsquo;re willing to skip.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Documentation Trick: Trace What an Orchestration Actually Calls
&lt;div id="the-documentation-trick-trace-what-an-orchestration-actually-calls" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-documentation-trick-trace-what-an-orchestration-actually-calls" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The technique behind that worked example is worth calling out on its own, because it&amp;rsquo;s underused. The &lt;a
href="https://developer.criticalmanufacturing.com/reference/api-extensionpoints/index.html"
target="_blank"
&gt;Extension Points documentation&lt;/a&gt; doesn&amp;rsquo;t just list Action Group names — for orchestrations, it shows what they invoke underneath, recursively. Instead of reading product source to reconstruct a call graph by hand, you can search the orchestration the UI calls, see the Action Groups available on it, and follow the chain down through every operation and sub-orchestration it triggers.&lt;/p&gt;
&lt;p&gt;That lets you make an informed trade-off: the topmost orchestration usually does the most (and costs the most), and each layer down sheds functionality you may or may not need. Pick the shallowest layer that still gives you the context your DEE actually requires — nothing more.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Anatomy of a DEE: Test Condition, Action Code, and References
&lt;div id="anatomy-of-a-dee-test-condition-action-code-and-references" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#anatomy-of-a-dee-test-condition-action-code-and-references" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Every DEE Action has a &lt;strong&gt;Test Condition Code&lt;/strong&gt; block that must return a boolean, and an &lt;strong&gt;Action Code&lt;/strong&gt; block that runs if that condition is true.&lt;/p&gt;
&lt;p&gt;Inside the action code, the &lt;code&gt;UseReference&lt;/code&gt; directive takes two string arguments: the assembly to reference, and the namespace within it to use — the DEE equivalent of adding a reference in Visual Studio plus a &lt;code&gt;using&lt;/code&gt; statement in C#.&lt;/p&gt;
&lt;p&gt;Two things worth knowing here that aren&amp;rsquo;t obvious from examples: product assemblies don&amp;rsquo;t need to be referenced at all — they&amp;rsquo;re already loaded — and project-specific assemblies only need to be declared once, even if you use types from that assembly across many lines. Declaring the same reference three times doesn&amp;rsquo;t multiply anything at runtime; it&amp;rsquo;s just inherited copy-paste noise. It&amp;rsquo;s also entirely valid to skip declaring the namespace and use the fully-qualified type name inline instead — slightly more verbose per line, but it keeps the top of the file clean, which is exactly what the auto-generated DEEs behind Business Workflows do (more on that below).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE Structure"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEEStructure.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Triggering DEEs: Automatic, Manual, and by Code
&lt;div id="triggering-dees-automatic-manual-and-by-code" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#triggering-dees-automatic-manual-and-by-code" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;DEEs trigger three ways.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatically&lt;/strong&gt;, by being associated with an Action Group or with a Rule attached to some other MES entity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manually&lt;/strong&gt;, through the UI, where you pick the DEE, supply parameters, and execute it directly.&lt;/li&gt;
&lt;li&gt;And &lt;strong&gt;from code&lt;/strong&gt;, when you need to invoke one explicitly:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;var serviceProvider = (IServiceProvider)Input[&amp;#34;ServiceProvider&amp;#34;];
Cmf.Foundation.Common.Abstractions.IAction deeRule =
    serviceProvider.GetService&amp;lt;Cmf.Foundation.Common.Abstractions.IAction&amp;gt;();
deeRule.Load(&amp;#34;CustomDEEAction&amp;#34;);
List&amp;lt;KeyValuePair&amp;lt;string, object&amp;gt;&amp;gt; parameters = new List&amp;lt;KeyValuePair&amp;lt;string, object&amp;gt;&amp;gt;();
if (this.Material != null)
{
    parameters.Add(new KeyValuePair&amp;lt;string, object&amp;gt;(&amp;#34;Material&amp;#34;, this.Material));
}
deeRule.Execute(parameters);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Other uses
&lt;div id="other-uses" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#other-uses" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;DEEs aren&amp;rsquo;t confined to Action Groups. They surface throughout MES: Checklists, Event Rules, Timers, Smart Table validations, Label Printing, Future Actions, Sort Rules, Maintenance Plans, Business Workflows, Automation Scheduled Action, and more. If you&amp;rsquo;ve built a Business Workflow, you&amp;rsquo;ve already created a DEE without necessarily realizing it.&lt;/p&gt;
&lt;p&gt;That last one is worth seeing directly. Configure a workflow that, say, automatically moves a material to the next step whenever it changes into a specific step name — and MES generates the DEE for you behind the scenes, wired up to all the relevant Action Groups automatically. Open the generated code and you&amp;rsquo;ll notice it skips &lt;code&gt;UseReference&lt;/code&gt; entirely: everything it touches is product assembly and product namespace, so there&amp;rsquo;s nothing to declare. It&amp;rsquo;s a useful reference for what &amp;ldquo;clean&amp;rdquo; DEE code looks like when you don&amp;rsquo;t need any project-specific references at all.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Business Workflow"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/BusinessWorkflow.png"
&gt;&lt;/figure&gt;
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Business Workflow DEE"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/BusinessWorkflowDEE.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Classification: Be specific
&lt;div id="classification-be-specific" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#classification-be-specific" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The classification field is easy to overlook, plenty of engineers never touch it, but it matters the moment a customer is autonomous enough to build their own Rules. Tag a DEE with a free-text classification, say &lt;code&gt;FreeWork&lt;/code&gt;, and then scope a Rule to only display DEEs with that same classification. When the user goes to build the rule themselves, they only see the options that are actually relevant to it, instead of scrolling through every DEE in the system looking for the right one. It&amp;rsquo;s a small feature, but it&amp;rsquo;s the difference between &amp;ldquo;the user can safely self-serve&amp;rdquo; and &amp;ldquo;the user picks the wrong DEE because nothing stopped them.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;There are also classifications that are important like, for executing DEEs from ConnectIoT they need to be with that scope.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Behind the Scenes: Versioning, Storage, and Caching
&lt;div id="behind-the-scenes-versioning-storage-and-caching" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#behind-the-scenes-versioning-storage-and-caching" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Every save to a DEE creates a new version rather than overwriting the last one.&lt;/p&gt;
&lt;p&gt;That gives you &lt;strong&gt;rollback&lt;/strong&gt;, &lt;strong&gt;comparison between versions&lt;/strong&gt;, and &lt;strong&gt;history&lt;/strong&gt;, a clear answer to &amp;ldquo;who changed this, when, and what exactly changed&amp;rdquo;. A question every implementation team has had to answer under pressure at some point. Setting a different version as effective is a straightforward state flip between records; nothing destructive happens to the versions you&amp;rsquo;re not using.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Comparison DEE"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/ComparisonDEE.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Underneath, DEEs live in their own database schema, aptly named &lt;code&gt;DEE&lt;/code&gt;. The core table, &lt;code&gt;Dee.T_Action&lt;/code&gt;, holds one row per version: the DEE&amp;rsquo;s name, description, the action code itself, the assembly name it compiles to, the validation code, and the validation assembly name. Every version of every DEE, product or custom, it makes no distinction, lives in this same table and goes through the same caching pipeline.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE Table Schema"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEETableSchema.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Loading works differently than most engineers assume. Every DEE is loaded into cache when the host starts, not lazily, all of them, up front. When someone changes a DEE&amp;rsquo;s effective version or its Action Group associations, the cache detects the assembly name has changed and reloads just that entry. What actually sits in cache is exactly what&amp;rsquo;s in the database table: name, action code, validation code, effective version. No compiled artifact yet.&lt;/p&gt;
&lt;p&gt;The DLL only gets compiled — and written to a temporary folder inside the host — the first time the DEE actually &lt;em&gt;executes&lt;/em&gt;, not when it&amp;rsquo;s imported and not when master data is loaded. This answers a question that came up directly in the session: deploying a package that reinstalls every DEE version, even ones that haven&amp;rsquo;t functionally changed, does not multiply the number of DLLs sitting around. You accumulate rows in a table; you don&amp;rsquo;t accumulate compiled artifacts until something actually runs.&lt;/p&gt;
&lt;h2 class="relative group"&gt;DEEs vs Services: Choosing the Right Tool
&lt;div id="dees-vs-services-choosing-the-right-tool" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dees-vs-services-choosing-the-right-tool" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is the recurring architectural fork: extend the product through a DEE, or build a proper service. Both are valid, and the right choice depends on scale and who needs to touch the logic afterward.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;DEEs&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Services&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only way to extend product services at all; highly reusable for small blocks; enable/disable straight from the UI; flexible updates without a new package delivery; easier to debug on the client side&lt;/td&gt;
&lt;td&gt;Better for large or complex codebases; cleaner for end users (logic hidden from the UI); less prone to unsynced client-side edits; easier to track execution history; supports service-level security&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client-side changes can get lost if not synced back to the repository; not ideal for large or complex logic; performance overhead on first execution; harder to track execution history; no service-level security for individual DEEs; vulnerable to breaking silently if the product renames namespaces or classes&lt;/td&gt;
&lt;td&gt;Requires a new package delivery for every change; harder to debug directly on the client side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The practical middle ground the session landed on: DEEs are the only lever you have for extending product services, for that use case there&amp;rsquo;s no real decision to make. For everything else, size and ownership decide it. Large, structured logic that benefits from real code organization belongs in a service. Small, toggle-able, client-adjustable logic belongs in a DEE.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why You Can&amp;rsquo;t See DEE Execution Time in Service Performance
&lt;div id="why-you-cant-see-dee-execution-time-in-service-performance" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-you-cant-see-dee-execution-time-in-service-performance" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is the practical cost of the DEE/Service trade-off that catches people off guard: history tables tell a very different story depending on which one you used.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;DEEs&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Services&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service History entry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Execute Action&lt;/code&gt; — the same name every time, regardless of which DEE ran&lt;/td&gt;
&lt;td&gt;The actual service name (e.g. &lt;code&gt;TrackInMaterial&lt;/code&gt;, &lt;code&gt;ComplexDispatchAndTrackInMaterial&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where to find real detail&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbo.T_OperationHistory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbo.T_ServiceHistory&lt;/code&gt; (already shows it)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Because every DEE shows up in &lt;code&gt;T_ServiceHistory&lt;/code&gt; as a generic &lt;code&gt;Execute Action&lt;/code&gt;, the history UI will show as a ExecuteAction which you will then have to drill down to check what was the DEE action called.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Service Performance&lt;/strong&gt; report, which without observability can sometimes be helpful to out what are the services taking with a worse performance, is blind to what DEE action is the actual bottleneck. The user will have to the go into &lt;code&gt;T_OperationHistory&lt;/code&gt; and join the two tables to get a real answer. That&amp;rsquo;s a genuine operational disadvantage of DEEs worth knowing about before you lean on one for something performance-sensitive.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE History"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEEHistory.png"
&gt;&lt;/figure&gt;
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE History2"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEEHistory_2.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Best Practices That Actually Matter
&lt;div id="best-practices-that-actually-matter" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#best-practices-that-actually-matter" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Try/Catch Has Limits You Don&amp;rsquo;t Control
&lt;div id="trycatch-has-limits-you-dont-control" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#trycatch-has-limits-you-dont-control" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Catching an exception inside a DEE and quietly moving on works fine, right up until the method you called wraps its body in &lt;code&gt;StartMethod&lt;/code&gt; / &lt;code&gt;EndMethod&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Those two calls maintain an internal stack of every start and end call made during the transaction. Swallow an exception thrown from inside that wrapped method, and the stack ends up with more starts than ends. That mismatch is exactly the cryptic error message a lot of engineers have hit without ever understanding where it came from.&lt;/p&gt;
&lt;p&gt;You can catch and re-throw a different, friendlier exception, that&amp;rsquo;s fine, because the transaction still fails the way the product expects. What you can&amp;rsquo;t do is catch and &lt;em&gt;suppress&lt;/em&gt; an exception coming out of a product method that uses &lt;code&gt;StartMethod&lt;/code&gt;/&lt;code&gt;EndMethod&lt;/code&gt;, and expect the transaction to close out cleanly. The validation happens at the end of the transaction, and it will catch the mismatch even if your code didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="DEE Throw"
src="https://image.j-roque.com/posts/20250821-howdodeeswork/DEE_Throw.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Transactions Don&amp;rsquo;t Roll Back the Outside World
&lt;div id="transactions-dont-roll-back-the-outside-world" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#transactions-dont-roll-back-the-outside-world" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The assumption &amp;ldquo;if something fails, everything gets rolled back&amp;rdquo; is true only for what&amp;rsquo;s inside the database transaction. Anything that already crossed a system boundary is permanent the moment it happens, transaction outcome notwithstanding.&lt;/p&gt;
&lt;p&gt;A DEE on &lt;code&gt;CreateMaterial.Post&lt;/code&gt; sends a message to an ERP system, then goes on to move the material to another step. If that move fails, the material creation rolls back, but the ERP already received the message. Create the material again and you&amp;rsquo;ll send that message a second time. If the ERP isn&amp;rsquo;t built to deduplicate, you now have two systems out of sync because one of them thinks something happened twice. The same happens when you send a message bus message to Connect IoT. The machine will receive the message. If the transaction aborts, the message will have already be received by the machine.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Changes are rolled back due to failure, except the ones that already crossed a system boundary. Everything inside the transaction is undone; everything the outside world already saw stays true.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Where possible, perform the irreversible, externally-visible action &lt;em&gt;last&lt;/em&gt; in the sequence, so a failure means nothing external happened yet. Where that&amp;rsquo;s not possible — timers, integration entries, anything asynchronous by nature — agree on a message format with a unique identifier the receiving system can use to ignore duplicates.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Keep Your Output Objects Thin
&lt;div id="keep-your-output-objects-thin" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#keep-your-output-objects-thin" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The output object a DEE returns gets serialized into JSON at the end of execution. If that output includes an entity like &lt;code&gt;Material&lt;/code&gt; with every property loaded — product, steps, facility, area, calendars, all of it — you&amp;rsquo;re paying for that JSON size whether or not the caller needs any of it. It compounds: loading a fully-populated entity is also extra database round trips you may not need in the first place.&lt;/p&gt;
&lt;p&gt;The fix is simple, load only the properties your DEE actually needs, and if you need a richer object to work with internally, build a separate object for that and keep it out of the output entirely. A good developer is a minimalist.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The tutorials and documentation get you going, but some decisions take years to come back to bite you. In this blog posts we tried to check the major hurdles and pain points and also performing a comparative analysis.&lt;/p&gt;
&lt;p&gt;After this you are ready to be even better at extending the MES system.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This blog post was based on a talk by Nicole Ribau in 2025-08-21 @CM-Portugal&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260724-howdodeeswork/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260724-howdodeeswork/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Understanding UI Pages: How They Really Work</title>
<link>https://j-roque.com/posts/20260724-howuipageswork/</link>
<pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260724-howuipageswork/</guid>
<description>Anyone can drag a widget onto a UI Page. Far fewer people wire it so it survives contact with 10,000 real records. This is what a UI Page actually is, and the mistakes that show up in nearly every implementation.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260724-howuipageswork/featured.png" alt="Understanding UI Pages: How They Really Work" /&gt;&lt;/p&gt;&lt;p&gt;UI Pages a low code designer to build your MES cockpits.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We will address what a &lt;strong&gt;UI Page&lt;/strong&gt; is made of, how its pieces connect, and through a live build of a real page where implementations quietly go wrong. First, a tour of the vocabulary (Builder, Widgets, Tasks, Properties, Links, Converters, Layouts), followed by a from-scratch build of a two-level grid, and closing with a set of best practices earned the hard way, on real projects.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Four Kinds of UI Page
&lt;div id="the-four-kinds-of-ui-page" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-four-kinds-of-ui-page" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;UI Page&lt;/strong&gt; is a configurable entity, built from widgets, properties, and data sources, that displays information and reacts to user input and system triggers.&lt;/p&gt;
&lt;p&gt;MES supports four shapes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cluster&lt;/strong&gt; — a navigation panel on the left, a detail panel on the right. Think of the Step View or the Material view: click an item on the left, its details render on the right.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Page&lt;/strong&gt; — the most common type. A blank canvas you populate by dragging and dropping elements to build interaction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wizard&lt;/strong&gt; — a sequence of steps that collects and prepares data before handing it off to a single transaction service call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step&lt;/strong&gt; — a custom, reusable unit you build once and drop into any Wizard.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;The Vocabulary of the Builder
&lt;div id="the-vocabulary-of-the-builder" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-vocabulary-of-the-builder" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Builder&lt;/strong&gt; is the design surface itself: a palette of widgets on one side, a canvas to drop them on, and a mechanism — &lt;strong&gt;Links&lt;/strong&gt; — to wire them together.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Builder"
src="https://image.j-roque.com/posts/20260724-howuipageswork/builder.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;Widget&lt;/strong&gt; is any visual element that takes input from, or shows output to, the user. Widgets connect to other elements (most commonly a data source) and carry their own individual configuration.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Widget"
src="https://image.j-roque.com/posts/20260724-howuipageswork/widget.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;Task&lt;/strong&gt;, what used to be called a Data Source in earlier versions, is how a UI Page actually fetches data to feed its widgets. As of v11, Tasks are grouped into distinct categories: &lt;code&gt;Data Source&lt;/code&gt;, &lt;code&gt;Transaction&lt;/code&gt; (Business Workflows, service calls), &lt;code&gt;Core&lt;/code&gt;, &lt;code&gt;KPI&lt;/code&gt;, &lt;code&gt;Area&lt;/code&gt;, &lt;code&gt;Material&lt;/code&gt;, and &lt;code&gt;UI Page&lt;/code&gt; (for publish/subscribe behavior between pages).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Task"
src="https://image.j-roque.com/posts/20260724-howuipageswork/task.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;Property&lt;/strong&gt; belongs to the page itself and persists across saves — effectively a page-scoped variable used to initialize, receive, or store data that needs to move between elements.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Property"
src="https://image.j-roque.com/posts/20260724-howuipageswork/properties.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;Action Button&lt;/strong&gt; lives on the page&amp;rsquo;s ribbon rather than the canvas, but is configured exactly like a Button widget. And a &lt;strong&gt;Link&lt;/strong&gt; is the wiring that makes all of the above talk to each other: connect one component&amp;rsquo;s output to another&amp;rsquo;s input, and the page reacts.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Action Button"
src="https://image.j-roque.com/posts/20260724-howuipageswork/actionbutton.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Converters: Bridging Type Mismatches
&lt;div id="converters-bridging-type-mismatches" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#converters-bridging-type-mismatches" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Links only work cleanly when the output type on one side matches the input type on the other. When they don&amp;rsquo;t, for example a widget outputs a string, but the target expects a boolean, a &lt;strong&gt;Converter&lt;/strong&gt; sits in between and reshapes the value.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Converter"
src="https://image.j-roque.com/posts/20260724-howuipageswork/converter.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Version 11 organizes converters into categories, and the walkthrough of the documentation surfaced a handful worth knowing by name rather:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Transformation&lt;/strong&gt; — e.g. &lt;code&gt;AnyToAnyProperty&lt;/code&gt;, which pulls a single named property off an object (grab a row&amp;rsquo;s &lt;code&gt;Id&lt;/code&gt; column, for instance), and &lt;code&gt;AnyToEmptyArray&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Object Handling&lt;/strong&gt; — array length, filter value, map value — the converters you reach for constantly when shaping a payload before it hits a DEE.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logical&lt;/strong&gt; — &lt;code&gt;IsEqual&lt;/code&gt;, &lt;code&gt;IsFalse&lt;/code&gt;, &lt;code&gt;IsNotNull&lt;/code&gt;, and similar boolean operators, usable directly without writing an expression.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entity&lt;/strong&gt; — most notably &lt;code&gt;LoadEntity&lt;/code&gt;, which takes an ID and resolves the full entity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource&lt;/strong&gt; and &lt;strong&gt;Material&lt;/strong&gt; converters, and more specialized ones layered on top of those.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Later on we will use &lt;code&gt;AnyToAnyProperty&lt;/code&gt; to real use twice.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Layouts and Overriding Native UI Actions
&lt;div id="layouts-and-overriding-native-ui-actions" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#layouts-and-overriding-native-ui-actions" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;Layout&lt;/strong&gt; governs how a page&amp;rsquo;s disposition changes with screen size — how many columns the Builder shows, whether side panels are visible, and how they&amp;rsquo;re arranged. New layouts aren&amp;rsquo;t hardcoded; they&amp;rsquo;re added as entries in the native &lt;code&gt;UIPageLayouts&lt;/code&gt; smart table, where you set a description and the column configuration to display in the Builder.&lt;/p&gt;
&lt;p&gt;Native UI Actions, the built-in views the product ships, like the Fab Explorer&amp;rsquo;s step view, can be overridden the same declarative way, through entries in the &lt;code&gt;UIPageContext&lt;/code&gt; smart table, keyed by the action&amp;rsquo;s identifier.&lt;/p&gt;
&lt;p&gt;Create a custom page, add an entry in &lt;code&gt;UIPageContext&lt;/code&gt; pointing the native step-view action at it, set the custom page as effective, and refreshing Fab Explorer now renders the custom page instead of the product default. Worth flagging: not every native UI Action can be fully replaced this way, some resist a complete override.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Override Product Page"
src="https://image.j-roque.com/posts/20260724-howuipageswork/override_product_page.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Presets: One Page, Many Contexts
&lt;div id="presets-one-page-many-contexts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#presets-one-page-many-contexts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;UI Page Presets&lt;/strong&gt; solve a specific, recurring problem: you want the same page reused across many contexts, each with different data, without building a dedicated page per context. The classic case is a dashboard button injected per-resource, same page design, different resource passed through as context depending on which button was clicked.&lt;/p&gt;
&lt;p&gt;In the demo we will build exactly that: a preset scoped to &lt;code&gt;Menu Entry&lt;/code&gt;, targeting the Manufacturing page, configured to inject one quick-access button per resource. Each button, when clicked, passes that resource&amp;rsquo;s identity into the page as context; a &lt;code&gt;GetObjectByName&lt;/code&gt; service-call Task resolves the full entity from that name, and the result feeds an Entity Details widget. Critically, each injected button can be scoped independently by role — in the demo, one resource&amp;rsquo;s button was restricted to a &lt;code&gt;Cookie Manager&lt;/code&gt; role while another was left open to all users.&lt;/p&gt;
&lt;p&gt;The advantage over the old approach is real: previously, supporting this pattern meant a dedicated page per context, built and wired by hand. With presets, you build the page once, and the preset configuration decides how many buttons appear and who can see each one, no additional customization per resource.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Presets"
src="https://image.j-roque.com/posts/20260724-howuipageswork/presets.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Anatomy of a Real UI Page: Building a Two-Level Sliding Grid
&lt;div id="anatomy-of-a-real-ui-page-building-a-two-level-sliding-grid" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#anatomy-of-a-real-ui-page-building-a-two-level-sliding-grid" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s imagine the user wants have a list of filters (container, facility, state) and a list view with two levels: Container View and Material By Slot Number. The user must be able to view the container detail info in the right side (Name; Orientation, Used Positions). We will add a sliding grid to show, green if the percentage is above 50% and red if it&amp;rsquo;s less.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Level 0: filters, a query, and clean naming
&lt;div id="level-0-filters-a-query-and-clean-naming" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#level-0-filters-a-query-and-clean-naming" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The first widget dropped onto the canvas was a Filter widget — deliberately renamed to &lt;code&gt;ContainerFilterWidget&lt;/code&gt; rather than left at its default &lt;code&gt;FilterWidget1234&lt;/code&gt;. That naming discipline isn&amp;rsquo;t cosmetic; it&amp;rsquo;s what keeps a page&amp;rsquo;s link graph legible once it has more than a handful of widgets on it.&lt;/p&gt;
&lt;p&gt;The Sliding Grid itself was configured in lazy (&amp;ldquo;on demand&amp;rdquo;) mode, with two levels defined and single-selection mode enabled — a direct consequence of the requirement that selecting a container has to populate the right panel. A Query task, &lt;code&gt;GetContainerInfo&lt;/code&gt;, supplied the first level&amp;rsquo;s columns (name, orientation, user position, total position), and the Filter widget&amp;rsquo;s output was linked straight into the query&amp;rsquo;s filter input, no converter needed, since both sides were already the same object type.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When creating task query make sure you select the appropriate retrieval time (Retrieve Data on Start/ Retrieve Data on Changes), as this impacts the responsiveness of the page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Build level 0"
src="https://image.j-roque.com/posts/20260724-howuipageswork/build_UI_0.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Adding Filters"
src="https://image.j-roque.com/posts/20260724-howuipageswork/adding_filters.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Level 1: a DEE as a data source, and the converter that fixes it
&lt;div id="level-1-a-dee-as-a-data-source-and-the-converter-that-fixes-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#level-1-a-dee-as-a-data-source-and-the-converter-that-fixes-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The second level deliberately used a different kind of Task, a &lt;strong&gt;DEE Action&lt;/strong&gt; instead of a query, specifically to show both paths side by side. The DEE, already written beforehand, took a container ID as input and returned a data table of the materials, products, flows, and steps associated with it.&lt;/p&gt;
&lt;p&gt;Wiring it up surfaced two real, live bugs, the kind every implementation runs into at least once.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First bug:&lt;/strong&gt; clicking a level-0 row triggered the level-1 fetch, but the DEE failed immediately. Opening the browser dev tools showed why: instead of receiving the container&amp;rsquo;s ID, the DEE was receiving the &lt;em&gt;entire row object&lt;/em&gt; from the sliding grid&amp;rsquo;s &amp;ldquo;fetch level&amp;rdquo; event. The fix was a converter — &lt;code&gt;AnyToAnyProperty&lt;/code&gt; — inserted on that link, extracting just the row&amp;rsquo;s &lt;code&gt;Id&lt;/code&gt; field, then a &lt;code&gt;SetMapValue&lt;/code&gt; converter injecting it into the DEE&amp;rsquo;s input as a new key, &lt;code&gt;ContainerId&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second bug:&lt;/strong&gt; with the ID now flowing correctly, the DEE executed successfully — visibly, in the dev tools&amp;rsquo; network response — but the grid still rendered no rows. The mismatch here was shape, not identity: the DEE returned a dictionary, while the Sliding Grid expected an array of a specific type. Same converter category solved it — another &lt;code&gt;AnyToAnyProperty&lt;/code&gt;, this time pulling the &lt;code&gt;Result&lt;/code&gt; field out of the dictionary into the shape the grid needed.&lt;/p&gt;
&lt;p&gt;Neither bug was exotic. Both are the direct, unglamorous consequence of connecting two components that don&amp;rsquo;t share a type — which is the entire reason Converters exist as a first-class concept in the Builder rather than an escape hatch.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Build level 1"
src="https://image.j-roque.com/posts/20260724-howuipageswork/level1_UI.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;The right panel: entity details wired to selection
&lt;div id="the-right-panel-entity-details-wired-to-selection" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-right-panel-entity-details-wired-to-selection" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The right-hand panel used an Entity Details widget bound to a Property — &lt;code&gt;Container&lt;/code&gt;, typed as a reference to the Container entity. Selecting a row on the Sliding Grid fires a selection-changed event, which writes the selected container into that property; the property, in turn, pushes straight through to the Entity Details widget. No query, no DEE, no round-trip, the selected row &lt;em&gt;is&lt;/em&gt; the entity, just handed off through a property acting as a pipe.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Right Panel"
src="https://image.j-roque.com/posts/20260724-howuipageswork/level2_rightpanel.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;The pagination bug hiding behind a page that &amp;ldquo;looked done&amp;rdquo;
&lt;div id="the-pagination-bug-hiding-behind-a-page-that-looked-done" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-pagination-bug-hiding-behind-a-page-that-looked-done" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;At this point the page rendered correctly, filtered correctly, drilled down correctly, and populated the right panel correctly. But it can have a serious problem: the grid&amp;rsquo;s pagination was happening entirely client-side.&lt;/p&gt;
&lt;p&gt;That means on first load, the query fetches &lt;em&gt;every matching row&lt;/em&gt;, and the browser slices it into pages after the fact. With ten rows in a demo, that&amp;rsquo;s invisible. With three thousand, five thousand, or ten thousand containers, which is the actual scale most of these grids run at in production, that&amp;rsquo;s a page load that silently pulls the entire table across the wire before showing you the first ten rows.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A grid that &amp;ldquo;just works&amp;rdquo; against ten demo rows can still be pulling the entire table on every load — the browser hides that cost by paginating locally, right up until someone opens it against real production data.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The fix: add two integer properties to the page, &lt;code&gt;PageNumber&lt;/code&gt; and &lt;code&gt;PageSize&lt;/code&gt;. Wire the Sliding Grid&amp;rsquo;s page-change and page-size-change events into those properties, and wire those same properties &lt;em&gt;forward&lt;/em&gt; into the query as filter parameters, so the query itself only ever asks the database for the page currently being viewed. The query&amp;rsquo;s total-record count feeds back into the grid so it can render the correct number of pages. Get this chain right, and the database only ever returns the rows actually on screen; get it wrong, and the grid is a convenient UI sitting on top of an unbounded query.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Server Side Loading"
src="https://image.j-roque.com/posts/20260724-howuipageswork/level2_serversideloading.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Template columns without hardcoded styles
&lt;div id="template-columns-without-hardcoded-styles" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#template-columns-without-hardcoded-styles" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The last piece of the requirement, a capacity column rendered as a green or red progress indicator using a custom column template with conditional syntax referencing the row&amp;rsquo;s data (&lt;code&gt;user positions&lt;/code&gt; over &lt;code&gt;total positions&lt;/code&gt;, colored green above 50%, red at or below it). The one deliberate choice worth calling out: the demo used the product&amp;rsquo;s existing CSS classes and CSS variables for the coloring, rather than hardcoded style values. Hardcoded styles silently diverge the moment the page renders under a different visual theme; variables inherit whatever theme is active automatically.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Custom Column"
src="https://image.j-roque.com/posts/20260724-howuipageswork/level2_customcolumn.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="HTML Code Block"
src="https://image.j-roque.com/posts/20260724-howuipageswork/html-codeblock.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Business Workflows: The Low-Code Way to Build a DEE
&lt;div id="business-workflows-the-low-code-way-to-build-a-dee" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#business-workflows-the-low-code-way-to-build-a-dee" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A Business Workflow can be standalone, or bound to an operation event (Track In &lt;code&gt;Pre&lt;/code&gt;/&lt;code&gt;Post&lt;/code&gt;, Track Out, Move Next — the native operations MES already exposes). You define its inputs — Material, Resource, whatever the scenario calls for — and drag in prebuilt building blocks like &lt;em&gt;Dispatch and Track In&lt;/em&gt;, wiring each block&amp;rsquo;s required fields back to the workflow&amp;rsquo;s custom inputs. Save it, and the product automatically generates the DEE underneath — test condition, action code, the whole apparatus — without a single line of C# written by hand. Wire an Action Button on a UI Page to trigger it, feed it Material and Resource straight from page properties, and clicking the button runs the generated DEE exactly as if someone had written it.&lt;/p&gt;
&lt;p&gt;Also, these low-code building blocks aren&amp;rsquo;t just an internal shortcut, they can be packaged and delivered to customers directly, letting their own teams compose new behavior out of pre-built, tested &amp;ldquo;Lego pieces&amp;rdquo; without opening a customization request at all. That&amp;rsquo;s the same extensibility philosophy behind DEEs in the first place, just one layer higher and without the code.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Button Biz Workflows"
src="https://image.j-roque.com/posts/20260724-howuipageswork/button_bizworkflows.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Best Practices
&lt;div id="best-practices" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#best-practices" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The closing list reads like a checklist, but every item traces back to something that actually broke on a real project:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Avoid redundant entity loads.&lt;/strong&gt; Reloading an entity through a converter when a dedicated Property already holds it triggers unnecessary, repeated loads every time an action runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Eliminate duplicate progress indicators.&lt;/strong&gt; Multiple spinners on one page read as broken to an operator, even when nothing is actually wrong. Centralize on one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use server-side pagination, not client-side&lt;/strong&gt; — the exact bug the demo walked into.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run data sources only when needed&lt;/strong&gt; — evaluate per Task whether it should trigger on load, on change, or both; triggering more than necessary directly slows the page down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use CSS variables, not hardcoded grid styles&lt;/strong&gt;, so template columns stay correct across different visual themes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Always measure performance against realistic data volumes&lt;/strong&gt;, not the handful of rows a demo happens to use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Follow a naming convention for widgets and links.&lt;/strong&gt; &lt;code&gt;ContainerFilterWidget&lt;/code&gt; beats &lt;code&gt;FilterWidget1234&lt;/code&gt; the moment a page has more than five links to untangle.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;What separates a UI Page that survives a real deployment from one that gets rebuilt six months later isn&amp;rsquo;t the widgets chosen. It&amp;rsquo;s whether pagination was wired all the way through to the query, whether the output stayed properly typed at every converter boundary, and whether &amp;ldquo;it works in the demo&amp;rdquo; was ever tested against something closer to the size of the actual database.&lt;/p&gt;
&lt;p&gt;The Builder will let you skip every one of those steps and still show you a page that looks finished. Production data is what actually checks your work.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This blog post was based on a talk by José Novo in 2025-09-11 @CM-Portugal&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260724-howuipageswork/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260724-howuipageswork/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>The Command That Makes Code Task Debt Payable</title>
<link>https://j-roque.com/posts/20260702-codetasktocustomtask/</link>
<pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260702-codetasktocustomtask/</guid>
<description>Code tasks are great for quick, atomic logic. Custom tasks are better for anything complex, reusable, or testable. This post breaks down the tradeoffs and shows how to migrate from one to the other with a single command.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260702-codetasktocustomtask/featured.png" alt="The Command That Makes Code Task Debt Payable" /&gt;&lt;/p&gt;&lt;p&gt;There&amp;rsquo;s a little known command in the CM CLI: &lt;code&gt;cmf dev iot convertCodeTasks&lt;/code&gt;. It converts your existing code tasks into proper custom tasks automatically. This post is about why you might want that and what you gain when you do.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Code Task and How It Drifts
&lt;div id="the-code-task-and-how-it-drifts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-code-task-and-how-it-drifts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Connect IoT is a low-code designer where each functional block (task) performs an atomic action or transformation. CM ships a full suite of out-of-the-box tasks covering math operations, MES requests, driver interactions, and more. But there are always use cases that fall outside that set.&lt;/p&gt;
&lt;p&gt;The &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/reference/tasks/core/workflow/actions/task_code/"
target="_blank"
&gt;code task&lt;/a&gt; is the &lt;em&gt;escape hatch&lt;/em&gt;: write TypeScript directly, access a curated set of framework utilities and APIs, and have it available to the controller immediately — &lt;strong&gt;no build pipeline&lt;/strong&gt;, &lt;strong&gt;no deployment step&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task"
src="https://image.j-roque.com/posts/20260702-codetasktocustomtask/code_task.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    import { Framework } from &amp;#39;framework&amp;#39;;
export default class {
        /** Allows accessing external functions */
        public framework: Framework;
        constructor(framework: Framework) {
            this.framework = framework;
        }
        /*
            * Entry point of the class (IMPORTANT: don&amp;#39;t change the signature of this method)
            * Should return an object containing the values for each output to emit
            * If necessary, use the parameter &amp;#34;outputs&amp;#34; to emit data while running the code.
        */
        public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
// Add code here
// emit output during execution: outputs.output1.emit(&amp;#34;something&amp;#34;);
            // return example: return { output1: inputs.input1, output2: &amp;#34;Hello World&amp;#34; };
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;For a quick, focused snippet this is exactly right. The problem is how code tasks tend to grow over time.&lt;/p&gt;
&lt;p&gt;The typical path: one code task for a parsing edge case, another for a validation, another for an MES call, until a controller has half its logic buried in scattered TypeScript snippets that nobody wants to touch. It doesn&amp;rsquo;t happen by bad intent. It happens by convenience, one task at a time.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;As a rule of thumb: if a code task reaches the hundreds-of-lines range, it&amp;rsquo;s doing more than one thing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At that point you&amp;rsquo;ve &lt;strong&gt;lost the visual clarity&lt;/strong&gt; of the low-code designer without gaining the structure of a proper software project. And the two constraints that make it a real problem are testing and libraries.&lt;/p&gt;
&lt;p&gt;Code task logic cannot be unit-tested in isolation — you can &lt;a
href="https://j-roque.com/posts/20250516-testinglowcode/"
target="_blank"
&gt;validate the workflow&lt;/a&gt; with the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/getting-started/"
target="_blank"
&gt;TestOrchestrator&lt;/a&gt;, but you cannot write tests against the TypeScript class itself. For a small snippet, that&amp;rsquo;s fine. For complex logic spanning many branches, it becomes a liability.&lt;/p&gt;
&lt;p&gt;Code tasks also can&amp;rsquo;t import external npm packages. Every piece of logic has to be written from scratch, no battle-tested libraries, no shared utilities across tasks.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;One performance note worth keeping in mind: the code task runs inside a &lt;a
href="https://www.npmjs.com/package/vm2"
target="_blank"
&gt;sandbox&lt;/a&gt;, which carries a small startup cost. Usually negligible; in high-volume scenarios with many code tasks, it compounds.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;What a Custom Task Gives You
&lt;div id="what-a-custom-task-gives-you" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-a-custom-task-gives-you" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The alternative is a &lt;strong&gt;Custom Task&lt;/strong&gt;: a proper TypeScript project, packaged and installed like any other IoT component, indistinguishable from a built-in CM task to whoever builds workflows with it.&lt;/p&gt;
&lt;p&gt;The historical friction was real, building a custom task required juggling the Node execution layer alongside an Angular presentation layer. Version 10.2 eliminated that. With automation task libraries, the IoT customization layer no longer needs Angular code task rendering is described by a JSON metadata definition.&lt;/p&gt;
&lt;hr&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-tasks-library/"
title="Automation Task Libraries"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-tasks-library/"
title="Automation Task Libraries"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;p&gt;Creating a &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_task/"
target="_blank"
&gt;customization task&lt;/a&gt; is now fully embedded in the &lt;a
href="https://criticalmanufacturing.github.io/cli/"
target="_blank"
&gt;CM CLI&lt;/a&gt;, becoming a natural part of the development flow.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="New iot Task"
src="https://image.j-roque.com/posts/20260702-codetasktocustomtask/cmf_new_iot_task.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What you get in return for that ownership:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Full unit test coverage.&lt;/strong&gt; The logic is a real TypeScript class — test it like any other. Fast, isolated, no controller needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Native debugging and IntelliSense.&lt;/strong&gt; Standard development tooling, not workarounds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;External npm packages.&lt;/strong&gt; The entire node ecosystem is available. Beyond packages, custom tasks unlock Connect IoT&amp;rsquo;s dependency injection mechanism — instead of instantiating a shared resource in every task, you register it once and every task draws from the same instance. We covered this in depth in the SQLite series: &lt;a
href="https://j-roque.com/posts/20250617-sqlite-i/"
target="_blank"
&gt;Third-Party Components in Connect IoT&lt;/a&gt; and &lt;a
href="https://j-roque.com/posts/20250625-sqlite-ii/"
target="_blank"
&gt;Sharing Injected Components in Connect IoT&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Shared Injectable"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/sharedinjectable.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reuse without workflow coupling.&lt;/strong&gt; A code task can only be shared through a subworkflow reference, which forces an architectural decision into every workflow that needs it. Custom tasks are available wherever you define them, no coupling required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Distributable packages.&lt;/strong&gt; Custom tasks live in an installable package. If you&amp;rsquo;re building a golden template or a baseline for multiple sites, you ship one package and every project gets the same tasks, maintained in one place. That same package can also include custom converters, custom drivers, and shared injectable services.&lt;/p&gt;
&lt;p&gt;The tradeoff is ownership: when CM releases a new version, you update your custom task dependencies and validate. It&amp;rsquo;s typically straightforward, but it&amp;rsquo;s a step code task users don&amp;rsquo;t have to think about.&lt;/p&gt;
&lt;h2 class="relative group"&gt;&lt;code&gt;convertCodeTasks&lt;/code&gt; in Action
&lt;div id="convertcodetasks-in-action" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#convertcodetasks-in-action" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Seeing it Work
&lt;div id="seeing-it-work" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#seeing-it-work" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s walk through a concrete example: a test machine outputting a non-trivial report file.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-log"&gt;### MACHINE TEST REPORT ###
# Generated by AutoTestBench v3.2
Machine-ID   : MTB-2291
Machine Name : Line3-Press-07
Operator     : jsmith
Timestamp    : 2026-07-01T14:32:09Z
--- PRODUCT INFO ---
Product Name: Bracket-Assembly-X200
Material    : Al-6061-T6
Batch#: 88213
--- TEST RESULTS ---
# code | description        | value   | unit | status
T01    | Tensile Strength    | 310.5   | MPa  | PASS
T02    | Yield Strength      | 275.2   | MPa  | PASS
T03    | Elongation          | 12.8    | %    | FAIL
T04    | Hardness            | 95      | HB   | PASS
OVERALL_RESULT: FAIL
CHECKSUM=9f3a21
### END OF REPORT ###&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A common request would be to extract fields like the Material, Product Name, Machine Name and test result.&lt;/p&gt;
&lt;p&gt;The developer could first use the file raw business scenario to create a template for file integrations.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="File Raw Business Scenario"
src="https://image.j-roque.com/posts/20260702-codetasktocustomtask/file_raw_bizscenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The file isn&amp;rsquo;t CSV or JSON — it&amp;rsquo;s a machine-specific format, common for test equipment. We parse it directly in the workflow with a code task.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    import { Framework } from &amp;#39;framework&amp;#39;;
interface TestRow {
    code: string;
    description: string;
    value: string;
    unit: string;
    status: string;
    }
interface ParsedTestFile {
    machineName: string;
    productName: string;
    materialName: string;
    overallResult: string;
    testResults: TestRow[];
    }
export default class {
/** Allows accessing external functions */
    private framework: Framework;
constructor(framework: Framework) {
        this.framework = framework;
    }
/**
     * Pulls a single &amp;#34;Label : value&amp;#34; style field out of the raw text.
     * Tolerant of variable whitespace around the colon and label casing.
     */
    private extractField(raw: string, label: string): string {
        const regex = new RegExp(`^${label}\\s*:\\s*(.&amp;#43;)$`, &amp;#39;im&amp;#39;);
        const match = raw.match(regex);
        return match ? match[1].trim() : &amp;#39;&amp;#39;;
    }
/**
     * Parses the pipe-delimited results table between
     * &amp;#34;--- TEST RESULTS ---&amp;#34; and the next blank line / section.
     */
    private extractTestResults(raw: string): TestRow[] {
        const rows: TestRow[] = [];
const sectionMatch = raw.match(/---\s*TEST RESULTS\s*---([\s\S]*?)(?:\n\s*\n|OVERALL_RESULT)/i);
        if (!sectionMatch) return rows;
const lines = sectionMatch[1]
        .split(&amp;#39;\n&amp;#39;)
        .map(l =&amp;gt; l.trim())
        .filter(l =&amp;gt; l.length &amp;gt; 0 &amp;amp;&amp;amp; !l.startsWith(&amp;#39;#&amp;#39;));
for (const line of lines) {
        const cols = line.split(&amp;#39;|&amp;#39;).map(c =&amp;gt; c.trim());
        if (cols.length &amp;lt; 5) continue; // skip malformed rows
const [code, description, value, unit, status] = cols;
        rows.push({ code, description, value, unit, status });
        }
return rows;
    }
/**
     * Entry point of the class (IMPORTANT: don&amp;#39;t change the signature of this method)
     * Should return an object containing the values for each output to emit
     * If necessary, use the parameter &amp;#34;outputs&amp;#34; to emit data while running the code.
     */
    public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
        const rawContent: string = inputs.fileContent ?? &amp;#39;&amp;#39;;
if (!rawContent) {
        throw new Error(&amp;#39;No file content provided in inputs.fileContent&amp;#39;);
        }
const machineName = this.extractField(rawContent, &amp;#39;Machine Name&amp;#39;);
        const productName = this.extractField(rawContent, &amp;#39;Product Name&amp;#39;);
        const materialName = this.extractField(rawContent, &amp;#39;Material&amp;#39;);
        const overallResult = this.extractField(rawContent, &amp;#39;OVERALL_RESULT&amp;#39;);
        const testResults = this.extractTestResults(rawContent);
const parsed: ParsedTestFile = {
        machineName,
        productName,
        materialName,
        overallResult,
        testResults,
        };
outputs.result.emit(parsed);
    }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Scenario"
src="https://image.j-roque.com/posts/20260702-codetasktocustomtask/file_raw_codetask.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;By running it, we see that it&amp;rsquo;s working as expected. This is still a fairly contained scenario, but it&amp;rsquo;s already a few dozen lines of TypeScript.&lt;/p&gt;
&lt;p&gt;Now let&amp;rsquo;s push it further: add a retrieve from the persistency layer, an MES service call to record defects, and some guard clauses.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
    public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
        const rawContent: string = inputs.fileContent ?? &amp;#39;&amp;#39;;
if (!rawContent) {
            throw new Error(&amp;#39;No file content provided in inputs.fileContent&amp;#39;);
        }
const machineName = this.extractField(rawContent, &amp;#39;Machine Name&amp;#39;);
        const productName = this.extractField(rawContent, &amp;#39;Product Name&amp;#39;);
        const materialName = this.extractField(rawContent, &amp;#39;Material&amp;#39;);
        const overallResult = this.extractField(rawContent, &amp;#39;OVERALL_RESULT&amp;#39;);
        const testResults = this.extractTestResults(rawContent);
const expectedMaterialName = await this.framework.dataStore.retrieve(&amp;#34;materialName&amp;#34;, &amp;#34;&amp;#34;);
        const expectedProductName = await this.framework.dataStore.retrieve(&amp;#34;productName&amp;#34;, &amp;#34;&amp;#34;);
if(materialName != expectedMaterialName &amp;amp;&amp;amp;
            productName!= expectedProductName) {
            throw new Error(`Invalid Material ${materialName} and Product ${productName}`);
        }
const parsed: ParsedTestFile = {
            machineName,
            productName,
            materialName,
            overallResult,
            testResults,
        };
this.framework.logger.info(`Overall Test Result for ${materialName} - ${overallResult}`);
if (overallResult == &amp;#34;FAIL&amp;#34;) {
const recordDefect = new this.framework.LBOS.Cmf.Navigo.BusinessOrchestration.MaterialManagement.InputObjects.RecordMaterialDefectsInput();
            recordDefect.Material = new this.framework.LBOS.Cmf.Navigo.BusinessObjects.Material();
            recordDefect.Material.Name = machineName;
            recordDefect.MaterialDefects = new this.framework.LBOS.Cmf.Navigo.BusinessObjects.MaterialDefectCollection();
for (const result of testResults) {
            if (result.status == &amp;#34;FAIL&amp;#34;) {
                const defect = new this.framework.LBOS.Cmf.Navigo.BusinessObjects.MaterialDefect();
                defect.DefectSource = this.framework.LBOS.Cmf.Navigo.BusinessObjects.MaterialDefectSource.None;
                defect.DefectType = this.framework.LBOS.Cmf.Navigo.BusinessObjects.MaterialDefectDefectType.Unit;
                defect.Reason = new this.framework.LBOS.Cmf.Navigo.BusinessObjects.Reason();
                defect.Reason.Name = result.code;
recordDefect.MaterialDefects.push(defect);
this.framework.logger.debug(`Adding Defect for ${materialName} : ${result.code}`);
            }
            }
await this.framework.system.call(recordDefect);
            this.framework.logger.info(`Added Defects for ${materialName}`);
        }
outputs.result.emit(parsed);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the code has real branching, class instantiation, and business logic. This is exactly the inflection point, still manageable today, painful in six months. This is where &lt;code&gt;convertCodeTasks&lt;/code&gt; becomes relevant.&lt;/p&gt;
&lt;p&gt;We run the command against the folder containing our workflows:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cmf dev iot convertCodeTasks Cmf.Custom.MESProject.IoT.Data/AutomationWorkFlows&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command nests the output under a folder named after each workflow. In our case we&amp;rsquo;re converting everything in the &lt;code&gt;NewFiles.json&lt;/code&gt; workflow, so the output lands in a &lt;code&gt;NewFiles&lt;/code&gt; folder. From there we add it to a custom IoT project, install, and compile.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Convert Code Task"
src="https://image.j-roque.com/posts/20260702-codetasktocustomtask/convert_codetask.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;With one command the code task is now a proper custom task — IntelliSense, external dependencies, unit testability, and full ownership of the code as a real project.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The conversion is best-effort and will flag any edge cases it can&amp;rsquo;t handle automatically.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Code tasks and custom tasks aren&amp;rsquo;t in competition. Code tasks are the right tool for quick, atomic, self-contained logic — especially when you want zero maintenance overhead on version updates. Custom tasks are the right tool when the logic is complex, reusable, or needs to be tested and shared.&lt;/p&gt;
&lt;p&gt;The problem has never been using code tasks. It&amp;rsquo;s been letting them grow beyond the role they were designed for, and feeling stuck there because migration seemed expensive.&lt;/p&gt;
&lt;p&gt;That excuse no longer holds.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260702-codetasktocustomtask/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260702-codetasktocustomtask/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Making the MES UI yours: Dynamically Injecting Wizard Steps and Entity Pages</title>
<link>https://j-roque.com/posts/20260701-opcuauiinteraction/</link>
<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260701-opcuauiinteraction/</guid>
<description>How to dynamically inject new steps into existing wizards and new tabs into entity pages using the MES customization model.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260701-opcuauiinteraction/featured.png" alt="Making the MES UI yours: Dynamically Injecting Wizard Steps and Entity Pages" /&gt;&lt;/p&gt;&lt;p&gt;The world of manufacturing is not just complex, it&amp;rsquo;s highly dynamic. This means your software has to be optimized, not just for performance but also for dealing with change.&lt;/p&gt;
&lt;p&gt;This is why CM MES UI is not a closed system. The platform exposes deliberate extension points. In this post we will show two patterns, injecting our own wizard steps and entity pages by just leveraging metadata.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Full Execution"
src="https://image.j-roque.com/posts/20260701-opcuauiinteraction/full_execution.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the &lt;a
href="https://j-roque.com/posts/20260630-ephemeralcontroller/"
target="_blank"
&gt;previous post&lt;/a&gt; we built an ephemeral OPC-UA controller that browses a server&amp;rsquo;s node tree on demand.&lt;/p&gt;
&lt;p&gt;The back-end is done. Now, let&amp;rsquo;s give our user the ability to use, with just a click of a button:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;new wizard step&lt;/strong&gt; inside the existing &lt;strong&gt;Automation Driver&lt;/strong&gt; creation wizard — so users can browse the OPC-UA server and auto-populate properties, events and commands without typing node IDs by hand.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;new entity page tab&lt;/strong&gt; on the &lt;code&gt;AutomationControllerInstance&lt;/code&gt; entity — so operators and engineers can open a live OPC-UA browser directly from the instance page while the controller is running.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both are shipped as a single Angular library. No platform code changes. No forking. The entire integration lives in a metadata service.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Customization Model
&lt;div id="the-customization-model" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-customization-model" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Using the &lt;a
href="https://criticalmanufacturing.github.io/cli/02-learn/concepts/layers-packages/#3-ui"
target="_blank"
&gt;CLI&lt;/a&gt; with the &lt;code&gt;cmf new html&lt;/code&gt; it will create an angular workspace. By running &lt;a
href="https://github.com/criticalmanufacturing/ngx-schematics/tree/689ae916142f0aed23ea12eafa694100aea2c076/packages/ngx-schematics#library"
target="_blank"
&gt;ng generate library&lt;/a&gt; we can now create our customization angular library. This library will have be generated with a secondary entrypoint for the metadata. This metadata will have a &lt;code&gt;.module.ts&lt;/code&gt; and a &lt;code&gt;.service.ts&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The MES UI customization system is built around &lt;code&gt;PackageMetadata&lt;/code&gt;, generated in the &lt;code&gt;.service.ts&lt;/code&gt;. You extend it, declare what your package provides, and the platform picks it up at startup.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Injectable()
export class CustomizationOpcuaMetadataService extends PackageMetadata {
public override get packageInfo(): PackageInfo {
        return {
            name: &amp;#39;customization-opcua&amp;#39;,
            loader: () =&amp;gt; import(&amp;#39;customization-opcua&amp;#39;),
            components: [
                &amp;#39;PageOpcUaBrowserComponent&amp;#39;,
                &amp;#39;WizardAutomationDriverOpcuaStepComponent&amp;#39;,
                &amp;#39;OpcuaNodeTreeComponent&amp;#39;
            ]
        };
    }
public override get flexComponents(): FlexComponent[] { ... }
    public override get entityTypes(): EntityTypeMetadata[] { ... }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;packageInfo&lt;/code&gt; loader uses a dynamic import. This means the library is &lt;strong&gt;code-split&lt;/strong&gt; and is only fetched from the server when the user actually navigates to a feature that needs it.&lt;/p&gt;
&lt;p&gt;Two getters do the heavy lifting: &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/presentation/metadata/flex_components/"
target="_blank"
&gt;flexComponents&lt;/a&gt; for wizard injection and &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/presentation/metadata/entity_types/"
target="_blank"
&gt;entityTypes&lt;/a&gt; for entity page injection.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Injecting a Wizard Step
&lt;div id="injecting-a-wizard-step" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#injecting-a-wizard-step" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Flex components&lt;/strong&gt; are the mechanism for injecting UI elements into existing platform views. The descriptor answers three questions: &lt;em&gt;which view&lt;/em&gt;, &lt;em&gt;where inside it&lt;/em&gt;, and &lt;em&gt;what to insert&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Definition"
src="https://image.j-roque.com/posts/20260701-opcuauiinteraction/driver_definition_add.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override get flexComponents(): FlexComponent[] {
    return [
        {
            id: &amp;#39;cmf-core-controls-wizard&amp;#39;,
            context: &amp;#39;cmf-core-connect-iot-wizard-automation-driver&amp;#39;,
            subComponentsToAdd: [
                {
                    target: {
                        anchor: &amp;#39;cmf-core-controls-wizard-step&amp;#39;,
                        index: 2,
                        location: FlexComponentLocation.NextTo
                    },
                    components: [
                        {
                            component: () =&amp;gt; import(&amp;#39;customization-opcua&amp;#39;)
                                .then(m =&amp;gt; m.WizardAutomationDriverOpcuaStepComponent),
                            subComponent: &amp;#39;cmf-core-controls-wizard-step&amp;#39;
                        }
                    ]
                }
            ]
        }
    ];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Breaking this down:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The component selector of the host widget (&lt;code&gt;cmf-core-controls-wizard&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;context&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The injection context. If defined, it will only apply if the context selector is an ancestor of the component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anchor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The CSS selector of the sibling element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Position relative to matching siblings (0-based)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;location&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NextTo&lt;/code&gt; means insert as a sibling after the anchor at that index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subComponent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Angular component type that wraps the injected component inside the host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In plain terms: find the Automation Driver wizard, locate the third &lt;code&gt;wizard-step&lt;/code&gt; in it, and insert our step right after it.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Step Component
&lt;div id="the-step-component" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-step-component" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The step itself extends &lt;code&gt;CustomizableComponent&lt;/code&gt; and declares a &lt;code&gt;WizardStep&lt;/code&gt; child:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Component({
    selector: &amp;#39;customization-opcua-wizard-automation-driver-opcua-step&amp;#39;,
    standalone: true,
    imports: [CommonModule, WizardModule, PropertyEditorModule, ProgressIndicatorModule, OpcuaNodeTreeComponent],
    templateUrl: &amp;#39;./wizard-automation-driver-opcua-step.component.html&amp;#39;,
    viewProviders: [{ provide: HOST_VIEW_COMPONENT, useExisting: forwardRef(() =&amp;gt; WizardAutomationDriverOpcuaStepComponent) }]
})
export class WizardAutomationDriverOpcuaStepComponent extends CustomizableComponent {
@ViewChild(WizardStep, { static: true })
    public wizardStep: WizardStep;
public showStep = false;
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things matter here. First, the &lt;code&gt;HOST_VIEW_COMPONENT&lt;/code&gt; provider — this is how the platform discovers your component when doing dependency injection walks up the component tree. Without it, services injected at the wizard level won&amp;rsquo;t reach your step. Second, &lt;code&gt;@ViewChild(WizardStep, { static: true })&lt;/code&gt; — the platform needs to find the &lt;code&gt;WizardStep&lt;/code&gt; directive inside your component to register it in the wizard&amp;rsquo;s step list.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Conditional Visibility
&lt;div id="conditional-visibility" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#conditional-visibility" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Not every Automation Driver uses OPC-UA. Showing the step for a SECS-GEM driver would be confusing. The component detects the selected protocol at runtime and hides itself when irrelevant:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    constructor(
        viewContainerRef: ViewContainerRef,
        private readonly lbo: LboService,
        private readonly messageBus: MessageBusService,
        private readonly ngZone: NgZone,
        private readonly feedbackService: FeedbackService,
        private readonly utilService: UtilService,
        @Optional() @Inject(WizardAutomationDriver) private readonly wizard?: WizardAutomationDriver
    ) {
        super(viewContainerRef);
    }
(...)
    public async ngDoCheck(): Promise&amp;lt;void&amp;gt; {
        if (this.wizard?.instance?.AutomationProtocol != null &amp;amp;&amp;amp;
            this.wizard.instance.AutomationProtocol.Id !== this._automationProtocol?.Id) {
const getProtocol = new Cmf.Foundation.BusinessOrchestration.ConnectIoTManagement
                .InputObjects.LoadAutomationProtocolItemsInput();
            getProtocol.LevelsToLoad = 1;
            getProtocol.AutomationProtocol = new Cmf.Foundation.BusinessObjects.AutomationProtocol();
            getProtocol.AutomationProtocol.Id = this.wizard.instance.AutomationProtocol.Id;
            this._automationProtocol = (await this.lbo.call(getProtocol) as any)?.AutomationProtocol;
this.showStep = this._automationProtocol?.Package?.includes(&amp;#39;driver-opcua&amp;#39;) ?? false;
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WizardAutomationDriver&lt;/code&gt; is injected via &lt;code&gt;@Optional() @Inject(WizardAutomationDriver)&lt;/code&gt;, it is the host wizard&amp;rsquo;s data context. When the user picks a protocol, &lt;code&gt;ngDoCheck&lt;/code&gt; fires, loads the protocol entity, checks whether the package name contains &lt;code&gt;driver-opcua&lt;/code&gt;, and toggles &lt;code&gt;showStep&lt;/code&gt;. The template binds this directly to &lt;code&gt;[isHidden]=&amp;quot;!showStep&amp;quot;&lt;/code&gt; on the &lt;code&gt;wizard-step&lt;/code&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;What the Step Does
&lt;div id="what-the-step-does" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-the-step-does" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Once visible, the step lets the user enter an OPC-UA server address, optionally provide credentials (which are encrypted with the server&amp;rsquo;s public key before being sent over the message bus), and browse the node tree:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public async fetch(): Promise&amp;lt;void&amp;gt; {
        await this.ngZone.run(async () =&amp;gt; {
            (...)
            const response = await this.messageBus.sendRequest(&amp;#39;Cmf.OPCUA.Browse&amp;#39;, {
                address: this.address,
                username: this.username,
                password: this.password,
            }, 30000);
const reply = JSON.parse(response.Data) as { browse: OpcUaNodeInfo[], message: string };
            this.opcuaTree = reply.browse;
        });
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The call goes to the ephemeral OPC-UA controller built in the previous post. The result is a tree of &lt;code&gt;OpcUaNodeInfo&lt;/code&gt; objects rendered by the shared &lt;code&gt;OpcuaNodeTreeComponent&lt;/code&gt;. The user selects nodes and clicks &amp;ldquo;Add Properties&amp;rdquo;, &amp;ldquo;Add Events&amp;rdquo; or &amp;ldquo;Add Commands&amp;rdquo; — which push entries directly into the wizard&amp;rsquo;s in-progress &lt;code&gt;AutomationDriver&lt;/code&gt; instance.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    &amp;lt;button
        type=&amp;#34;button&amp;#34;
        class=&amp;#34;cmf-btn&amp;#34;
        [disabled]=&amp;#34;selectedNodes.size === 0&amp;#34;
        (click)=&amp;#34;addProperties()&amp;#34;
        i18n=&amp;#34;@@customization-opcua/wizard-automation-driver-opcua-step#ADD_PROPERTIES&amp;#34;&amp;gt;
        Add Properties
    &amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public addProperties(): void {
        const instance = this.wizard?.instance;
if (!instance.Properties) {
            instance.Properties = new Cmf.Foundation.BusinessObjects.AutomationPropertyCollection();
        }
const existingIds = new Set(instance.Properties.map(p =&amp;gt; p.DevicePropertyId));
for (const nodeId of this.selectedNodes) {
            // Skip if the property already exists. This can happen when adding events, as they reuse the same nodes as properties.
            if (existingIds.has(nodeId)) { continue; }
            const node = this.nodeMap.get(nodeId);
instance.Properties.push(this.buildProperty(node));
            existingIds.add(nodeId);
        }
        this.feedbackService.success(&amp;#39;Properties added successfully.&amp;#39;);
    }
    (...)
    private buildProperty(node: OpcUaNodeInfo): Cmf.Foundation.BusinessObjects.AutomationProperty {
        return new Cmf.Foundation.BusinessObjects.AutomationProperty({
            Name: node.displayName || node.browseName,
            DevicePropertyId: node.nodeId,
            DataType: OPCUA_TO_AUTOMATION_DATA_TYPE[node.dataType]
                ?? Cmf.Foundation.BusinessObjects.AutomationDataType.String,
            AutomationProtocolDataType: this.getAutomationProtocolDataType(node.dataType),
            IsReadable: true,
            IsWritable: false
        });
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The add properties will fetch the existing instance (in this case the instance is the Automation Driver Definition) properties and if there isn&amp;rsquo;t already a node created it will add to the current instance.&lt;/p&gt;
&lt;p&gt;The add event properties and add commands are very similar, they wil interpret the information from the opc-ua browse and add events and commands to the instance object.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Injecting an Entity Page Tab
&lt;div id="injecting-an-entity-page-tab" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#injecting-an-entity-page-tab" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The second extension point is &lt;code&gt;entityTypes&lt;/code&gt;. This is how you add a new tab (view) to an existing entity&amp;rsquo;s detail page.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Instance Polling"
src="https://image.j-roque.com/posts/20260701-opcuauiinteraction/instance_polling.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override get entityTypes(): EntityTypeMetadata[] {
    return [{
        name: &amp;#39;AutomationControllerInstance&amp;#39;,
        views: [{
            id: &amp;#39;Custom.AutomationControllerInstancePersistencyViewer&amp;#39;,
            name: &amp;#39;OPC-UA Browser&amp;#39;,
            path: &amp;#39;opc-ua-browser&amp;#39;,
            loadComponent: () =&amp;gt; import(&amp;#39;customization-opcua&amp;#39;)
                .then(m =&amp;gt; m.PageOpcUaBrowserComponent),
            type: ViewType.Simple,
            canExecute: async (entityTypeName, entityInstance, messages) =&amp;gt; {
                (...)
            }
        }]
    }];
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The structure maps directly to what the platform needs to render a tab:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The entity type this view attaches to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unique identifier for the view (used for routing and access control)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL segment appended to the entity detail route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loadComponent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lazy-loaded component factory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ViewType.Simple&lt;/code&gt; for a plain component, &lt;code&gt;ViewType.Wizard&lt;/code&gt; for a wizard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;canExecute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Async gate — hides the tab entirely if it returns &lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 class="relative group"&gt;The &lt;code&gt;canExecute&lt;/code&gt; Gate
&lt;div id="the-canexecute-gate" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-canexecute-gate" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The OPC-UA browser tab is not useful on every &lt;code&gt;AutomationControllerInstance&lt;/code&gt;. We only want it visible when the underlying &lt;code&gt;AutomationManager&lt;/code&gt; has been explicitly enabled for it. The gate loads the manager&amp;rsquo;s entity attributes and checks for the &lt;code&gt;IsOPCUABrowserEnabled&lt;/code&gt; attribute flag.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;canExecute: async (entityTypeName, entityInstance, messages) =&amp;gt; {
    const manager = entityInstance?.AutomationManager;
    if (manager == null) { return true; }
const { EntityAttributesUtil } = await import(&amp;#39;cmf-core-business-controls&amp;#39;);
    const entityAttributesUtil = this.injector.get(EntityAttributesUtil);
    const result = await entityAttributesUtil.loadEntityAttributes(manager);
    const managerWithAttributes = result?.Entity as Cmf.Foundation.BusinessObjects.AutomationManager;
return managerWithAttributes?.Attributes?.has(IS_OPCUA_BROWSER_ENABLED_ATTR)
        &amp;amp;&amp;amp; managerWithAttributes.Attributes.get(IS_OPCUA_BROWSER_ENABLED_ATTR) === true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;EntityAttributesUtil&lt;/code&gt; is itself lazily imported — no unnecessary bundle cost when the tab check runs on an instance that doesn&amp;rsquo;t even have a manager loaded.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Wiring it Together
&lt;div id="wiring-it-together" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#wiring-it-together" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The entry point is &lt;code&gt;CustomizationOpcuaMetadataModule&lt;/code&gt;. Register it in your application and the rest happens automatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@NgModule({
    providers: [provideCustomizationOpcua()]
})
export class CustomizationOpcuaMetadataModule { }
export function provideCustomizationOpcua(): EnvironmentProviders {
    return makeEnvironmentProviders([
        provideMetadata(CustomizationOpcuaMetadataService)
    ]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;provideMetadata&lt;/code&gt; registers your &lt;code&gt;PackageMetadata&lt;/code&gt; subclass with the platform&amp;rsquo;s metadata registry. At startup, the platform collects all registered metadata services and calls their getters to assemble the final routing table, flex injection map and action bar configuration.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The entire customization — two new UI surfaces, a shared tree component, and a metadata module — ships as a single versioned Angular library with zero changes to the platform itself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 class="relative group"&gt;The Page Component
&lt;div id="the-page-component" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-page-component" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;PageOpcUaBrowserComponent&lt;/code&gt; is a standalone component extending &lt;code&gt;CustomizableComponent&lt;/code&gt;. It mirrors the wizard step&amp;rsquo;s browse logic but adds a live monitoring panel on the right side of a page splitter: the user selects nodes in the tree, adds them to a monitored list, and the component polls the OPC-UA server at a configurable interval to display current tag values.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Component({
    standalone: true,
    selector: &amp;#39;customization-opcua-page-opc-ua-browser&amp;#39;,
    imports: [CommonModule, BasePageModule, ActionBarModule, /* ... */, PageSplitterModule],
    viewProviders: [{ provide: HOST_VIEW_COMPONENT, useExisting: forwardRef(() =&amp;gt; PageOpcUaBrowserComponent) }]
})
export class PageOpcUaBrowserComponent extends CustomizableComponent implements OnDestroy {
public monitoredTags: MonitoredTag[] = [];
    public isPolling = false;
    private _pollingHandle: ReturnType&amp;lt;typeof setInterval&amp;gt; | null = null;
public ngOnDestroy(): void {
        this.stopPolling();
    }
public async startPolling(): Promise&amp;lt;void&amp;gt; {
        const intervalMs = await this.pollIntervalMs();
        this._pollingHandle = setInterval(() =&amp;gt; this.pollTags(), intervalMs);
        this.isPolling = true;
    }
public stopPolling(): void {
        if (this._pollingHandle != null) {
            clearInterval(this._pollingHandle);
            this._pollingHandle = null;
        }
        this.isPolling = false;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The polling interval is read from the platform configuration path &lt;code&gt;/Cmf/System/Configuration/ConnectIoT/OPCUA/PollingInterval&lt;/code&gt;, with a fallback of 60 seconds. This means you can tune it per deployment without redeploying the customization.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Shared Building Block
&lt;div id="the-shared-building-block" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-shared-building-block" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Both components reuse &lt;code&gt;OpcuaNodeTreeComponent&lt;/code&gt;, a standalone component that wraps the platform&amp;rsquo;s &lt;code&gt;TreeViewModule&lt;/code&gt; and renders the OPC-UA node tree with checkboxes:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;export interface OpcUaNodeInfo {
    nodeId: string;
    nodeClass: string;
    browseName: string;
    displayName: string;
    dataType: string;
    value?: unknown;
    children: OpcUaNodeInfo[];
    parentNode: OpcUaNodeInfo | null;
}
@Component({
    selector: &amp;#39;customization-opcua-node-tree&amp;#39;,
    standalone: true,
    imports: [CommonModule, TreeViewModule, CheckboxModule],
})
export class OpcuaNodeTreeComponent {
@Input() set nodes(value: OpcUaNodeInfo[]) { /* rebuild tree model */ }
    @Input() selectedNodes: Set&amp;lt;string&amp;gt; = new Set();
    @Input() selectedCommands: Set&amp;lt;string&amp;gt; = new Set();
    @Input() showCheckbox: boolean = true;
    @Input() showDataType: boolean = true;
    @Input() showValue: boolean = true;
@Output() selectionChanged = new EventEmitter&amp;lt;Set&amp;lt;string&amp;gt;&amp;gt;();
    @Output() commandSelectionChanged = new EventEmitter&amp;lt;Set&amp;lt;string&amp;gt;&amp;gt;();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Having a separate component allows us to easily reuse it in different contexts, maintaining flexibility on what to show.&lt;/p&gt;
&lt;p&gt;One of the key aspect of this component and throughout this implementation is that we try as much as possible to reuse CM MES UI components. This way we can keep the look and feel of the UI and also save time in not having to build the UI from scratch.&lt;/p&gt;
&lt;p&gt;The help portal has a whole section dedicated to sandboxing the main MES UI components. For this component we are leveraging the tree view.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/11.3/Reference/DesignSystem/Components%3ETreeView"
title="Design System"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/11.3/Reference/DesignSystem/Components%3ETreeView"
title="Design System"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;    &amp;lt;cmf-core-controls-treeView
        *ngIf=&amp;#34;treeModel; else noData&amp;#34;
        [data]=&amp;#34;treeModel&amp;#34;
        [showRootNode]=&amp;#34;true&amp;#34;
        [nodeSelectionEnabled]=&amp;#34;false&amp;#34;&amp;gt;
        &amp;lt;ng-template cmf-core-controls-treeView-nodeTemplate let-item&amp;gt;
            &amp;lt;div class=&amp;#34;opcua-node-row&amp;#34;&amp;gt;
                @if(isLeaf(item)) {
                    &amp;lt;span *ngIf=&amp;#34;showValue &amp;amp;&amp;amp; item.tag?.value&amp;#34; class=&amp;#34;opcua-node-value&amp;#34; [class.opcua-node-value--changed]=&amp;#34;changedNodeIds.has(item.id)&amp;#34;&amp;gt;{{ item.tag.value }}&amp;lt;/span&amp;gt;
                    &amp;lt;span *ngIf=&amp;#34;showDataType &amp;amp;&amp;amp; item.tag?.dataType&amp;#34; class=&amp;#34;opcua-node-type&amp;#34;&amp;gt;{{ item.tag.dataType }}&amp;lt;/span&amp;gt;
                    &amp;lt;cmf-core-controls-checkbox
                        *ngIf=&amp;#34;showCheckbox &amp;amp;&amp;amp; item.tag?.parentNodeClass !== &amp;#39;Method&amp;#39;&amp;#34;
                        [value]=&amp;#34;selectedNodes.has(item.id)&amp;#34;
                        (checked)=&amp;#34;toggleSelect(item.id, true)&amp;#34;
                        (unchecked)=&amp;#34;toggleSelect(item.id, false)&amp;#34;&amp;gt;
                    &amp;lt;/cmf-core-controls-checkbox&amp;gt;
                } @else {
                    &amp;lt;cmf-core-controls-checkbox
                        *ngIf=&amp;#34;showCheckbox &amp;amp;&amp;amp; item.tag?.nodeClass === &amp;#39;Method&amp;#39;&amp;#34;
                        class=&amp;#34;opcua-command-checkbox&amp;#34;
                        [value]=&amp;#34;selectedCommands.has(item.id)&amp;#34;
                        (checked)=&amp;#34;toggleCommand(item.id, true)&amp;#34;
                        (unchecked)=&amp;#34;toggleCommand(item.id, false)&amp;#34;&amp;gt;
                    &amp;lt;/cmf-core-controls-checkbox&amp;gt;
                }
            &amp;lt;/div&amp;gt;
        &amp;lt;/ng-template&amp;gt;
    &amp;lt;/cmf-core-controls-treeView&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can check all the component details by searching in the reference documentation.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-controls/components/TreeView.html"
title="Reference Components"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-1').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-1"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-1').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-controls/components/TreeView.html"
title="Reference Components"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-1');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;p&gt;In order to populate our tree we just need to make sure we are building a &lt;a
href="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-controls/interfaces/TreeViewModel.html"
target="_blank"
&gt;TreeViewModel&lt;/a&gt; with &lt;a
href="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-controls/interfaces/TreeViewModelLeaf.html"
target="_blank"
&gt;Leafs&lt;/a&gt; and &lt;a
href="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-controls/interfaces/TreeViewModelNode.html"
target="_blank"
&gt;Nodes&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    treeModel: TreeViewModel | null = null;
    (...)
    private buildTreeModel(nodes: OpcUaNodeInfo[], openIds: Set&amp;lt;string&amp;gt; | null): TreeViewModel {
        return {
            rootNode: {
                id: &amp;#39;__opcua_root__&amp;#39;,
                name: &amp;#39;OPC-UA Server&amp;#39;,
                iconClass: &amp;#39;icon-core-et-lg-folder&amp;#39;,
                // On first load openIds is null — omit isOpen so the tree defaults to open.
                // On refresh, explicitly restore the previous state.
                ...(openIds != null &amp;amp;&amp;amp; { isOpen: openIds.has(&amp;#39;__opcua_root__&amp;#39;) }),
                children: nodes.map(n =&amp;gt; this.toTreeItem(n, openIds, null)),
            } as TreeViewModelNode,
        };
    }
private toTreeItem(node: OpcUaNodeInfo, openIds: Set&amp;lt;string&amp;gt; | null, parentNode: OpcUaNodeInfo | null): TreeViewModelNode | TreeViewModelLeaf {
        const iconClass = this.getNodeIconClass(node, parentNode);
        const tag = { ...node, parentNodeClass: parentNode?.nodeClass ?? null };
        if (node.children.length &amp;gt; 0) {
            return {
                id: node.nodeId,
                name: node.displayName || node.browseName,
                iconClass: iconClass,
                ...(openIds != null &amp;amp;&amp;amp; { isOpen: openIds.has(node.nodeId) }),
                tag,
                children: node.children.map(c =&amp;gt; this.toTreeItem(c, openIds, node)),
            } as TreeViewModelNode;
        }
        return {
            id: node.nodeId,
            name: node.displayName || node.browseName,
            iconClass: iconClass,
            tag,
        } as TreeViewModelLeaf;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In our new component we added the tree building and toggle logic, and also some visual flexibility to hide and show different parts of it. We are also leveraging the CM MES &lt;a
href="https://help.criticalmanufacturing.com/11.3/Reference/DesignSystem/Icons"
target="_blank"
&gt;icons&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The wizard step uses it with &lt;code&gt;[showValue]=&amp;quot;false&amp;quot;&lt;/code&gt; since there are no live values at driver-creation time.&lt;/p&gt;
&lt;p&gt;The page component uses it with &lt;code&gt;[showDataType]=&amp;quot;false&amp;quot;&lt;/code&gt; on the browse panel to save horizontal space. Same component, different configuration.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The two patterns shown here — &lt;code&gt;flexComponents&lt;/code&gt; for injecting into existing views, &lt;code&gt;entityTypes&lt;/code&gt; for attaching new views to entities, show how simple and extensible the UI is. The OPC-UA browser crosses wizard context injection, conditional step visibility, entity attribute gating, lazy loading, and shared component reuse, with one goal: make life simpler for the end-user.&lt;/p&gt;
&lt;p&gt;The patterns themselves are protocol-agnostic. Replace the OPC-UA message bus calls with anything else and the injection mechanics remain identical.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260701-opcuauiinteraction/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260701-opcuauiinteraction/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Ephemeral Controllers: Applying a Familiar Pattern to MES</title>
<link>https://j-roque.com/posts/20260630-ephemeralcontroller/</link>
<pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260630-ephemeralcontroller/</guid>
<description>Ephemeral Controllers: Applying a Familiar Pattern to MES.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260630-ephemeralcontroller/featured.png" alt="Ephemeral Controllers: Applying a Familiar Pattern to MES" /&gt;&lt;/p&gt;&lt;p&gt;When you need to query a third-party on demand — not maintain a persistent session — Connect IoT&amp;rsquo;s always-on model becomes the wrong tool. Here&amp;rsquo;s a pattern for bridging that gap.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral OPC-UA"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_opcua.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Critical Manufacturing offers &lt;em&gt;four&lt;/em&gt; different types of controllers out of the box: &lt;strong&gt;Connect IoT&lt;/strong&gt;, &lt;strong&gt;Factory Automation&lt;/strong&gt;, &lt;strong&gt;Data Platform&lt;/strong&gt; and &lt;strong&gt;Enterprise Integration&lt;/strong&gt;. They are split into &lt;strong&gt;two&lt;/strong&gt; big categories.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Persistent Communication&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Job Execution&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a
href="https://j-roque.com/posts/20250325-connectiotstructure/"
target="_blank"
&gt;Connect IoT&lt;/a&gt; is based on a &lt;strong&gt;persistent&lt;/strong&gt; connection to an external component. It was built to connect to external systems and have a mostly connected existence. It will link a low code controller logic to a driver connection.&lt;/p&gt;
&lt;p&gt;It is simple to understand why, if you think about machine integrations with SECS-GEM or IPC-CFX, not being connected means that something wrong has happened and is not a valid state. For example, for the REST driver we already see a shift as the definition of being connected becomes murkier.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Factory Automation&lt;/em&gt;, &lt;em&gt;Data Platform&lt;/em&gt; and &lt;em&gt;Enterprise Integration&lt;/em&gt; are &lt;strong&gt;job execution&lt;/strong&gt; engines. They have a &lt;em&gt;start&lt;/em&gt; and &lt;em&gt;end&lt;/em&gt; planned execution and they don&amp;rsquo;t have a persistent connection to any driver, they are runners of low code execution.&lt;/p&gt;
&lt;p&gt;In the Data Platform scope we are able to hook low code workflows to Data Platform IoTEvents, so whenever a new event is triggered we can run a low code workflow, we already addressed that on &lt;a
href="https://j-roque.com/posts/20260414-odata-agentic/#main-workflow"
target="_blank"
&gt;Data Platform - Agentic Use-Case&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Factory Automation is similar to the Data Platform scope but it uses Factory Automation IoTEvents.&lt;/p&gt;
&lt;p&gt;It is designed for high-automation scenarios where the system listens to factory events and orchestrates workflows across different systems in response. Unlike simpler automation, it supports hierarchical and long-running jobs whose state and context are persisted in the database, enabling robust error handling and recovery. A key use case is Fleet Management, where it coordinates automated transport systems (e.g., AMR) based on material needs determined by the MES.&lt;/p&gt;
&lt;p&gt;By having persisted jobs it supports long running jobs with multiple checkpoints and stop conditions. It also allows the user the ability to stop and restart jobs.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/monitoring/automation_factory_automation/?h=factory&amp;#43;automation"
title="Factory Automation"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/monitoring/automation_factory_automation/?h=factory&amp;#43;automation"
title="Factory Automation"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;blockquote&gt;
&lt;p&gt;Full tutorial on factory automation &lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/factory-automation/factoryautomationtransport/"
target="_blank"
&gt;Factory Automation Transport&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Ephemeral Connections
&lt;div id="ephemeral-connections" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ephemeral-connections" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Ephemeral connections (often referred as &lt;em&gt;on-demand connections&lt;/em&gt;, &lt;em&gt;lazy connections&lt;/em&gt; or &lt;em&gt;transient sessions&lt;/em&gt;) are the missing type, they solve the need to communicate with external drivers and also having this job like behavior. It is a pattern built on just being connected long enough to either receive or broadcast a message or small lifecycle.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral vs long running"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_longrunning.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In ephemeral connections the manager is deployed and all of its components are running, but disconnected. They will wait for an external input, like a message bus message to start. They will start, perform their task, reply back if so needed and return to a disconnected state.&lt;/p&gt;
&lt;h2 class="relative group"&gt;OPC-UA Browser Use Case
&lt;div id="opc-ua-browser-use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#opc-ua-browser-use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Connect IoT supports OPC-UA natively and its client is built in order to guarantee long lived connections with the OPC-UA server.&lt;/p&gt;
&lt;p&gt;One request that we had was to have the ability to browse the OPC-UA nodes. This follows the ephemeral pattern as our goal is not to maintain a long lived connection to the server but to just be connected long enough to retrieve the browse information.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral Connection"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_connection.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Managing the lifecycle
&lt;div id="managing-the-lifecycle" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#managing-the-lifecycle" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In a typical integration scenario the template setup would be to listen for an onInitialize event, configure our equipment connection and instruct the driver layer to connect.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Template Initialize"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/template_initialize.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;To create the ephemeral pattern we will not want to &lt;strong&gt;configure&lt;/strong&gt; and &lt;strong&gt;connect&lt;/strong&gt; when the driver initializes. We want to only configure the connection and perform the connect when an external input is provided.&lt;/p&gt;
&lt;p&gt;To do this we will need to change the workflow template. We will add a listener to an external message and on that message handle the configuration and connection.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Incorrect Ephemeral"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/incorrect_ephemeral.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Still this is not enough. Notice that we are performing the command to connect, but this command does not guarantee the connection was actually established. Therefore, the function that would execute logic would assume the connection was done, before the connection was actually established.&lt;/p&gt;
&lt;p&gt;In order to solve this problem we can create a new task that will not only send the connect command, but also only emit success when the state is actually changed to communicating.&lt;/p&gt;
&lt;p&gt;The task will be split into two actions.&lt;/p&gt;
&lt;p&gt;First, will request the driver to connect.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  this._logger.info(&amp;#34;Requesting driver connection...&amp;#34;);
  await this._driverProxy.connect();
  this._logger.debug(`Connect requested, waiting for &amp;#39;Communicating&amp;#39; state (timeout: ${this.connectionTimeoutKey}ms)`);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Second, it will create a listener for the driver state change. When the driver reaches communicating it will emit success. The task will also need to handle the setup, in order to correctly handle the disconnect/connect cycle.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  const currentContext = this._executionContext.current;
  this._connectionTimer = setTimeout(() =&amp;gt; {
      this._cleanup();
      const error = new Error(`ConnectionFail: Connection timeout: driver did not reach &amp;#39;Communicating&amp;#39; state within ${this.connectionTimeoutKey}ms`);
      this._logger.error(error.message);
      this.error.emit(error);
  }, this.connectionTimeoutKey);
this._stateChangeHandler = (state: string) =&amp;gt; {
      currentContext.run(() =&amp;gt; {
          this._logger.debug(`Communication state change received: &amp;#39;${state}&amp;#39;`);
          if (state === &amp;#34;Setup&amp;#34;) {
              this._logger.info(&amp;#34;Driver reached &amp;#39;Setup&amp;#39; state, sending SetupSuccess&amp;#34;);
              this._driverProxy.setupResult(true);
          } else if (state === &amp;#34;Communicating&amp;#34;) {
              this._cleanup();
              this._logger.info(&amp;#34;Driver reached &amp;#39;Communicating&amp;#39; state, emitting request&amp;#34;);
              this.success.emit(true);
          }
      });
  };
(...)
  private _cleanup(): void {
      if (this._connectionTimer !== null) {
          clearTimeout(this._connectionTimer);
          this._connectionTimer = null;
      }
      if (this._stateChangeHandler !== null) {
          this._driverProxy.off(&amp;#34;communicationStateChange&amp;#34;, this._stateChangeHandler);
          this._stateChangeHandler = null;
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the timeout is reached it means that in the allotted time the driver did not reach the state communicating and we will throw an exception.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral Controller"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_controller.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is signals one of the issues with ephemeral connections. You pay the setup connection toll for each request. As the connection is not pre-established, the sequence will first do a new connection and only that is established will it perform the action you wish.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Managing Concurrency
&lt;div id="managing-concurrency" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#managing-concurrency" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;One of the advantages of not having an always on connection is using the same implementation for different connections, simply by changing the trigger message.&lt;/p&gt;
&lt;p&gt;In our use case we wish to reuse the browse logic for any OPC-UA server. This way a user 1 can perform a browse for server 1 and user 2 in a different UI can perform the browser for server 2. Of course, this opens the question of concurrency on how to manage requests made almost at the same time from different UI instances.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral Concurrency"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_concurency.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In order to address this use case we implemented a locking mechanism. Where the first request locks the implementation, this means that other requests will wait for the unlock to be able to execute. In a very high volume use case this method could face some hardship, but in a world of occasional user browsing is more than enough.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Concurrency Locks"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/concurrency_locks.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In this workflow when we receive a request for a browse, we will first retrieve the lock status.&lt;/p&gt;
&lt;p&gt;If it&amp;rsquo;s locked we we will wait for 1 second and try again, after 10 attempts we will send back an error saying we were not able to query the OPC-UA Server.&lt;/p&gt;
&lt;p&gt;If the status is unlocked we will execute a browse, wrapped in a try catch, this way we make sure that any unexpected scenario will not forever lock the browsing mechanism. The first action in the browse is changing the status to locked and the last thing is always unlocking the status.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Seeing it Run
&lt;div id="seeing-it-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#seeing-it-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We can now use our ephemeral connection. We will pass on the OPC-UA endpoint, perform a browse and display in a tree the result.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Ephemeral OPC-UA"
src="https://image.j-roque.com/posts/20260629-ephemeralcontroller/ephemeral_opcua.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Note how we can see that it starts from a ready state, it moves to communicating and then goes back to disconnected. This is the ephemeral lifecycle, it goes to communicating just enough time to perform the request logic and then it disconnects.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The always-on pattern is right for MES control and monitoring: it provides a stable, long-lived connection where being disconnected is itself an error.&lt;/p&gt;
&lt;p&gt;The ephemeral pattern fits ad-hoc requests — browsing, discovery, occasional queries — where the connection setup cost is acceptable and reusing one manager process across different targets saves significant resources. The constraint is connection overhead and lock contention: if request volume is high or setup latency is unacceptable, a persistent connection or session pool is the better answer.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260630-ephemeralcontroller/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260630-ephemeralcontroller/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Automatic Edge Deploy with Business Scenarios</title>
<link>https://j-roque.com/posts/20260629-automaticdeploywithbizscenario/</link>
<pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260629-automaticdeploywithbizscenario/</guid>
<description>Using business scenarios to easily deploy your edge connect iot application.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260629-automaticdeploywithbizscenario/featured.png" alt="Automatic Edge Deploy with Business Scenarios" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In &lt;a
href="https://j-roque.com/posts/20260611-businessscenario/"
target="_blank"
&gt;Guaranteed Outcomes: Building Deterministic Chat Workflows with Business Scenarios&lt;/a&gt; we did a deep dive on using business scenarios and in &lt;a
href="https://j-roque.com/posts/20250721-edge-deploy/"
target="_blank"
&gt;Edge Deploy&lt;/a&gt; we explained how the automatic edge deploy works with Connect IoT.&lt;/p&gt;
&lt;p&gt;Creating a new integration today means manually creating protocols, driver definitions, controllers, managers, and instances, wiring them together. With business scenarios we can automate all of that.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Biz Scenario Full"
src="https://image.j-roque.com/posts/20260626-automaticdeploywithbizscenario/biz_scenario_full.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Automatic Edge Deployment
&lt;div id="automatic-edge-deployment" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#automatic-edge-deployment" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Connect IoT startup &lt;a
href="https://nodejs.org/en"
target="_blank"
&gt;nodejs&lt;/a&gt; application, the &lt;strong&gt;Automation Manager&lt;/strong&gt; is very adaptable. It can run on Edge or on Cloud, it can be installed in Linux or Windows.&lt;/p&gt;
&lt;p&gt;It can be deployed via the CM Devops Center, or it can be deployed with a One Click Deploy. The One Click Deploy is an implicit installation via a middleware component, solely focused on Automation Manager deployment, the &lt;strong&gt;Automation Manager Controller&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The Automation Manager Controller will query the MES for what managers does it need to deploy and deploy them. It can run in the MES cluster or in an edge cluster.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC Deploy"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcdeploymanagers.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Business Scenarios
&lt;div id="business-scenarios" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#business-scenarios" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Business scenarios are focused on a question and answer back and forth, with deterministic outcomes. The goal is to give to the user who makes the scenario and the user that is answering the scenario a gated use-case where the outcome is expected and valid.&lt;/p&gt;
&lt;p&gt;It is defined in a json metadata where we define a set of sequential steps.&lt;/p&gt;
&lt;hr&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-business-scenario/building-scenarios/"
title="Building Scenarios"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-business-scenario/building-scenarios/"
title="Building Scenarios"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Creating an Automatic Deploy Scenario
&lt;div id="creating-an-automatic-deploy-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-an-automatic-deploy-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;I split my business scenario into four distinct stages, &lt;strong&gt;validation&lt;/strong&gt; (orange), &lt;strong&gt;information gathering&lt;/strong&gt; (blue), &lt;strong&gt;generation&lt;/strong&gt; (grey) and &lt;strong&gt;execution&lt;/strong&gt; (green).&lt;/p&gt;
&lt;p&gt;Here we can see the full execution map from &lt;strong&gt;Script&lt;/strong&gt; steps to run code, &lt;strong&gt;Question&lt;/strong&gt; steps prompt the user, &lt;strong&gt;Condition&lt;/strong&gt; steps branch the flow, and &lt;strong&gt;Message&lt;/strong&gt; steps display information without requiring input.&lt;/p&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
graph TD
classDef startClass fill:#0074b7,stroke:#005490,color:#ffffff
classDef endClass fill:#2e7d32,stroke:#005490,color:#ffffff
classDef validation fill:#d06b1f,stroke:#005490,color:#ffffff
classDef informationgathering fill:#005490,stroke:#3a78b5,color:#ffffff
classDef generation fill:#7e8c7e,stroke:#005490,color:#ffffff
classDef execution fill:#24b324,stroke:#005490,color:#ffffff
CheckIfUserIsIntegrationUser["Script:
CheckIfUserIsIntegrationUser
(selectedUser)"]:::validation --&gt; InformTheUser
InformTheUser["Message:
InformTheUser"]:::validation --&gt; NoAutomationInstances
NoAutomationInstances["Message:
NoAutomationInstances"]:::validation --&gt; ResolveIoTEnabledEntities
ResolveIoTEnabledEntities["Script:
ResolveIoTEnabledEntities
(iotEnabledEntities)"]:::validation --&gt; IoTEntityEnabled
IoTEntityEnabled["Condition:
IoTEntityEnabled"]:::validation --&gt;
|"size(iotEnabledEntities)[0] &gt; 1"|AskWhichIoTEntityType
IoTEntityEnabled["Condition:
IoTEntityEnabled"]:::informationgathering --&gt;
|"size(iotEnabledEntities)[0] == 1"|AskWhichIoTEntity
AskWhichIoTEntityType["Question:
AskWhichIoTEntityType
(iotEnabledEntityType)"]:::informationgathering --&gt; AskWhichIoTEntity
AskWhichIoTEntity["Question:
AskWhichIoTEntity
(iotEnabledEntity)"]:::informationgathering --&gt; GenerateOpcUaController:::generation
ExecuteMasterData["Script:
ExecuteMasterData
(masterdataResult)"]:::execution --&gt; ScenarioExecuted
ScenarioExecuted["Message:
ScenarioExecuted"]:::execution --&gt; ScenarioExecutedPackage:::execution
StartStep["Start Step"]:::startClass --&gt; CheckIfUserIsIntegrationUser
EndStep["End Step"]:::endClass --&gt; ExecuteMasterData
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;blockquote&gt;
&lt;p&gt;All business scenarios showed here were made using the CLI and &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_automationbusinessscenarios/"
target="_blank"
&gt;customization framework&lt;/a&gt;, which provides scaffolding, intellisense and other helpers.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 class="relative group"&gt;Validation
&lt;div id="validation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#validation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This stage we make sure the user has all the required &lt;strong&gt;permissions&lt;/strong&gt; and the system has all the required &lt;strong&gt;pre-conditions&lt;/strong&gt; to execute the scenario.&lt;/p&gt;
&lt;p&gt;The user can only deploy the automation manager if he is an &lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/how-to/howto_setuserasintegrationuser/"
target="_blank"
&gt;integration user&lt;/a&gt;. So we add a silent step to validate that condition.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;   {
      &amp;#34;name&amp;#34;: &amp;#34;CheckIfUserIsIntegrationUser&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
      &amp;#34;resultKey&amp;#34;: &amp;#34;selectedUser&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;script&amp;#34;: [
         &amp;#34;if(!this.securityService.user.IsIntegrationUser) {&amp;#34;,
         &amp;#34;   throw new Error(&amp;#39;This scenario can only be executed by a User that is an Integration User&amp;#39;);&amp;#34;,
         &amp;#34;}&amp;#34;,
         &amp;#34;this.securityService.user&amp;#34;
         ]
      },
      &amp;#34;next&amp;#34;: &amp;#34;InformTheUser&amp;#34;
   },&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our integration will require an appended MES entity to run the automation. The entity can be any entity type as long as the entity type is marked as &lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/how-to/howto_markentitytypeasconnectiotenabled/"
target="_blank"
&gt;connect iot enabled&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;   {
      &amp;#34;name&amp;#34;: &amp;#34;ResolveIoTEnabledEntities&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
      &amp;#34;resultKey&amp;#34;: &amp;#34;iotEnabledEntities&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;script&amp;#34;: [
         &amp;#34;(async () =&amp;gt; {&amp;#34;,
         &amp;#34;   const entities = await this.lboUtilities.iotEnabledEntities();&amp;#34;,
         &amp;#34;   if(entities.length == 1) {&amp;#34;,
         &amp;#34;       this.answers.iotEnabledEntity = entities[0];&amp;#34;,
         &amp;#34;   }&amp;#34;,
         &amp;#34;   return entities;&amp;#34;,
         &amp;#34;})()&amp;#34;
         ]
      },
      &amp;#34;next&amp;#34;: &amp;#34;IoTEntityEnabled&amp;#34;
   }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will make sure there is at least one entity type iot enabled.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Information Gathering
&lt;div id="information-gathering" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#information-gathering" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This stage is where we start asking the user for relevant information for the integration.&lt;/p&gt;
&lt;p&gt;First, we add a flow where the user chooses the entity type and the entity. If there&amp;rsquo;s only one entity type marked as connect iot enabled we will auto fill the answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;   {
      &amp;#34;name&amp;#34;: &amp;#34;IoTEntityEnabled&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Condition&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;condition&amp;#34;: {
         &amp;#34;size(iotEnabledEntities)[0] &amp;gt; 1&amp;#34;: &amp;#34;AskWhichIoTEntityType&amp;#34;,
         &amp;#34;size(iotEnabledEntities)[0] == 1&amp;#34;: &amp;#34;AskWhichIoTEntity&amp;#34;
         }
      },
      &amp;#34;next&amp;#34;: &amp;#34;&amp;#34;
   },
   {
      &amp;#34;name&amp;#34;: &amp;#34;AskWhichIoTEntityType&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
      &amp;#34;resultKey&amp;#34;: &amp;#34;iotEnabledEntityType&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;message&amp;#34;: &amp;#34;Please choose an EntityType to use for this integration&amp;#34;,
         &amp;#34;dataType&amp;#34;: &amp;#34;EntityType&amp;#34;,
         &amp;#34;settings&amp;#34;: {
         &amp;#34;data&amp;#34;: [
            &amp;#34;this.answers.iotEnabledEntities&amp;#34;
         ]
         }
      },
      &amp;#34;next&amp;#34;: &amp;#34;AskWhichIoTEntity&amp;#34;
   },
   {
      &amp;#34;name&amp;#34;: &amp;#34;AskWhichIoTEntity&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
      &amp;#34;message&amp;#34;: &amp;#34;Select an entity type&amp;#34;,
      &amp;#34;resultKey&amp;#34;: &amp;#34;iotEnabledEntity&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;dataType&amp;#34;: &amp;#34;FindEntity&amp;#34;,
         &amp;#34;settings&amp;#34;: {
         &amp;#34;query&amp;#34;: &amp;#34;${script(./scripts/create-opcua-controller/find_entity.ts)}&amp;#34;,
         &amp;#34;displayField&amp;#34;: &amp;#34;Name&amp;#34;
         }
      },
      &amp;#34;next&amp;#34;: &amp;#34;GenerateOpcUaController&amp;#34;
   },&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this scenario, the workflow is already pre-defined and the only variable is the the entity to associate to the instance. So we won&amp;rsquo;t require any more information from the user.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Generation
&lt;div id="generation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#generation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The generation stage is where we will gather all the information collected from the user and build the entities to add to the system. In this example, we are building a master data package to be uploaded to the system.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;   {
      &amp;#34;name&amp;#34;: &amp;#34;GenerateOpcUaController&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
      &amp;#34;settings&amp;#34;: {
         &amp;#34;script&amp;#34;: &amp;#34;${script(./scripts/create-opcua-controller/generate_opcua_controller.ts)}&amp;#34;
      },
      &amp;#34;next&amp;#34;: &amp;#34;&amp;#34;
   },&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script will be focused on mirroring the workflow and creating the entities for the deployment.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Example Workflow"
src="https://image.j-roque.com/posts/20260626-automaticdeploywithbizscenario/Example_workflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is an example workflow that we will have be deployed via business scenario and automatically deployed.&lt;/p&gt;
&lt;p&gt;The script builds the full Connect IoT entity stack for an OPC-UA integration: a &lt;strong&gt;Protocol&lt;/strong&gt; defines the communication type, a &lt;strong&gt;Driver Definition&lt;/strong&gt; maps the communication interface, a &lt;strong&gt;Controller&lt;/strong&gt; holds the workflow logic, and a &lt;strong&gt;Manager&lt;/strong&gt; and &lt;strong&gt;Automation Instance&lt;/strong&gt; tie everything to an MES entity (mapping a physical machine).&lt;/p&gt;
&lt;p&gt;The script will extract the user answers and the names for the iot entities.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   // PackagePacker: Start of Script
   (async () =&amp;gt; {
      const managerName = &amp;#34;OPCUA Browser Manager&amp;#34;;
      const controllerName = &amp;#34;OPCUA Browser Controller&amp;#34;;
      const protocolName = &amp;#34;OPCUA Browser Protocol&amp;#34;;
      const driverDefName = &amp;#34;OPCUA Browser Driver Definition &amp;#34;;
      const driverAlias = &amp;#34;OPCUA&amp;#34;;
      const opcuaPackage = &amp;#34;@criticalmanufacturing/connect-iot-driver-opcua&amp;#34;;
      const coreTasksPackage = &amp;#34;@criticalmanufacturing/connect-iot-controller-engine-core-tasks&amp;#34;;
      const customTasksPackage = &amp;#34;@criticalmanufacturing/connect-iot-controller-engine-custom-tasks&amp;#34;;
      const tasksPackages = [coreTasksPackage, customTasksPackage];
      const entityType = this.answers.iotEnabledEntityType.Name;
// ────────────────────────────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we can start building the iot entities.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   // ── 1. Protocol ──────────────────────────────────────────────────────────
   await this.masterdataDirector.buildProtocol({
         name: protocolName,
         type: &amp;#34;Ephemeral&amp;#34;,
         packageName: opcuaPackage,
   });
// ── 2. Driver Definition ─────────────────────────────────────────────────
   await this.masterdataDirector.buildDriverDefinition(
         {
            name: driverDefName,
            type: &amp;#34;Ephemeral&amp;#34;,
            automationProtocol: protocolName,
            objectType: entityType,
         },
         [], // properties
         [], // events
         [] // eventProperties
   );&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;Automation Controller&lt;/strong&gt; is the more complex one as it will have all the workflow logic. Here the idea is we have a set of root tasks and the other tasks are nested under the root tasks. Similar to what you see in the workflow editor&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   const setupRoot = positionRootTask(await this.workflowBuilder.addRootTask(
         coreTasksPackage,
         &amp;#34;function&amp;#34;,
         { name: &amp;#34;Setup&amp;#34; },
         [
            { name: &amp;#34;address&amp;#34;, displayName: &amp;#34;address&amp;#34;, dataType: &amp;#34;String&amp;#34;, value: &amp;#34;&amp;#34; },
            { name: &amp;#34;username&amp;#34;, displayName: &amp;#34;username&amp;#34;, dataType: &amp;#34;String&amp;#34; },
            { name: &amp;#34;password&amp;#34;, displayName: &amp;#34;password&amp;#34;, dataType: &amp;#34;String&amp;#34; },
            { name: &amp;#34;getNodes&amp;#34;, displayName: &amp;#34;getNodes&amp;#34;, dataType: &amp;#34;Object&amp;#34; },
         ],
         [
            { name: &amp;#34;browse&amp;#34;, displayName: &amp;#34;browse&amp;#34;, dataType: &amp;#34;Object&amp;#34;, value: &amp;#34;&amp;#34; },
            { name: &amp;#34;getProps&amp;#34;, displayName: &amp;#34;getProps&amp;#34;, dataType: &amp;#34;Object&amp;#34; },
         ]
   ));
// tryCatch wrapper inside the handler branch
   const tryCatch = await this.workflowBuilder.addTask(
         setupRoot.id,
         &amp;#34;handler&amp;#34;,
         coreTasksPackage,
         &amp;#34;tryCatch&amp;#34;,
         {},
         [],
         []
   );
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An interesting use case to point out is the condition task as it creates a branching between the &lt;code&gt;handler&lt;/code&gt; and the &lt;code&gt;else&lt;/code&gt;. This is quite easy to distinguish we just need to nest the following tasks into the different branches.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;
   // try: condition – check if password is provided
   const pwCondition = await this.workflowBuilder.addTask(
         tryCatch.id,
         &amp;#34;try&amp;#34;,
         coreTasksPackage,
         &amp;#34;condition&amp;#34;,
         {},
         [],
         [],
         [
            {
               name: &amp;#34;handler&amp;#34;,
               settings: { type: &amp;#34;If&amp;#34;, condition: `{{ $${setupRoot.name}.password != null and $${setupRoot.name}.password != &amp;#34;&amp;#34; }}` },
               tasks: [],
            },
            {
               name: &amp;#34;else&amp;#34;,
               settings: { type: &amp;#34;Else&amp;#34; },
               tasks: [],
            },
         ]
   );
// if password set: rsaDecrypter – decrypt the incoming encrypted password
   const rsaDecrypter = await this.workflowBuilder.addTask(
         pwCondition.id,
         &amp;#34;handler&amp;#34;,
         customTasksPackage,
         &amp;#34;rsaDecrypter&amp;#34;,
         {},
         [{ name: &amp;#34;encryptedBase64&amp;#34;, displayName: &amp;#34;encryptedBase64&amp;#34;, value: ` {{ $${setupRoot.name}.password }}`, dataType: &amp;#34;String&amp;#34; }],
         [{ name: &amp;#34;value&amp;#34;, displayName: &amp;#34;value&amp;#34;, value: &amp;#34;{{ $this.value }}&amp;#34;, dataType: &amp;#34;String&amp;#34; }]
   );
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now that we populated the workflow builder we can extract the master data and add the automation controller.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   // ── 4. Controller ─────────────────────────────────────────────────────────
   await this.masterdataDirector.buildController(
         {
            name: controllerName,
            type: &amp;#34;Ephemeral&amp;#34;,
            scope: &amp;#34;ConnectIoT&amp;#34;,
            objectType: entityType,
            tasksPackages: tasksPackages,
            tasksLibraryPackages: tasksLibraryPackages,
            controllerPackageVersion: undefined,
            defaultWorkflowType: &amp;#34;ControlFlow&amp;#34;,
         },
         [
            {
               automationController: controllerName,
               name: driverAlias,
               displayName: driverAlias,
               automationDriverDefinition: driverDefName,
               color: &amp;#34;#ffdd00&amp;#34;,
               order: 1,
            },
         ],
         [
            {
               automationController: controllerName,
               name: &amp;#34;Setup&amp;#34;,
               displayName: &amp;#34;Setup&amp;#34;,
               workflow: setupWorkflow,
               order: 1,
               isFile: true,
               workflowType: &amp;#34;ControlFlow&amp;#34;,
            },
         ]
   );&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The next step is creating the automation manager and the instance. If you are curious to understand how all these entities relate with each other you can read about it &lt;a
href="https://j-roque.com/posts/20250325-connectiotstructure/"
target="_blank"
&gt;overview Connect IoT Structure&lt;/a&gt; and &lt;a
href="https://j-roque.com/posts/20250725-iot-extensibility-i/#architecture"
target="_blank"
&gt;IoT Extensibility&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
   // ── 5. Manager ─────────────────────────────────────────────────────────
(...)
   const managerInfo: any = {
         Name: managerName,
         LogicalAddress: managerName,
         Type: &amp;#34;Ephemeral&amp;#34;,
         DeploymentMode: &amp;#34;AutomaticDeploy&amp;#34;,
         DeploymentState: &amp;#34;Ready&amp;#34;,
         MonitorPackageVersion: getNewestVersion(getNPMPackageVersionOutput.PackageVersions.get(&amp;#34;@criticalmanufacturing/connect-iot-monitor&amp;#34;)),
         ManagerPackageVersion: getNewestVersion(getNPMPackageVersionOutput.PackageVersions.get(&amp;#34;@criticalmanufacturing/connect-iot-manager&amp;#34;))
   };
   this.masterdataDirector.builder.addGeneric(&amp;#34;&amp;lt;SM&amp;gt;AutomationManager&amp;#34;, managerInfo);
// ── 7. AutomationInstances ─────────────────────────────────────────────────────────
const worksheetNameMapping: any = {
         WorksheetName: &amp;#34;AutomationInstances&amp;#34;,
         Value: &amp;#34;&amp;lt;DM&amp;gt;AutomationControllerInstance&amp;#34;
   };
   this.masterdataDirector.builder.addGeneric(&amp;#34;WorksheetNameMapping&amp;#34;, worksheetNameMapping);
const aciInfo: any = {
         AutomationController: controllerName,
         AutomationManager: managerName,
         EntityName: this.answers.iotEnabledEntity.Name
   };
   this.masterdataDirector.builder.addGeneric(&amp;#34;&amp;lt;LOOKUP&amp;gt;AutomationInstances&amp;#34;, aciInfo);
const adiInfo: any = {
         AutomationController: controllerName,
         AutomationManager: managerName,
         EntityName: this.answers.iotEnabledEntity.Name,
         DriverName: driverAlias
   };
this.masterdataDirector.builder.addGeneric(&amp;#34;&amp;lt;LOOKUP&amp;gt;AutomationInstances&amp;#34;, adiInfo);
})();
// PackagePacker: End of Script&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An important highlight is that our manager will be created with the &lt;strong&gt;Deployment Mode&lt;/strong&gt; automatic deploy. This means that right away it is marked as a manager that is to be deployed by automation manager controller. The &lt;strong&gt;Deployment State&lt;/strong&gt; is important in having the deployment be automatic, if it is in a ready state this means it is marked to be automatically deployed, if it&amp;rsquo;s not set as ready the user will have to manually press in the deploy button.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Executing the Scenario
&lt;div id="executing-the-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#executing-the-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We can now import the scenario to our MES. Either via an installation of the customization or &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-business-scenario/automation_business_scenario_synchronize/"
target="_blank"
&gt;directly in the system&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scenario System"
src="https://image.j-roque.com/posts/20260626-automaticdeploywithbizscenario/scenario_system.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Executing Scenario"
src="https://image.j-roque.com/posts/20260626-automaticdeploywithbizscenario/executing_scenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Note that the scenario generated a master data package that will upload our master data to the system. When it finishes running we have all the connect iot entities with an Automation Manager in the state ready and a controller instance associated to it.&lt;/p&gt;
&lt;p&gt;At this point the integration is &lt;strong&gt;live&lt;/strong&gt;. The Automation Manager will be picked up by the Automation Manager Controller and deployed automatically — no further manual steps, no SSH session, no configuration file editing, no server or cluster access.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Automatic Deployment
&lt;div id="automatic-deployment" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#automatic-deployment" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The automation manager controller is has now deployed the automation manager with an instance. You can see in the Connect IoT UI that the instance is now green and in the state ready. If we take a look in the cluster we can see that we now have a deployment and a pod running.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Manager Controller Deploy"
src="https://image.j-roque.com/posts/20260626-automaticdeploywithbizscenario/amc_deploy.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In this use-case the automation manager controller is running in the same stack of the MES but it could also be deployed alone in an edge stack.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This use case opens up the door for an easy way to quickly handle edge deployments.&lt;/p&gt;
&lt;p&gt;The possibilities are endless and can be fine-tuned to your particular needs.&lt;/p&gt;
&lt;p&gt;The same pattern could deploy a different protocol type based on user selection, provision multiple machines in a single scenario run, or include a validation step that checks whether a compatible Automation Manager is already running in the target cluster.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260629-automaticdeploywithbizscenario/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260629-automaticdeploywithbizscenario/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Building an RSA encryption flow with Connect IoT</title>
<link>https://j-roque.com/posts/20260625-rsa-iot/</link>
<pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260625-rsa-iot/</guid>
<description>How RSA asymmetric encryption works, why it matters for IoT device security, and where it breaks down at scale.</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260625-rsa-iot/featured.png" alt="Building an RSA encryption flow with Connect IoT" /&gt;&lt;/p&gt;&lt;p&gt;Shared secrets are a liability. Every device that holds a password is a device that can leak one. RSA flips that model — and in manufacturing environments where equipment outlives the engineers who deployed it, that difference matters.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Message Bus Encrypted"
src="https://image.j-roque.com/posts/20260625-rsa-iot/messagebus_encrypted.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Explaining RSA
&lt;div id="explaining-rsa" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#explaining-rsa" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;RSA&lt;/strong&gt; (Rivest–Shamir–Adleman) is an asymmetric encryption algorithm. Unlike symmetric schemes where the same key encrypts and decrypts, RSA gives you a mathematically linked key pair: a &lt;strong&gt;public key&lt;/strong&gt; you can hand out freely, and a &lt;strong&gt;private key&lt;/strong&gt; you never share.&lt;/p&gt;
&lt;p&gt;The security property is asymmetry: data encrypted with the public key can only be decrypted with the matching private key. The underlying hardness assumption is integer factorization — breaking a 2048-bit RSA key by brute force (without quantum computing) would take longer than the universe has existed.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="RSA Decryption"
src="https://sectigostore.com/blog/wp-content/uploads/2020/06/how-rsa-works.png"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;How RSA encryption and decryption work&lt;/em&gt; — Image source: &lt;em&gt;&lt;a
href="https://sectigostore.com/blog/ecdsa-vs-rsa-everything-you-need-to-know/"
target="_blank"
&gt;https://sectigostore.com/blog/ecdsa-vs-rsa-everything-you-need-to-know/&lt;/a&gt;&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In practice, RSA is rarely used to encrypt bulk data directly — it&amp;rsquo;s expensive for large payloads. It shines for protecting small, high-value secrets: credentials, tokens, encryption keys for other ciphers.&lt;/p&gt;
&lt;h2 class="relative group"&gt;UI Interaction Scenario
&lt;div id="ui-interaction-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ui-interaction-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A common scenario where we ask the user to provide a user and password in order to authenticate, like for example in an OPC-UA connection, can create some challenges. It requires communication between the UI and the edge process, this may comes with a security risk, where a listener placed in the communication layer could sniff the message with the password. Admittedly only people with high level access could hook that listener, but we eliminated a class of exposure without changing the user experience.&lt;/p&gt;
&lt;p&gt;So we created this RSA flow. First, the UI is going to request the public key from the controller.&lt;/p&gt;
&lt;p&gt;The controller exposes a messagebus listener for a topic to provide the public key.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="RSA Setup"
src="https://image.j-roque.com/posts/20260625-rsa-iot/RSA_Setup.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see an example of a &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/presentation/"
target="_blank"
&gt;custom UI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;We can inject the &lt;strong&gt;MessageBusService&lt;/strong&gt; and exchange messages with the controller.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import {
    CustomizableComponent,
    HOST_VIEW_COMPONENT,
    MessageBusService,
    ConfigCacheService
} from &amp;#39;cmf-core&amp;#39;;
(...)
constructor(
      viewContainerRef: ViewContainerRef,
      private readonly messageBus: MessageBusService,
      private readonly ngZone: NgZone,
      private config: ConfigCacheService,
   ) {
      super(viewContainerRef);
   }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core/injectables/MessageBusService.html"
title="MessageBusService"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-0"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-0').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core/injectables/MessageBusService.html"
title="MessageBusService"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-0');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   const response = await this.messageBus.sendRequest(&amp;#39;Cmf.OPCUA.PublicKey&amp;#39;, {}, 30000);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now that the custom UI has the public key it is able to encrypt the password.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   this.password = await this.encryptPassword(this.password, this._publicKey);&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   private async encryptPassword(password: string, publicKeyPem: string): Promise&amp;lt;string&amp;gt; {
// Import the PEM public key
      const pemHeader = &amp;#39;-----BEGIN PUBLIC KEY-----&amp;#39;;
      const pemFooter = &amp;#39;-----END PUBLIC KEY-----&amp;#39;;
      const pemContents = publicKeyPem
         .replace(pemHeader, &amp;#39;&amp;#39;).replace(pemFooter, &amp;#39;&amp;#39;).replace(/\s/g, &amp;#39;&amp;#39;);
      const binaryDer = Uint8Array.from(atob(pemContents), c =&amp;gt; c.charCodeAt(0));
const cryptoKey = await window.crypto.subtle.importKey(
         &amp;#39;spki&amp;#39;,
         binaryDer.buffer,
         { name: &amp;#39;RSA-OAEP&amp;#39;, hash: &amp;#39;SHA-256&amp;#39; },
         false,
         [&amp;#39;encrypt&amp;#39;]
      );
const encoded = new TextEncoder().encode(password);
      const encrypted = await window.crypto.subtle.encrypt(
         { name: &amp;#39;RSA-OAEP&amp;#39; },
         cryptoKey,
         encoded
      );
// Return as base64 string — safe to send over message bus
      return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And can perform the connect request passing the user and password and the password will now be encrypted.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   const response = await this.messageBus.sendRequest(&amp;#39;Cmf.OPCUA.Browse&amp;#39;, {
      address: this.address,
      username: this.username,
      password: this.password,
   }, 30000);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="RSA Decrypt"
src="https://image.j-roque.com/posts/20260625-rsa-iot/RSA_Decrypt.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can see the traffic is encrypted:&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Message Bus Encrypted"
src="https://image.j-roque.com/posts/20260625-rsa-iot/messagebus_encrypted.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;The Three-Task Architecture
&lt;div id="the-three-task-architecture" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-three-task-architecture" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Connect IoT implementation splits RSA into three focused tasks that compose together in a workflow:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RsaSetupTask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Generate the key pair; persist the private key; emit the public key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RsaEncrypterTask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Encrypt a plaintext string using the stored public key → base64 output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RsaDecrypterTask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Decrypt a base64 ciphertext using the stored private key → plaintext output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The public key leaves the system (shared with whoever needs to send encrypted data). The private key never does — it lives in Connect IoT&amp;rsquo;s persistent data store.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Flow Step by Step
&lt;div id="the-flow-step-by-step" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-flow-step-by-step" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;1. Setup (runs once)
   RsaSetupTask generates a 2048-bit RSA key pair.
   Private key → stored in DataStore (Persistent).
   Public key → emitted as output, shared with external system.
2. Encryption (runs at the sender)
   External system (or another workflow) receives the public key.
   RsaEncrypterTask encrypts a sensitive value with that public key.
   Output: base64-encoded ciphertext — safe to transmit or store.
3. Decryption (runs when the secret is needed)
   RsaDecrypterTask receives the base64 ciphertext.
   Retrieves the private key from DataStore.
   Decrypts → emits the original plaintext.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In machine integration, it&amp;rsquo;s common to have the need for authentication. The MES already supports holding that encrypted information on a configuration entry. But for more dynamic scenarios this may not be enough.&lt;/p&gt;
&lt;h2 class="relative group"&gt;RSA Setup
&lt;div id="rsa-setup" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#rsa-setup" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The RSA setup is quite simple, it will use the &lt;a
href="https://nodejs.org/api/crypto.html"
target="_blank"
&gt;node crypto&lt;/a&gt; library to create a public private key pair and store them locally.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
      if (changes[&amp;#34;activate&amp;#34;]) {
         // It is advised to reset the activate to allow being reactivated without the value being different
         this.activate = undefined;
try {
               const privateKey = await this._dataStore.retrieve(&amp;#34;rsa_privateKey&amp;#34;, undefined);
               if (!privateKey) {
                  const { privateKey, publicKey } = crypto.generateKeyPairSync(&amp;#39;rsa&amp;#39;, {
                     modulusLength: 2048,
                     publicKeyEncoding: { type: &amp;#39;spki&amp;#39;, format: &amp;#39;pem&amp;#39; },
                     privateKeyEncoding: { type: &amp;#39;pkcs8&amp;#39;, format: &amp;#39;pem&amp;#39; }
                  });
                  await this._dataStore.store(&amp;#34;rsa_privateKey&amp;#34;, privateKey, System.DataStoreLocation.Persistent);
                  await this._dataStore.store(&amp;#34;rsa_publicKey&amp;#34;, publicKey, System.DataStoreLocation.Persistent);
               }
this.publicKey.emit(await this._dataStore.retrieve(&amp;#34;rsa_publicKey&amp;#34;, undefined));
               this.success.emit(true);
} catch (error) {
               this.logAndEmitError((error as Error)?.message);
         }
      }
   }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;RSA Decrypter
&lt;div id="rsa-decrypter" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#rsa-decrypter" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The RSA decrypter will receive a base64 encrypted string and will use the private key to decrypt the message.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
        if (changes[&amp;#34;activate&amp;#34;]) {
            // It is advised to reset the activate to allow being reactivated without the value being different
            this.activate = undefined;
try {
                const buffer = Buffer.from(this.encryptedBase64, &amp;#39;base64&amp;#39;);
                const privatekey = await this._dataStore.retrieve(&amp;#34;rsa_privateKey&amp;#34;, undefined)
                const decrypted = crypto.privateDecrypt(
                    { key: privatekey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: &amp;#39;sha256&amp;#39; },
                    buffer
                ).toString(&amp;#39;utf8&amp;#39;);
this.value.emit(decrypted);
                this.success.emit(true);
            } catch (error) {
                this.logAndEmitError((error as Error)?.message);
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;RSA Encrypter
&lt;div id="rsa-encrypter" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#rsa-encrypter" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The RSA encrypter is similar to what we saw for the UI. It will leverage a public key and encrypt a string into an encrypted base64.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;   public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
      if (changes[&amp;#34;activate&amp;#34;]) {
         // It is advised to reset the activate to allow being reactivated without the value being different
         this.activate = undefined;
try {
               if (this.publicKey == null) {
                  this.publicKey = await this._dataStore.retrieve(&amp;#34;rsa_publicKey&amp;#34;, undefined);
               }
this.encryptedBase64.emit(crypto.publicEncrypt(
                  { key: this.publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: &amp;#39;sha256&amp;#39; },
                  Buffer.from(this.value, &amp;#39;utf8&amp;#39;)
               ).toString(&amp;#39;base64&amp;#39;));
               this.success.emit(true);
} catch (error) {
               this.logAndEmitError((error as Error)?.message);
         }
      }
   }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Key Implementation Decisions
&lt;div id="key-implementation-decisions" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#key-implementation-decisions" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;OAEP padding with SHA-256.&lt;/strong&gt; Both the encrypter and decrypter use &lt;code&gt;RSA_PKCS1_OAEP_PADDING&lt;/code&gt; with &lt;code&gt;oaepHash: 'sha256'&lt;/code&gt;. OAEP (Optimal Asymmetric Encryption Padding) is the current standard — it adds randomness to each encryption operation, so encrypting the same plaintext twice produces different ciphertexts. This defeats chosen-ciphertext attacks that the older PKCS#1 v1.5 padding is vulnerable to. Never use &lt;code&gt;RSA_PKCS1_PADDING&lt;/code&gt; for new implementations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SPKI/PKCS8 PEM format.&lt;/strong&gt; The key pair is generated with &lt;code&gt;type: 'spki'&lt;/code&gt; for the public key and &lt;code&gt;type: 'pkcs8'&lt;/code&gt; for the private key. These are the standard, interoperable formats — any external system using OpenSSL, Java, Python&amp;rsquo;s &lt;code&gt;cryptography&lt;/code&gt; library, or .NET can consume the public key without conversion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lazy key generation.&lt;/strong&gt; &lt;code&gt;RsaSetupTask&lt;/code&gt; checks the data store before generating:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-typescript"&gt;const privateKey = await this._dataStore.retrieve(&amp;#34;rsa_privateKey&amp;#34;, undefined);
if (!privateKey) {
    const { privateKey, publicKey } = crypto.generateKeyPairSync(&amp;#39;rsa&amp;#39;, { ... });
    await this._dataStore.store(&amp;#34;rsa_privateKey&amp;#34;, privateKey, System.DataStoreLocation.Persistent);
    await this._dataStore.store(&amp;#34;rsa_publicKey&amp;#34;, publicKey, System.DataStoreLocation.Persistent);
}
this.publicKey.emit(await this._dataStore.retrieve(&amp;#34;rsa_publicKey&amp;#34;, undefined));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it ten times and you get the same key pair. The first run generates and persists; subsequent runs retrieve and emit. This means the public key you distribute stays valid across restarts and re-deployments — until you explicitly clear the data store and force regeneration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No key rotation built in.&lt;/strong&gt; This is a deliberate simplicity tradeoff. Rotation requires coordinating a new public key with every external system holding the old one. That&amp;rsquo;s a workflow concern, not a task concern.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Advantages
&lt;div id="advantages" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#advantages" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Private key never leaves the runtime.&lt;/strong&gt; The only copy of the private key is in Connect IoT&amp;rsquo;s on edge persistent data store. External systems only ever see the public key. A compromised network packet or log file can&amp;rsquo;t expose the secret.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Plaintext never stored.&lt;/strong&gt; Sensitive values — passwords, tokens, API keys — can live in the system exclusively as ciphertext. The plaintext exists only transiently during decryption, in memory, for the duration of the operation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Interoperable by default.&lt;/strong&gt; SPKI/PKCS8 PEM with OAEP-SHA256 is understood by every modern crypto library. Any system that needs to encrypt data for this Connect IoT instance can do so without custom tooling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Composable with Connect IoT&amp;rsquo;s task model.&lt;/strong&gt; Each task does one thing. &lt;code&gt;RsaSetupTask&lt;/code&gt; can run at controller startup; &lt;code&gt;RsaDecrypterTask&lt;/code&gt; can run on demand inside any workflow that needs to resolve a secret. No global state beyond what&amp;rsquo;s explicitly stored.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Disadvantages
&lt;div id="disadvantages" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#disadvantages" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Data store as key store.&lt;/strong&gt; Connect IoT&amp;rsquo;s persistent data store is not a hardware security module. The private key is as protected as the underlying storage layer — encrypted at rest only if the runtime or OS provides that. For high-assurance environments, this is worth reviewing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No ciphertext authentication.&lt;/strong&gt; RSA-OAEP prevents chosen-ciphertext attacks, but it doesn&amp;rsquo;t authenticate the &lt;em&gt;sender&lt;/em&gt;. Anyone who holds the public key can produce a valid ciphertext. If you need to verify that the encrypted message came from a specific source, you need signatures in addition to encryption — a separate concern.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key rotation is a manual operation.&lt;/strong&gt; Clearing the data store and re-running &lt;code&gt;RsaSetupTask&lt;/code&gt; produces a new key pair, but every external system holding the old public key will be sending ciphertexts that the new private key can&amp;rsquo;t decrypt. Rotation requires out-of-band coordination.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Quantum threat (long horizon).&lt;/strong&gt; Shor&amp;rsquo;s algorithm breaks RSA on a sufficiently capable quantum computer. This doesn&amp;rsquo;t affect current deployments, but equipment with 20-year lifespans installed today should at least acknowledge that post-quantum migration will be necessary eventually. We also have an interesting article on this topic &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250919_mes_postquantum_encryption/"
target="_blank"
&gt;Is your MES Quantum Safe?&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Three tasks, roughly 100 lines of TypeScript, and you have a credible answer to &amp;ldquo;how can we dynamically send to the edge sensitive data.&amp;rdquo; That&amp;rsquo;s a meaningful improvement over the status quo in most IoT deployments.&lt;/p&gt;
&lt;p&gt;The first way to increase security is making it easy to use.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260625-rsa-iot/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260625-rsa-iot/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Guaranteed Outcomes: Building Deterministic Chat Workflows with Business Scenarios</title>
<link>https://j-roque.com/posts/20260611-businessscenario/</link>
<pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260611-businessscenario/</guid>
<description>Deterministic Decision Flows, modelling a Secs-Gem Integration</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260611-businessscenario/featured.png" alt="Guaranteed Outcomes: Building Deterministic Chat Workflows with Business Scenarios" /&gt;&lt;/p&gt;&lt;p&gt;The rise of AI has brought action and insight through a normal conversation. We&amp;rsquo;ve been building a lot of support for leveraging AI flows &lt;a
href="https://j-roque.com/posts/20260218-frominsighttoaction/"
target="_blank"
&gt;From Insight to Action: AI That Actually Does Something&lt;/a&gt; and &lt;a
href="https://j-roque.com/posts/20260414-odata-agentic/"
target="_blank"
&gt;Data Platform - Agentic Use-Case&lt;/a&gt;, today we are addressing a different type of solution.&lt;/p&gt;
&lt;p&gt;This can be a great way for dynamic engagements, but it is also a probabilistic tool. It does not guarantee a set path of available options.&lt;/p&gt;
&lt;p&gt;Even constrained AI&amp;rsquo;s with grammar or low temperature, are based on probabilistic outcomes and required a deep validation of the solution offered and the actions to perform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Business Scenarios&lt;/strong&gt; were thought exactly to tackle those kinds of problems. They are focused on a question and answer back and forth, with deterministic outcomes. The goal is to give to the user who makes the scenario and the user that is answering the scenario a gated use-case where the outcome is expected and valid.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Secs-Gem Integration"
src="https://image.j-roque.com/posts/20260611-businessscenario/secs_gem_scenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Business scenarios are made up of a scope, of steps and checkpoint hooks. They are defined in an easy to see and interpret JSON metadata object.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;scope&lt;/strong&gt; will be in which view of the MES they will be accessible. If a user is in the BusinessData view he can add the scope &lt;strong&gt;LandingPage/BusinessData&lt;/strong&gt;, this means that the scenario will only be visible if the user is in the BusinessData landing page.&lt;/p&gt;
&lt;p&gt;A scenario can be created for any action in the MES.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A common approach in creating complex scenarios is to make a set of questions to the user and then generate a master data package that he can view, validate and execute.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Create an MES User
&lt;div id="create-an-mes-user" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#create-an-mes-user" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s start with a simple scenario. We want via chat to be able to create an MES &lt;strong&gt;User&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Create a User Biz Scenario"
src="https://image.j-roque.com/posts/20260611-businessscenario/biz_scenario_createuser.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;We will define in what scopes we want the user to be able to create users. For this example, I chose scope &lt;strong&gt;Security&lt;/strong&gt;. The scope security already safeguards that only users with access to the page with the security &lt;a
href="https://help.criticalmanufacturing.com/userguide/administration/security/features/?h=features"
target="_blank"
&gt;feature&lt;/a&gt; can access this scenario.&lt;/p&gt;
&lt;p&gt;Now we can define our flow. We will create a set of questions we need in order to be able to create a user.&lt;/p&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
graph TD
classDef startClass fill:#0074b7,stroke:#005490,color:#ffffff
classDef endClass fill:#2e7d32,stroke:#1b5e20,color:#ffffff
classDef questionClass fill:#5b9bd5,stroke:#3a78b5,color:#ffffff
classDef scriptClass fill:#d06b1f,stroke:#a05010,color:#ffffff
StartStep["Start Step"]:::startClass --&gt; AskUserAccount
AskUserAccount["Question:
AskUserAccount
(userAccount)"]:::questionClass --&gt; AskName
AskName["Question:
AskName
(userName)"]:::questionClass --&gt; AskEmail
AskEmail["Question:
AskEmail
(mailAddress)"]:::questionClass --&gt; LoadRoles
LoadRoles["Script:
LoadRoles
(rolesList)"]:::scriptClass --&gt; AskPrimaryRole
AskPrimaryRole["Question:
AskPrimaryRole
(primaryRole)"]:::questionClass --&gt; AskAuthStrategy
AskAuthStrategy["Question:
AskAuthStrategy
(authenticationStrategy)"]:::questionClass --&gt; AskPassword
AskPassword:::questionClass
EndStep["End Step"]:::endClass --&gt; CreateUser
CreateUser:::endClass
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;h3 class="relative group"&gt;Creating a Scenario
&lt;div id="creating-a-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A scenario is composed of a json that defines each of the steps to execute.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A scenario can be created directly in the MES UI, but typically the scenario is managed through a customization project and this is what we will focus on today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 class="relative group"&gt;Development Workspace
&lt;div id="development-workspace" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#development-workspace" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Business Scenarios are handled in customization as part of the IoT customization package.&lt;/p&gt;
&lt;p&gt;In order to generate a new iot package the CLI provides the command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cmf new iot&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the IoT.Packages generated we can now create the package that will hold our business scenarios, by executing:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cmf new iot businessScenario&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command will generate the following structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-log"&gt;📂.vscode
┣ 📂doc
┃ ┗📜business_scenario_&amp;lt;scenario&amp;gt;.md
┣ 📂scenarios
┃ ┗📂scripts
┃ ┗📜business_scenario_&amp;lt;scenario&amp;gt;.json
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at some &lt;strong&gt;developer experience&lt;/strong&gt; features the development workspace gives you.&lt;/p&gt;
&lt;p&gt;The first one is having &lt;strong&gt;json schema intellisense&lt;/strong&gt; that can complement the information provided in the &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-business-scenario/"
target="_blank"
&gt;documentation portal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="JSON Schema Intellisense"
src="https://image.j-roque.com/posts/20260611-businessscenario/biz_scenario_createuser.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The second is the ability to easily compile and deploy the changes to the scenario.&lt;/p&gt;
&lt;p&gt;The creation of the scenario in the MES can be done like any other iot package with the use of the command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;npm run packagePacker&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the user can add it to the repository and create the scenario. The user can also go to the folder &lt;strong&gt;TEMP&lt;/strong&gt; which will have the package.json with the scenario with the embedded scripts.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploying Scenario"
src="https://image.j-roque.com/posts/20260611-businessscenario/deploying_scenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Any time we want to change the scenario we can edit it and change the metadata information.&lt;/p&gt;
&lt;p&gt;Finally, the ability to generate mermaid flowcharts mapping our scenario, is very handy when our scenarios start to become complex, with the use of the vscode extension &lt;a
href="https://marketplace.visualstudio.com/items?itemName=CriticalManufacturing.automation-business-scenarios-vscode"
target="_blank"
&gt;Automation Business Scenarios Renderer&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Convert Mermaid Chart"
src="https://image.j-roque.com/posts/20260611-businessscenario/convert_mermaid_chart.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Breaking Down our Create User Scenario
&lt;div id="breaking-down-our-create-user-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#breaking-down-our-create-user-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;First, we start off by providing a valid name and description. As explained previously the &lt;code&gt;scopes&lt;/code&gt; will be where the scenario is available. The scopes are comma separated, for our scenario we just have the scenario available for the Security page.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
    &amp;#34;name&amp;#34;: &amp;#34;Create MES User&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Guides the user through creating a new MES user by collecting account details, display name, email address, primary role, authentication strategy, and password.&amp;#34;,
    &amp;#34;scopes&amp;#34;: &amp;#34;Security&amp;#34;,
    (...)
    &amp;#34;metadata&amp;#34;: {&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The metadata field is where we will have our steps and our checkpoint hooks. For this, scenario we have two stages, the main stage where we are asking the user questions and the end stage where we want to call a script to create the user in the MES.&lt;/p&gt;
&lt;p&gt;Our first question is to ask for the user account, so this is our start checkpoint and we will have an end step which will invoke a script, where we create a user, therefore the resultType is Script and our end step is CreateUser.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;(...)
    &amp;#34;start&amp;#34;: &amp;#34;AskUserAccount&amp;#34;,
    &amp;#34;finally&amp;#34;: &amp;#34;&amp;#34;,
    &amp;#34;end&amp;#34;: &amp;#34;CreateUser&amp;#34;,
    &amp;#34;resultType&amp;#34;: &amp;#34;Script&amp;#34;,
    &amp;#34;steps&amp;#34;: [...]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A step is defined by having a name, a type, a set of settings and a next step. If a step is a &lt;strong&gt;Question&lt;/strong&gt; it will have a resultKey, which will be where the step stores the answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;(...)
    {
        &amp;#34;name&amp;#34;: &amp;#34;AskUserAccount&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;userAccount&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;message&amp;#34;: &amp;#34;Please provide the user account (login name):&amp;#34;,
            &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;AskName&amp;#34;
    },
(...)
    {
        &amp;#34;name&amp;#34;: &amp;#34;LoadRoles&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;rolesList&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;script&amp;#34;: &amp;#34;${script(./scripts/create-mes-user/load_roles.ts)}&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;AskPrimaryRole&amp;#34;
    },
    {
        &amp;#34;name&amp;#34;: &amp;#34;AskPrimaryRole&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;primaryRole&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;message&amp;#34;: &amp;#34;Please select the primary role for this user:&amp;#34;,
            &amp;#34;dataType&amp;#34;: &amp;#34;Enum&amp;#34;,
            &amp;#34;settings&amp;#34;: {
                &amp;#34;data&amp;#34;: [ &amp;#34;this.answers.rolesList;&amp;#34; ]
            }
        },
        &amp;#34;next&amp;#34;: &amp;#34;AskAuthStrategy&amp;#34;
    },
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;LoadRoles&lt;/code&gt; is a &lt;strong&gt;Script&lt;/strong&gt; step. Script steps are steps that execute a browser side code script. Scripts can be directly referenced, or can be stored in a separate typescript file and then bundled into the scenario.&lt;/p&gt;
&lt;p&gt;A Script is defined by having a start and end token (&lt;em&gt;// PackagePacker: Start of Script&lt;/em&gt; &lt;em&gt;// PackagePacker: End of Script&lt;/em&gt;). Scripts have access to several utilities and can perform actions or query the MES.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import { Cmf } from &amp;#34;cmf-lbos&amp;#34;;
import { ScriptScopeBase } from &amp;#34;../types/globals&amp;#34;;
class LoadRolesWrapper extends ScriptScopeBase {
    private loadRoles() {
        // PackagePacker: Start of Script
        (async () =&amp;gt; {
            const output = await this.System.call(
              new Cmf.Foundation.BusinessOrchestration.SecurityManagement.InputObjects
                .GetAllRolesInput()) 
                as Cmf.Foundation.BusinessOrchestration.SecurityManagement.OutputObjects.GetAllRolesOutput;
            return output.Roles.map((r: any) =&amp;gt; ({ Id: r.Id, Name: r.Name }));
        })();
        // PackagePacker: End of Script
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;LoadRoles&lt;/code&gt; script is quite simple, it retrieves all the roles and prepares them in a format that can be then ingested by the Enum Question step.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: Result keys of the steps are available in the &lt;strong&gt;this.answers&lt;/strong&gt; object.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; With the use of the &lt;code&gt;debugger;&lt;/code&gt; token you are able to debug your script execution in your browser devtools.&lt;/span&gt;
&lt;/div&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;(...)
  {
      &amp;#34;name&amp;#34;: &amp;#34;CreateUser&amp;#34;,
      &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
      &amp;#34;settings&amp;#34;: {
          &amp;#34;script&amp;#34;: &amp;#34;${script(./scripts/create-mes-user/create_user.ts)}&amp;#34;
      },
      &amp;#34;next&amp;#34;: &amp;#34;&amp;#34;
  }
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;CreateUser&lt;/code&gt; is similar to the &lt;code&gt;LoadRoles&lt;/code&gt;, but invokes a slightly more complex script.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  import { Cmf } from &amp;#34;cmf-lbos&amp;#34;;
  import { ScriptScopeBase } from &amp;#34;../types/globals&amp;#34;;
class CreateUserWrapper extends ScriptScopeBase {
    private createUser() {
        // PackagePacker: Start of Script
        (async () =&amp;gt; {
            const rolesList: Array&amp;lt;{ Id: string; Name: string }&amp;gt; = this.answers.rolesList ?? [];
// The Enum question with {Id, Name}[] data stores the selected Id;
            // fall back to Name match in case the runtime stores the Name instead.
            const selectedRole =
                rolesList?.find((r) =&amp;gt; r.Id === this.answers.primaryRole) ??
                rolesList.find((r) =&amp;gt; r.Name === this.answers.primaryRole);
const user = new Cmf.Foundation.Security.User();
            user.IsEnabled = true;
            user.UserAccount = this.answers.userAccount;
            user.UserName = this.answers.userName;
            user.MailAddress = this.answers.mailAddress;
            user.AuthenticationStrategy = this.answers.authenticationStrategy;
            user.Password = this.answers.password;
const role = new Cmf.Foundation.Security.Role();
            role.Id = selectedRole.Id;
            role.Name = selectedRole.Name;
            user.PrimaryRole = role;
const input = new Cmf.Foundation.BusinessOrchestration.SecurityManagement.InputObjects
                                .CreateUserInput();
            input.User = user;
            input.Password = this.answers.password;
            input.IgnoreLastServiceId = true;
            input.IsToSkipSyncFromAd = true;
            input.PlainSecureFields = true;
await this.System.call(input);
        })();
        // PackagePacker: End of Script
    }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script uses the information from the previous questions steps, stored in the this.answers and invokes the CreateUser MES service.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating a SECS-GEM Data Collection Workflow
&lt;div id="creating-a-secs-gem-data-collection-workflow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-secs-gem-data-collection-workflow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Business Scenarios are also a good way to tackle harder use cases.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;SECS-GEM is a very common equipment integration standard in the world of semiconductors.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/secsgem_tutorial/"
title="Secs-Gem Tutorial"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-2').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-2"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-2').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/secsgem_tutorial/"
title="Secs-Gem Tutorial"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-2');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;p&gt;A secs-gem integration, even using a low code platform is often seen as something very complex and that may touch in different system entities. Check my post for more insight on this &lt;a
href="https://j-roque.com/posts/20260105-gem300/"
target="_blank"
&gt;GEM300&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;With a Business Scenario we can create at least a template approach so the user can have something to start using and build upon.&lt;/p&gt;
&lt;p&gt;In this scenario the goal was to generate a secs-gem integration that will map secs-gem events to MES data collections.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Secs-Gem Integration"
src="https://image.j-roque.com/posts/20260611-businessscenario/secs_gem_scenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This scenario serves to show how we can create more complex scenarios, that are very simple for the user to execute, but that can already have some sophistication.&lt;/p&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
graph TD
classDef startClass fill:#0074b7,stroke:#005490,color:#ffffff
classDef finallyClass fill:#2e7d32,stroke:#1b5e20,color:#ffffff
classDef questionClass fill:#5b9bd5,stroke:#3a78b5,color:#ffffff
classDef scriptClass fill:#d06b1f,stroke:#a05010,color:#ffffff
classDef conditionClass fill:#7b4ea0,stroke:#5a3278,color:#ffffff
StartStep["Start Step"]:::startClass --&gt; NameIntegration
NameIntegration["Question:
NameIntegration
(integrationName)"]:::questionClass --&gt; ValidateResourceIsConnectIoTEnabled
ValidateResourceIsConnectIoTEnabled["Script:
ValidateResourceIsConnectIoTEnabled
(iotEnabledEntities)"]:::scriptClass --&gt; AskIpAddressPort
AskIpAddressPort["Question:
AskIpAddressPort
(ipAddressPort)"]:::questionClass --&gt; AskMode
AskMode["Question:
AskMode
(mode)"]:::questionClass --&gt; RetrieveProtocolSettings
RetrieveProtocolSettings["Script:
RetrieveProtocolSettings"]:::scriptClass --&gt; AskMoreCommunicationSettings
AskMoreCommunicationSettings["Question:
AskMoreCommunicationSettings
(moreCommSettings)"]:::questionClass --&gt; ConditionMoreCommSettings
ConditionMoreCommSettings["Condition:
ConditionMoreCommSettings"]:::conditionClass --&gt;|moreCommSettings == true| CommunicationSettingsToChange
ConditionMoreCommSettings --&gt;|moreCommSettings == false| AskCollectMachineEventData
CommunicationSettingsToChange["Question:
CommunicationSettingsToChange
(settingToChange)"]:::questionClass --&gt; DataTypeRouting
DataTypeRouting["Script:
DataTypeRouting"]:::scriptClass --&gt; InputTypeCondition
InputTypeCondition["Condition:
InputTypeCondition"]:::conditionClass --&gt;|paramDatatypeRender == 'datetime'| AskDateTime
InputTypeCondition --&gt;|paramDatatypeRender == 'number'| AskNumber
InputTypeCondition --&gt;|paramDatatypeRender == 'enum'| AskEnum
InputTypeCondition --&gt;|paramDatatypeRender == 'string'| AskString
InputTypeCondition --&gt;|paramDatatypeRender == 'boolean'| AskBoolean
InputTypeCondition --&gt;|paramDatatypeRender == 'object'| AskObject
AskDateTime["Question:
AskDateTime
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskNumber["Question:
AskNumber
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskString["Question:
AskString
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskEnum["Question:
AskEnum
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskBoolean["Question:
AskBoolean
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskObject["Question:
AskObject
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
SetProtocolSetting["Script:
SetProtocolSetting"]:::scriptClass --&gt; AskMoreSettings
AskMoreSettings["Question:
AskMoreSettings
(moreSettings)"]:::questionClass --&gt; ConditionMoreSettings
ConditionMoreSettings["Condition:
ConditionMoreSettings"]:::conditionClass --&gt;|moreSettings == true| CommunicationSettingsToChange
ConditionMoreSettings --&gt;|moreSettings == false| AskCollectMachineEventData
AskCollectMachineEventData["Question:
AskCollectMachineEventData
(collectMachineEventData)"]:::questionClass --&gt; ConditionCollectMachineEventData
ConditionCollectMachineEventData["Condition:
ConditionCollectMachineEventData"]:::conditionClass --&gt;|collectMachineEventData == true| AskDataCollection
AskDataCollection["Question:
AskDataCollection
(currentDataCollection)"]:::questionClass --&gt; AskDataCollectionMode
AskDataCollectionMode["Question:
AskDataCollectionMode
(currentDataCollectionMode)"]:::questionClass --&gt; AskEventCEID
AskEventCEID["Question:
AskEventCEID
(eventCEID)"]:::questionClass --&gt; SetEvent
SetEvent["Script:
SetEvent"]:::scriptClass --&gt; AskRPTID
AskRPTID["Question:
AskRPTID
(rptid)"]:::questionClass --&gt; SetRPTID
SetRPTID["Script:
SetRPTID"]:::scriptClass --&gt; AskSVID
AskSVID["Question:
AskSVID
(svid)"]:::questionClass --&gt; AskSVIDDataType
AskSVIDDataType["Question:
AskSVIDDataType
(svidDataType)"]:::questionClass --&gt; SetSVID
SetSVID["Script:
SetSVID"]:::scriptClass --&gt; AskMapToParameter
AskMapToParameter["Question:
AskMapToParameter
(mapToParameter)"]:::questionClass --&gt; ConditionMapToParameter
ConditionMapToParameter["Condition:
ConditionMapToParameter"]:::conditionClass --&gt;|mapToParameter == true| AskChooseDCParameter
ConditionMapToParameter --&gt;|mapToParameter == false| AskAddAdditionalSVID
AskChooseDCParameter["Question:
AskChooseDCParameter
(dcParameter)"]:::questionClass --&gt; SetDCParamToSVID
SetDCParamToSVID["Script:
SetDCParamToSVID"]:::scriptClass --&gt; AskAddAdditionalSVID
AskAddAdditionalSVID["Question:
AskAddAdditionalSVID
(addAnotherSVID)"]:::questionClass --&gt; ConditionAddSVID
ConditionAddSVID["Condition:
ConditionAddSVID"]:::conditionClass --&gt;|addAnotherSVID == true| AskSVID
ConditionAddSVID --&gt;|addAnotherSVID == false| AskAddAdditionalRPTID
AskAddAdditionalRPTID["Question:
AskAddAdditionalRPTID
(addAnotherRPTID)"]:::questionClass --&gt; ConditionAddRPTID
ConditionAddRPTID["Condition:
ConditionAddRPTID"]:::conditionClass --&gt;|addAnotherRPTID == true| AskRPTID
ConditionAddRPTID --&gt;|addAnotherRPTID == false| AskAddAdditionalEvents
AskAddAdditionalEvents["Question:
AskAddAdditionalEvents
(addAnotherEvent)"]:::questionClass --&gt; ConditionAddEvent
ConditionAddEvent["Condition:
ConditionAddEvent"]:::conditionClass --&gt;|addAnotherEvent == true| AskEventCEID
ConditionAddEvent --&gt;|addAnotherEvent == false| AskAddAdditionalDataCollections
AskAddAdditionalDataCollections["Question:
AskAddAdditionalDataCollections
(addAnotherDC)"]:::questionClass --&gt; ConditionAddDC
ConditionAddDC["Condition:
ConditionAddDC"]:::conditionClass --&gt;|addAnotherDC == true| AskDataCollection
GenerateSecsGemIntegration["Script:
GenerateSecsGemIntegration"]:::scriptClass
FinallyStep["Finally Step"]:::finallyClass --&gt; GenerateSecsGemIntegration
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;br&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Note:&lt;/strong&gt; In this scenario the outcome is not a action in the MES but a master data package that the user can then validate and load. For complex, actions this allows the user to be confident into what he is uploading into the system. It also, provides a template the user can then build upon..&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;This scenario shows interesting patterns as having the ability to create flows that are continuously reused and how to leverage information from the MES.&lt;/p&gt;
&lt;p&gt;Deep diving on this implementation would be somewhat extensive and would defeat the purpose of a blog post. If you are interested to see how this scenario is built feel free to check it at &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/Secs-Gem%20Business%20Scenario/Cmf.Custom.MESProject.IoT/Cmf.Custom.MESProject.IoT.Packages/src/connect-iot-business-scenarios-ignite"
target="_blank"
&gt;business-scenarios&lt;/a&gt;. I will focus on some interesting patterns.&lt;/p&gt;
&lt;h3 class="relative group"&gt;DataType Routing
&lt;div id="datatype-routing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#datatype-routing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We are extracting information on what are the equipment settings for secs-gem from the information in the iot package. Each setting has its own datatype, in other words, some settings may be strings, decimals, integers, booleans, etc. One of the advantages of the questions is the ability to render the input field to the type that the user has to specify, this prevents a lot of human error.&lt;/p&gt;
&lt;p&gt;In order to offer this to the user and due to the fact that for this scenario the setting datatype is dynamic depending on the setting, we can create a datatype routing. Depending on the setting type we route it to the correct question datatype.&lt;/p&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
graph TD
classDef scriptClass fill:#d06b1f,stroke:#a05010,color:#ffffff
classDef conditionClass fill:#7b4ea0,stroke:#5a3278,color:#ffffff
classDef questionClass fill:#5b9bd5,stroke:#3a78b5,color:#ffffff
DataTypeRouting["Script:
DataTypeRouting"]:::scriptClass --&gt; InputTypeCondition
InputTypeCondition["Condition:
InputTypeCondition"]:::conditionClass --&gt;|paramDatatypeRender == 'datetime'| AskDateTime
InputTypeCondition --&gt;|paramDatatypeRender == 'number'| AskNumber
InputTypeCondition --&gt;|paramDatatypeRender == 'enum'| AskEnum
InputTypeCondition --&gt;|paramDatatypeRender == 'string'| AskString
InputTypeCondition --&gt;|paramDatatypeRender == 'boolean'| AskBoolean
InputTypeCondition --&gt;|paramDatatypeRender == 'object'| AskObject
AskDateTime["Question:
AskDateTime
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskNumber["Question:
AskNumber
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskString["Question:
AskString
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskEnum["Question:
AskEnum
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskBoolean["Question:
AskBoolean
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
AskObject["Question:
AskObject
(settingNewValue)"]:::questionClass --&gt; SetProtocolSetting
SetProtocolSetting["Script:
SetProtocolSetting"]:::scriptClass
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;h3 class="relative group"&gt;Generating the Master Data
&lt;div id="generating-the-master-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#generating-the-master-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In this scenario the end goal is to generate a master data package. The system offers a robust set of utilities to help build the master data. It implements a &lt;a
href="https://refactoring.guru/design-patterns/builder"
target="_blank"
&gt;builder pattern&lt;/a&gt;, where the user can through the script build the master data.&lt;/p&gt;
&lt;p&gt;The director is responsible for supplying a set of utilities in order for the user to add entities to the master data. We can see an example of adding configuration entries to our master data.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
    //#region Config
    const parentConfig = {
        parentPath: &amp;#34;/Cmf/Custom/ConnectIoT&amp;#34;,
        name: `${this.answers.integrationName}`,
        valueType: &amp;#34;&amp;#34;,
        value: &amp;#34;&amp;#34;
    };
    await this.masterdataDirector.builder
        .addConfig(parentConfig.parentPath, parentConfig.name, parentConfig.value, parentConfig.valueType);
const ipAndAddressConfig = {
        parentPath: `${parentConfigPath}`,
        name: `ipAndAddress`,
        valueType: &amp;#34;string&amp;#34;,
        value: this.answers.ipAddressPort
    };
    await this.masterdataDirector.builder
        .addConfig(ipAndAddressConfig.parentPath, ipAndAddressConfig.name, ipAndAddressConfig.value, ipAndAddressConfig.valueType);
//#endregion Config
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Connect IoT workflows the director also provides a specific builder for workflows, the workflowBuilder. For the workflow use case the goal is to build the entity automation controller and pass on all the information it requires, to do this the director supplies a &lt;code&gt;.BuildController()&lt;/code&gt; method.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
    //#region Setup Workflow
    // Add Driver Event Task
    const rootTaskId = (await this.workflowBuilder.addRootTask(
        &amp;#34;@criticalmanufacturing/connect-iot-controller-engine-core-tasks&amp;#34;,
        &amp;#34;driverEvent&amp;#34;,
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        driverAlias
    )).id;
(...)
    // Add Equipment Config Task
    await this.workflowBuilder.addTask(
        rootTaskId,
        &amp;#34;handler&amp;#34;,
        &amp;#34;@criticalmanufacturing/connect-iot-controller-engine-core-tasks&amp;#34;,
        &amp;#34;equipmentConfig&amp;#34;,
        { _inputs: equipmentConfigInputs },
        [
            {
                &amp;#34;name&amp;#34;: &amp;#34;networkAddress&amp;#34;,
                &amp;#34;displayName&amp;#34;: &amp;#34;networkAddress&amp;#34;,
                &amp;#34;value&amp;#34;: `{{ $${configTask.name}.ipAddressAndPort.split(&amp;#39;:&amp;#39;)[0] }}`,
                &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;
            },
            {
                &amp;#34;name&amp;#34;: &amp;#34;networkPort&amp;#34;,
                &amp;#34;displayName&amp;#34;: &amp;#34;networkPort&amp;#34;,
                &amp;#34;value&amp;#34;: `{{ $${configTask.name}.ipAddressAndPort.split(&amp;#39;:&amp;#39;)[1] }}`,
                &amp;#34;dataType&amp;#34;: &amp;#34;Integer&amp;#34;
            }
        ],
        undefined,
        undefined,
        undefined,
        driverAlias,
        undefined,
        expressions
    );
(...)
const workflows: any[] = [];
    const controllerSetupWorkflow = {
        automationController: `${this.answers.integrationName} Controller`,
        name: `Setup`,
        displayName: `Setup`,
        workflow: this.workflowBuilder.getWorkflow(),
        order: 1
    };
(...)
    workflows.push(controllerSetupWorkflow);
(...)
    await this.masterdataDirector.buildController(controller, [controllerDriverDefinition], workflows);
    //#endregion Build Controller
})();
// PackagePacker: End of Script
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note how the two tasks differ, the first task is the root task. The root task is the task that will trigger the workflow, in order to nest the tasks, we pass on the root id of the task where it should nest under. This is how we create the control flow execution tree.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Control Flow"
src="https://image.j-roque.com/posts/20260611-businessscenario/controlflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Build your own simple scenarios and create cleaner workflows for your everyday activities, this is what the Business Scenarios are all about.&lt;/p&gt;
&lt;p&gt;Business Scenarios trade LLM expressiveness for guaranteed, auditable outcomes. That trade can be worth making in a regulated production environment.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260611-businessscenario/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260611-businessscenario/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Scheduled Action - Planned roll-outs made easy</title>
<link>https://j-roque.com/posts/20260526-scheduledaction/</link>
<pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260526-scheduledaction/</guid>
<description>Planned roll-outs made easy</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260526-scheduledaction/featured.png" alt="Scheduled Action - Planned roll-outs made easy" /&gt;&lt;/p&gt;&lt;p&gt;The Critical Manufacturing MES version &lt;code&gt;11.3&lt;/code&gt; brought about a set of exciting features. One that grabbed my attention were the &lt;strong&gt;Automation Scheduled Actions&lt;/strong&gt;, not just for the use cases that they solve out-of-the-box but out the ability to extend it to solve additional use-cases.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Run ASD"
src="https://image.j-roque.com/posts/20260526-scheduledaction/ASD_MAO.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;The problem of scale. Solutions that work with dozens may start failing with hundreds and thousands.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When large factories use an MES and start creating integrations with other systems, most of them are 1-1 or 1 to a small subset.&lt;/p&gt;
&lt;p&gt;A customer, has &lt;strong&gt;one ERP&lt;/strong&gt;, &lt;strong&gt;one PLM&lt;/strong&gt;, maybe a &lt;strong&gt;small subset of QMS&lt;/strong&gt; systems.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Low Scale Integrations"
src="https://image.j-roque.com/posts/20260526-scheduledaction/thirdparties-lowscale.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In these scenarios, manual interaction for &lt;code&gt;start/stop operations&lt;/code&gt;, &lt;code&gt;updates&lt;/code&gt; and &lt;code&gt;roll-outs&lt;/code&gt; are fine to do by a button press.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The challenge comes when we factor in the most prolific integration type in the shop-floor, integrations with equipment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="At Scale Integrations"
src="https://image.j-roque.com/posts/20260526-scheduledaction/thirdparties-scale.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Machines can easily rise to &lt;strong&gt;hundreds of integrations&lt;/strong&gt; in the shopfloor, they are &lt;strong&gt;complex, varied and have strict rollout plans&lt;/strong&gt;. Machine integrations can go from controlling parts of a machine, to controlling areas or machine groups.&lt;/p&gt;
&lt;p&gt;This means that &lt;strong&gt;even in a simple factory your number of integrations starts piling up&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Our system was already fine-tuned to handle &lt;strong&gt;remote start/stop, version control and update/rollback control&lt;/strong&gt;, but was missing a mechanism for planned roll-outs. Anyone that has ever wanted to do a planned version rollout of a version in a factory knows why. It&amp;rsquo;s very complex to define when a machine can be updated and how to define if it was a successful rollout or not.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why we built a mechanism that is tailored for the common use case, but also allows for the user to customize it with his own business rules.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Lifecycle of the Automation Scheduled Action
&lt;div id="lifecycle-of-the-automation-scheduled-action" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lifecycle-of-the-automation-scheduled-action" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The scheduled action is broken into two different concepts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Definition -&lt;code&gt;AutomationScheduledActionDefinition&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Execution -&lt;code&gt;AutomationScheduledActionTask&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One scheduled action, can trigger several tasks.&lt;/p&gt;
&lt;p&gt;A scheduled action definition has a set of steps that we can configure either with pre-defined actions or with custom business rules.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_context_resolution/"
target="_blank"
&gt;Context Resolution&lt;/a&gt; - Defines the target entity and resolves it to create tasks for each entity instance.&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_detectors/"
target="_blank"
&gt;Detectors&lt;/a&gt; - Defines how the system determines whether a target instance is currently in a safe state to act on.&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_actions/"
target="_blank"
&gt;Actions&lt;/a&gt; - Defines the maintenance action to execute when all checks pass.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Context"
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/diagrams/processing_flow.drawio.svg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The main steps of the lifecycle are the &lt;strong&gt;Context Resolution&lt;/strong&gt; which will define what are the entities that will have a pending task, the &lt;strong&gt;Detectors&lt;/strong&gt; which will decide if the task is ready to be executed and finally the &lt;strong&gt;Actions&lt;/strong&gt; which will be the work we want done. Each task will have its own lifecycle and completion rate.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Task Lifecycle"
src="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/diagrams/cycle_evaluation.drawio.svg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Additionally the user may also configure, &lt;strong&gt;notifications&lt;/strong&gt; and &lt;strong&gt;gatekeeping/validation&lt;/strong&gt; steps.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/#notifications"
target="_blank"
&gt;Notifications&lt;/a&gt; - Defines the optional notification templates an Automation Scheduled Action task can use during its lifecycle.&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_preconditions/"
target="_blank"
&gt;Pre-Conditions&lt;/a&gt; - Defines the time-based or global constraints that must pass before readiness evaluation continues.&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_acceptance_gates/"
target="_blank"
&gt;Acceptance Gates&lt;/a&gt; - Defines extra checks that run after the detector reports ready and confirms that it is still safe to proceed.&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-scheduled-action/elements/automation_scheduled_action_post_actions_validations/"
target="_blank"
&gt;Post-Action Validations&lt;/a&gt; - Defines the checks performed after the action runs to confirm it completed successfully.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;The Standard Use-Cases
&lt;div id="the-standard-use-cases" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-standard-use-cases" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The main uses cases for this feature are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Moving&lt;/strong&gt; the automation &lt;strong&gt;to a new automation manager&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Updating an automation controller instance&lt;/strong&gt; to a new version.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In &lt;a
href="https://j-roque.com/posts/20250325-connectiotstructure/"
target="_blank"
&gt;Connect IoT Structure&lt;/a&gt; we already did a deep dive on how Connect IoT is structured and what are its main relations and entities.&lt;/p&gt;
&lt;p&gt;In a nutshell, Connect IoT has a definition part (&lt;code&gt;Automation Protocol&lt;/code&gt;/&lt;code&gt;Automation Driver Definition&lt;/code&gt;/&lt;code&gt;Automation Controller&lt;/code&gt;), which is responsible for defining the interface logic and the business logic of the low code workflow:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Definition part of Connect IoT "
src="https://image.j-roque.com/posts/20250325-observability/img/relationprotocoldriverdefinition.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Then it has the execution part that links the drivers and controller to an MES entity, this is called an &lt;code&gt;Automation Controller Instance&lt;/code&gt;. The &lt;strong&gt;same Automation Controller can be reused across N number of instances&lt;/strong&gt;, this makes it so a controller created to control a machine type, can be reused across different machine types. The &lt;code&gt;Automation Manager&lt;/code&gt; maps to the process that will host the instances.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Execution part of Connect IoT"
src="https://image.j-roque.com/posts/20250325-observability/img/relationmanagercontrollerdrivers.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at what are the main use cases tackled with the new feature.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Changing the Automation Instances to Different Managers
&lt;div id="changing-the-automation-instances-to-different-managers" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#changing-the-automation-instances-to-different-managers" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;manager&lt;/strong&gt; is the process that is &lt;strong&gt;hosting a set number of instances&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This process is running on a particular VM or cluster, imagine that you want to perform some kind of maintenance on the cluster or in the VM.&lt;/p&gt;
&lt;p&gt;You can schedule an &lt;em&gt;Automation Scheduled Action&lt;/em&gt; to when appropriate (e.g. no materials in resources being controlled by those instances) &lt;strong&gt;migrate all the instances to a new manager&lt;/strong&gt; in a different machine, perform your machine maintenance and then schedule them back to the original machine.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Move Manager"
src="https://image.j-roque.com/posts/20260526-scheduledaction/move_manager.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Complete Tutorial:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/automation-scheduled-action/scenarios/01-change-automation-manager-cookie-lines/"
target="_blank"
&gt;Move Controller Instances to a new Automation Manager&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class="relative group"&gt;Updating a Controller version
&lt;div id="updating-a-controller-version" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#updating-a-controller-version" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;controller version of the instance&lt;/strong&gt; will dictate what is the &lt;strong&gt;definition that is running&lt;/strong&gt; for a particular automation.&lt;/p&gt;
&lt;p&gt;Imagine, you have an Automation Controller &lt;strong&gt;Testing Machine A.1&lt;/strong&gt;, this controller holds the definition for the integration of all your site testing machines.&lt;/p&gt;
&lt;p&gt;You did a new development adding a particular feature, rolled out the change for a particular machine and know want to roll out the change for all the testing machines on your shopfloor. Previously, you would have to go one by one and perform the update, now you can define your Automation Scheduled Action, with your particular subset of acceptable criteria for update (e.g no materials in resource) and roll-out the update autonomously for all machines.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Update Controller Version"
src="https://image.j-roque.com/posts/20260526-scheduledaction/update_controller.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Complete Tutorial:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/automation-scheduled-action/scenarios/03-sanitation-window-ready-delay/"
target="_blank"
&gt;Update Controller Version when there are No Materials in the Resource &lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Using it for Other Entities
&lt;div id="using-it-for-other-entities" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#using-it-for-other-entities" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Most of the out-of-the-box features are tailored for the standard use cases but it is still open for customization and for other uses.&lt;/p&gt;
&lt;p&gt;We can think of a use case where we want our engineer to be notified when the machine is available to perform maintenance on a PLC. The PLC may be controlling several machines and if it&amp;rsquo;s available or not may depend on business rules.&lt;/p&gt;
&lt;p&gt;In our use case we have an opc-ua server mapped as an MES Resource Component and then shared between multiple other resources, a classic OPC-UA line case. Our goal now is to open a maintenance activity when the line is not producing (e.g. no materials in resources).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Component Maintenance"
src="https://image.j-roque.com/posts/20260526-scheduledaction/component_maintenance.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Check out our &lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/connect-iot-equipment-integration/automation-scheduled-action/scenarios/05-custom-dee-quality-and-allergen-flow/?h=maintenance"
target="_blank"
&gt;Custom DEE Action at Every Stage tutorial&lt;/a&gt; for a detailed guide of the use of customization this feature&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Framing it in the Lifecycle
&lt;div id="framing-it-in-the-lifecycle" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#framing-it-in-the-lifecycle" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;First, we need to assert our context. In our use-case the &lt;strong&gt;Context&lt;/strong&gt; will be all the component resources that are connected with automation.&lt;/p&gt;
&lt;p&gt;Our &lt;strong&gt;Detector&lt;/strong&gt; will change to ready when there are &lt;strong&gt;no materials in each of the resources&lt;/strong&gt; that are using the component resource.&lt;/p&gt;
&lt;p&gt;Finally, our &lt;strong&gt;Action&lt;/strong&gt; is to &lt;strong&gt;create the Maintenance Activity Order&lt;/strong&gt; for the component resource and &lt;strong&gt;change the Resources to engineering&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you are curious about how maintenances work, check out our &lt;a
href="https://help.criticalmanufacturing.com/userguide/business-data/maintenance-activity-order/?h=maintenance"
target="_blank"
&gt;help&lt;/a&gt; and our &lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/maintenance-management/maintenance-management/?h=maintenance"
target="_blank"
&gt;deep dive&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Building It
&lt;div id="building-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We will need to create two simple &lt;a
href="https://help.criticalmanufacturing.com/userguide/administration/dee_actions/?h=dee"
target="_blank"
&gt;DEEs&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The first DEE will retrieve all the component resources with a link to automation:&lt;/p&gt;
&lt;p&gt;I used our &lt;strong&gt;no code query builder&lt;/strong&gt; to generate a query that will give me all the resources of processing type component.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Query Builder"
src="https://image.j-roque.com/posts/20260526-scheduledaction/query_builder.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  // System
  UseReference(&amp;#34;&amp;#34;, &amp;#34;System.Data&amp;#34;);
// Foundation
  UseReference(&amp;#34;Cmf.Foundation.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Foundation.BusinessObjects.QueryObject&amp;#34;);
// Navigo
  UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessObjects.Abstractions&amp;#34;);
var serviceProvider = (IServiceProvider)Input[&amp;#34;ServiceProvider&amp;#34;];
  IEntityFactory entityFactory = serviceProvider.GetService&amp;lt;IEntityFactory&amp;gt;();
// Generated Query through the Query Builder
  #region Query GetAllResourcesComponentsWithConnectionToAutomation
IQueryObject query = entityFactory.Create&amp;lt;IQueryObject&amp;gt;();
  query.Description = &amp;#34;&amp;#34;;
  query.EntityTypeName = &amp;#34;Resource&amp;#34;;
  query.Name = &amp;#34;GetAllResourcesComponentsWithConnectionToAutomation&amp;#34;;
  query.Query = new Query();
  query.Query.Distinct = true;
  query.Query.Filters = new FilterCollection()
  {
    new Cmf.Foundation.BusinessObjects.QueryObject.Filter()
    {
      Name = &amp;#34;ProcessingType&amp;#34;,
      ObjectName = &amp;#34;Resource&amp;#34;,
      ObjectAlias = &amp;#34;Resource_1&amp;#34;,
      Operator = Cmf.Foundation.Common.FieldOperator.IsEqualTo,
      Value = Cmf.Navigo.BusinessObjects.ProcessingType.Component,
      LogicalOperator = Cmf.Foundation.Common.LogicalOperator.Nothing,
      FilterType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.FilterType.Normal,
    },
    new Cmf.Foundation.BusinessObjects.QueryObject.Filter()
    {
      Name = &amp;#34;UniversalState&amp;#34;,
      ObjectName = &amp;#34;Resource&amp;#34;,
      ObjectAlias = &amp;#34;Resource_1&amp;#34;,
      Operator = Cmf.Foundation.Common.FieldOperator.IsEqualTo,
      Value = Cmf.Foundation.Common.Base.UniversalState.Active,
      LogicalOperator = Cmf.Foundation.Common.LogicalOperator.Nothing,
      FilterType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.FilterType.Normal,
    }
  };
  query.Query.Fields = new FieldCollection()
  {
    new Field()
    {
      Alias = &amp;#34;Name&amp;#34;,
      ObjectName = &amp;#34;Resource&amp;#34;,
      ObjectAlias = &amp;#34;Resource_1&amp;#34;,
      IsUserAttribute = false,
      Name = &amp;#34;Name&amp;#34;,
      Position = 1,
      Sort = Cmf.Foundation.Common.FieldSort.NoSort
    }
  };
DataSet resultDataSet = query.Execute(false, null);
IResourceCollection resourcesOfTypeComponent = entityFactory.CreateCollection&amp;lt;IResourceCollection&amp;gt;();
bool hasData = resultDataSet != null
      &amp;amp;&amp;amp; resultDataSet.Tables != null
      &amp;amp;&amp;amp; resultDataSet.Tables.Count &amp;gt; 0
      &amp;amp;&amp;amp; resultDataSet.Tables[0].Rows != null
      &amp;amp;&amp;amp; resultDataSet.Tables[0].Rows.Count &amp;gt; 0;
#endregion Query GetAllResourcesComponentsWithConnectionToAutomation
if (hasData)
  {
      foreach (DataRow row in resultDataSet.Tables[0].Rows)
      {
          IResource resourceComponent = entityFactory.Create&amp;lt;IResource&amp;gt;();
          resourceComponent.Name = row.Field&amp;lt;string&amp;gt;(&amp;#34;Name&amp;#34;);
          resourcesOfTypeComponent.Add(resourceComponent);
      }
if (resourcesOfTypeComponent.Count &amp;gt; 0)
      {
          resourcesOfTypeComponent.Load();
// Only send Resources with Connections to Automation
          var result = resourcesOfTypeComponent.Where(res =&amp;gt; res.GetAutomationControllerInstance() != null);
// Entities that will be received for ASD Task creation
          Input[&amp;#34;Result&amp;#34;] = resourcesOfTypeComponent?.ToArray();
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our DEE will &lt;strong&gt;run the query&lt;/strong&gt; and if there are results, it will check if there&amp;rsquo;s any &lt;code&gt;AutomationControllerInstance&lt;/code&gt; &lt;strong&gt;linked to the Resource&lt;/strong&gt; and then add the &lt;strong&gt;outcome to the result Input&lt;/strong&gt; object.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The second DEE will be responsible for &lt;strong&gt;opening maintenance activities&lt;/strong&gt; and change the &lt;strong&gt;component resource and the parent resources to SEMI-E10 state engineering&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For this case I created already a Maintenance Plan and Maintenance Plan Instance for my component Resource.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  // Navigo
  UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessObjects.Abstractions&amp;#34;);
  UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessObjects&amp;#34;);
  UseReference(&amp;#34;Cmf.Navigo.BusinessOrchestration.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.Abstractions&amp;#34;);
  UseReference(&amp;#34;Cmf.Navigo.BusinessOrchestration.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.MaintenanceManagement.InputObjects&amp;#34;);
  UseReference(&amp;#34;Cmf.Navigo.BusinessOrchestration.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.ResourceManagement.InputObjects&amp;#34;);
// Foundation
  UseReference(&amp;#34;Cmf.Foundation.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Foundation.BusinessObjects.Abstractions&amp;#34;);
// System
  UseReference(&amp;#34;&amp;#34;, &amp;#34;System.Collections.ObjectModel&amp;#34;);
if (!Input.TryGetValue(&amp;#34;Entity&amp;#34;, out object entity) || entity == null)
      throw new ArgumentNullException(&amp;#34;Entity&amp;#34;, $&amp;#34;Input &amp;#39;{entity}&amp;#39; is required.&amp;#34;);
#region Constants
  const string MaintenanceActivityName     = &amp;#34;Perform Maintenance on PLC&amp;#34;;
  string MaintenancePlanInstanceName = $&amp;#34;PLC Maintenance-{(entity as IEntity).Name}-001&amp;#34;;
  const string OwnerRoleName               = &amp;#34;Administrators&amp;#34;;
  const string ResourceStateModelName      = &amp;#34;SEMI E10&amp;#34;;
  const string TargetStateName             = &amp;#34;Engineering&amp;#34;;
  #endregion Constants
var serviceProvider = (IServiceProvider)Input[&amp;#34;ServiceProvider&amp;#34;];
  IEntityFactory entityFactory                       = serviceProvider.GetService&amp;lt;IEntityFactory&amp;gt;();
  IMaintenanceOrchestration maintenanceOrchestration = serviceProvider.GetRequiredService&amp;lt;IMaintenanceOrchestration&amp;gt;();
  IResourceOrchestration resourceOrchestration       = serviceProvider.GetRequiredService&amp;lt;IResourceOrchestration&amp;gt;();
#region Create Maintenance Activity Order
  // Load MaintenanceActivity
  IMaintenanceActivity maintenanceActivity = entityFactory.Create&amp;lt;IMaintenanceActivity&amp;gt;();
  maintenanceActivity.Load(MaintenanceActivityName, null);
// Load MaintenancePlanInstance
  IMaintenancePlanInstance planInstance = entityFactory.Create&amp;lt;IMaintenancePlanInstance&amp;gt;();
  planInstance.Load(MaintenancePlanInstanceName, null);
// Request Maintenance Activity Order
  var requestInput = new RequestMaintenanceActivityOrdersInput
  {
      ScheduleDate        = DateTime.UtcNow,
      RequestApprovalMode = Cmf.Navigo.BusinessObjects.ApprovalMode.ManualApproval,
      OrderReleaseMode    = Cmf.Navigo.BusinessObjects.ReleaseMode.AutoRelease,
      OwnerRole           = OwnerRoleName,
      RequestMaintenanceActivityOrderInput = new Collection&amp;lt;RequestMaintenanceActivityOrderInput&amp;gt;
      {
          new RequestMaintenanceActivityOrderInput
          {
              MaintenanceActivity     = maintenanceActivity,
              MaintenancePlanInstance = planInstance
          }
      }
  };
var requestOutput = maintenanceOrchestration.RequestMaintenanceActivityOrders(requestInput);
  Input[&amp;#34;RequestMaintenanceActivityOrdersOutput&amp;#34;] = requestOutput;
  IResource resource = planInstance.Resource;
#endregion Create Maintenance Activity Order
#region Change Resource States to Engineering
  // Load the resource fully, then retrieve its ascendent resources (depth 1 = direct parents)
  resource.Load(resource.Name, null);
  resource.GetAscendentResources(1);
IResourceCollection resourceList = resource.ParentResources;
  resourceList.Add(resource);
if (resourceList != null &amp;amp;&amp;amp; resourceList.Count &amp;gt; 0)
  {
      IStateModel stateModel = serviceProvider.GetService&amp;lt;IStateModel&amp;gt;();
      stateModel.Load(ResourceStateModelName);
var adjustInput = new AdjustResourcesStateInput
      {
          Resources                   = resourceList,
          StateModel                  = stateModel,
          StateModelStateName         = TargetStateName,
          IsToAdjustSubResourcesState = false
      };
var adjustOutput = resourceOrchestration.AdjustResourcesState(adjustInput);
      Input[&amp;#34;AdjustResourcesStateOutput&amp;#34;] = adjustOutput;
  }
  #endregion Change Resource States to Engineering
Input[&amp;#34;Result&amp;#34;] = true;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the &lt;strong&gt;Detector&lt;/strong&gt; of no materials in resource, we can leverage the DEE that the product ships out of the box &lt;code&gt;AutomationScheduledActionNoMaterialsOnResource&lt;/code&gt; and tweak it to also consider parent resources. This way we can use the out of the box option of &lt;code&gt;No Materials On Resource&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  UseReference(&amp;#34;&amp;#34;, &amp;#34;Cmf.Foundation.Common.Exceptions&amp;#34;);
var output = new Dictionary&amp;lt;string, object&amp;gt;();
if (Input.ContainsKey(&amp;#34;Resource&amp;#34;))
  {
      if (Input[&amp;#34;Resource&amp;#34;] is IResource resource)
      {
          resource.Load();
resource.GetAscendentResources(1);
IResourceCollection resourceList = resource.ParentResources;
          resourceList.Add(resource);
if (resourceList != null &amp;amp;&amp;amp; resourceList.Count &amp;gt; 0)
          {
              foreach(var resourceInList in resourceList) {
                  resourceInList.Load(resource);
                  resourceInList.LoadRelations(Cmf.Foundation.Common.Constants.MaterialResource);
if(resourceInList.ResourceMaterials != null &amp;amp;&amp;amp; resourceInList.ResourceMaterials.Any()) {
                      output.Add(&amp;#34;Result&amp;#34;, false);
                      return output;
                  }
              }
          }
      }
  }
  output.Add(&amp;#34;Result&amp;#34;, true);
  return output;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we will also want to &lt;strong&gt;notify our employees&lt;/strong&gt; that the &lt;strong&gt;task was run successfully&lt;/strong&gt; and they can now execute the maintenance.&lt;/p&gt;
&lt;p&gt;For that we can create a new &lt;strong&gt;Notification Template&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Notification Template"
src="https://image.j-roque.com/posts/20260526-scheduledaction/MAOASD_Template.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;An interesting feature is the ability of the use of &lt;strong&gt;tokens to construct our notification&lt;/strong&gt;. Notice how we have some elements between double curly braces. They will be replaced in runtime with the correct values.&lt;/p&gt;
&lt;p&gt;The system supports adding a notification in several task hooks Pre-Action, Success, Failure, and Ignored. For our use-case we will just hook it on the Success.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Running It
&lt;div id="running-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;I created a simple use case.&lt;/p&gt;
&lt;p&gt;I have two component resources &lt;strong&gt;OPC-UA Tester Server&lt;/strong&gt; and &lt;strong&gt;OPC-UA Tester Server 1&lt;/strong&gt; connected to test resources. &lt;code&gt;Tester-02&lt;/code&gt; is a &lt;strong&gt;parent resource&lt;/strong&gt; of the component resource &lt;code&gt;OPC-UA Tester Server&lt;/code&gt; and has a material in-process.&lt;/p&gt;
&lt;p&gt;The only one that is ready to run the scheduled action of creating a maintenance activity will be &lt;strong&gt;OPC-UA Tester Server 1&lt;/strong&gt;, due to the detector &lt;code&gt;No material on Resource&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If we abort the material in &lt;code&gt;Tester-02&lt;/code&gt;, in the next run, it will now be ready and will open a maintenance for &lt;strong&gt;OPC-UA Tester Server&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Run ASD"
src="https://image.j-roque.com/posts/20260526-scheduledaction/ASD_MAO.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;11.3 brought a feature with a lot of flexibility and that opens the door to new and exciting use cases. Hope to see people tinker with it and solving problems in creative ways.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260526-scheduledaction/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260526-scheduledaction/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Data Platform - Agentic Use-Case</title>
<link>https://j-roque.com/posts/20260414-odata-agentic/</link>
<pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260414-odata-agentic/</guid>
<description>Data Platform as a foundation for agentic use cases</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260414-odata-agentic/featured.png" alt="Data Platform - Agentic Use-Case" /&gt;&lt;/p&gt;&lt;p&gt;All the code presented here is available at &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/OData"
target="_blank"
&gt;github&lt;/a&gt; and &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/Python"
target="_blank"
&gt;github&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;What if instead of gibberish alarms, red lights and panic, you could have a detailed assessment of what went wrong and contextualized on what was going wrong before the catastrophical fail. Adding on to that, what if that report came with a set of probable causes and possible solutions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic OData Task"
src="https://image.j-roque.com/posts/20260414-odata-agentic/ai-alarms.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Leveraging the ToolBox
&lt;div id="leveraging-the-toolbox" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#leveraging-the-toolbox" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In previous posts we brought into the Connect IoT toolbox the universal language for analytics &lt;a
href="https://j-roque.com/posts/20260406-python-codetask/"
target="_blank"
&gt;python&lt;/a&gt; and we followed with strengthening our ability to use low code to query the CM Data Platform via &lt;a
href="https://j-roque.com/posts/20260406-python-codetask/"
target="_blank"
&gt;OData&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In this post I want to use all of these tools that we&amp;rsquo;ve been building and create a context good enough to feed an LLM.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Data Platform Low Code
&lt;div id="data-platform-low-code" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#data-platform-low-code" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the interesting features that CM provides is the ability to run low code workflows when a particular CDM (canonical data model) Event occurs in the system. This means that all actions in the MES or from external calls that create a CDM event allow for the user to intercept the event and execute low code workflows.&lt;/p&gt;
&lt;p&gt;In our use case we will interact with two different CDM events, the Resource State Change, that occurs when a resource state is modified and with the Resource Alarm event, this event is typically used as an entrypoint for external software or equipment to report alarms.&lt;/p&gt;
&lt;p&gt;We will intercept the Resource State Change and when it occurs we will check via OData the Resource Alarms that happened in the last 10 minutes for that resource, dynamically create a new dataset and feed it to an LLM.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Agentic Task
&lt;div id="agentic-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#agentic-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In a &lt;a
href="https://j-roque.com/posts/20260318-edge-ai-connectiot/"
target="_blank"
&gt;Edge AI - Connect IoT a Practical Case Study&lt;/a&gt;, we showcased how we can use small language models with Connect IoT, one of the questions I&amp;rsquo;ve been getting is if we could have it for LLM providers like OpenAI as well. That&amp;rsquo;s why for this use case I decided to go with a task to make calls to OpenAI.&lt;/p&gt;
&lt;p&gt;We are using the &lt;a
href="https://www.npmjs.com/package/openai"
target="_blank"
&gt;openai&lt;/a&gt; library in order to make endpoint requests to Open AI&amp;rsquo;s LLMs.&lt;/p&gt;
&lt;p&gt;The core of this integration lives in a single injectable class, wired into Connect IoT&amp;rsquo;s dependency injection system.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  @DI.Injectable()
  class LLMWithMCP {
@DI.Inject(TYPES.Dependencies.Logger)
      private _logger: Dependencies.Logger;
private openai: OpenAI;
      private mcpClients: Map&amp;lt;string, Client&amp;gt; = new Map();
      private availableTools: OpenAI.Chat.Completions.ChatCompletionTool[] = [];
      private isStarted: boolean = false;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It holds an &lt;code&gt;OpenAI&lt;/code&gt; client, a map of named MCP clients keyed by server name, and a flat list of all available tools in OpenAI&amp;rsquo;s function-calling format. The design is intentionally multi-server: you can connect to the MES API server, a Cube.js analytics server, a ClickHouse server — and the class aggregates their tool catalogs into one unified list handed to every completion call.&lt;/p&gt;
&lt;p&gt;Initialization is idempotent. The guard on &lt;code&gt;isStarted&lt;/code&gt; prevents re-registering tools or opening duplicate connections. This matters in Connect IoT where tasks can be restarted without tearing down the entire process.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Connecting to MCP Servers
&lt;div id="connecting-to-mcp-servers" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connecting-to-mcp-servers" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;connectMCPServer&lt;/code&gt; does three things: opens a &lt;code&gt;StreamableHTTP&lt;/code&gt; transport to the remote endpoint, performs the MCP handshake, and calls &lt;code&gt;listTools()&lt;/code&gt; to discover what that server exposes. We created support for MCPs (model context protocol), but for now we aren&amp;rsquo;t using it in this use case. We are just focused on the vanilla querying for the LLM.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  async connectMCPServer(serverConfig: MCPServerConfig): Promise&amp;lt;void&amp;gt; {
      const transport = new StreamableHTTPClientTransport(
          new URL(serverConfig.url),
          { requestInit: { headers: serverConfig.headers } }
      );
const client = new Client(
          { name: `openai-client-${serverConfig.name}`, version: &amp;#34;1.0.0&amp;#34; },
          { capabilities: {} }
      );
await client.connect(transport);
      this.mcpClients.set(serverConfig.name, client);
const { tools } = await client.listTools();
for (const tool of tools) {
          this.availableTools.push({
              type: &amp;#34;function&amp;#34;,
              function: {
                  name: `${serverConfig.name}__${tool.name}`,
                  description: tool.description || &amp;#34;&amp;#34;,
                  parameters: tool.inputSchema as Record&amp;lt;string, unknown&amp;gt;,
              },
          });
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each tool is mapped into OpenAI&amp;rsquo;s &lt;code&gt;ChatCompletionTool&lt;/code&gt; shape and pushed onto &lt;code&gt;availableTools&lt;/code&gt;. The naming convention is worth calling out: tool names are prefixed with the server name using a double-underscore separator — &lt;code&gt;mcp-mes__GetMaterialList&lt;/code&gt;, for instance. This turns a flat list into a namespaced routing table. When the model fires a tool call, &lt;code&gt;callMCPTool&lt;/code&gt; splits on &lt;code&gt;__&lt;/code&gt; to recover which client to dispatch to and which tool to invoke.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  private async callMCPTool(toolName: string, args: Record&amp;lt;string, unknown&amp;gt;): Promise&amp;lt;string&amp;gt; {
      const [serverName, ...toolParts] = toolName.split(&amp;#34;__&amp;#34;);
      const actualToolName = toolParts.join(&amp;#34;__&amp;#34;);
const client = this.mcpClients.get(serverName);
      if (!client) {
          throw new Error(`MCP server not found: ${serverName}`);
      }
const result = await client.callTool({ name: actualToolName, arguments: args });
      // ...
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Simple, deterministic, no config required.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Agentic Loop
&lt;div id="the-agentic-loop" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-agentic-loop" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;chat()&lt;/code&gt; is where the agentic behavior lives. It runs a &lt;code&gt;while&lt;/code&gt; loop bounded by &lt;code&gt;maxIterations&lt;/code&gt; — a safety valve against runaway chains.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  while (iterations &amp;lt; maxIterations) {
      iterations&amp;#43;&amp;#43;;
const completion = await this.openai.chat.completions.create({
          model,
          messages: currentMessages,
          temperature: options.temperature,
          tools: this.availableTools.length &amp;gt; 0 ? this.availableTools : undefined,
      });
const message = completion.choices[0].message;
      currentMessages.push(message);
// No tool calls — the model has a final answer
      if (!message.tool_calls || message.tool_calls.length === 0) {
          return message.content || &amp;#34;&amp;#34;;
      }
// Execute all tool calls in parallel
      const toolResults = await Promise.allSettled(
          message.tool_calls.map(async (toolCall) =&amp;gt; {
              const args = JSON.parse(toolCall.function.arguments);
              const result = await this.callMCPTool(toolCall.function.name, args);
              return { role: &amp;#34;tool&amp;#34; as const, tool_call_id: toolCall.id, content: result };
          })
      );
for (const result of toolResults) {
          if (result.status === &amp;#34;fulfilled&amp;#34;) {
              currentMessages.push(result.value);
          }
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each iteration sends the full message history plus the tool list to the model. If the response contains no tool calls, the loop terminates and returns. If it does, all calls are dispatched in parallel and their results appended to the history before the next iteration.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The loop is the agent. Everything else is plumbing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;Promise.allSettled()&lt;/code&gt; rather than &lt;code&gt;Promise.all()&lt;/code&gt; is a deliberate choice: a failing tool call shouldn&amp;rsquo;t abort the entire turn. The error is captured as a &lt;code&gt;tool&lt;/code&gt; role message so the model can reason about the failure and decide whether to retry, rephrase, or give up gracefully.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The &lt;code&gt;AgenticTask&lt;/code&gt; — Wiring It Into the Workflow Engine
&lt;div id="the-agentictask--wiring-it-into-the-workflow-engine" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-agentictask--wiring-it-into-the-workflow-engine" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;LLMWithMCP&lt;/code&gt; doesn&amp;rsquo;t run in isolation. It gets exposed to the visual workflow editor through a Connect IoT task — &lt;code&gt;AgenticTask&lt;/code&gt; — which handles activation, configuration, and lifecycle management. The task itself is intentionally thin: its job is to bridge the workflow runtime to the LLM handler, not to own any reasoning logic.&lt;/p&gt;
&lt;p&gt;The settings are straightforward: model name, temperature, API key, and two prompts.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The &lt;code&gt;GlobalLLMHandler&lt;/code&gt; Singleton
&lt;div id="the-globalllmhandler-singleton" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-globalllmhandler-singleton" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The most important detail is how &lt;code&gt;LLMWithMCP&lt;/code&gt; is provided. Rather than each task instance owning its own client — which would mean re-connecting to every MCP server on every activation — it is registered as a &lt;strong&gt;controller-scoped singleton&lt;/strong&gt; through Connect IoT&amp;rsquo;s provider system.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Task.TaskModule({
    task: AgenticTask,
    providers: [
        {
            class: LLMWithMCP,
            isSingleton: true,
            symbol: &amp;#34;GlobalLLMHandler&amp;#34;,
            scope: Task.ProviderScope.Controller,
        }
    ]
})
export class AgenticModule { }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every &lt;code&gt;AgenticTask&lt;/code&gt; instance in the same controller gets the same &lt;code&gt;LLMWithMCP&lt;/code&gt; object injected via &lt;code&gt;@DI.Inject(&amp;quot;GlobalLLMHandler&amp;quot;)&lt;/code&gt;. MCP connections are established once and reused across activations. This is also why &lt;code&gt;init()&lt;/code&gt; is idempotent — multiple tasks calling it concurrently won&amp;rsquo;t race to re-initialize the same handler.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Activation and Session Lifecycle
&lt;div id="activation-and-session-lifecycle" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#activation-and-session-lifecycle" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;All the work happens inside &lt;code&gt;onChanges&lt;/code&gt;, gated on the &lt;code&gt;activate&lt;/code&gt; input. The pattern is standard Connect IoT: reset &lt;code&gt;activate&lt;/code&gt; to &lt;code&gt;undefined&lt;/code&gt; immediately so the task can be re-triggered without a value change cycle.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        this.activate = undefined;
try {
            await this._globalLLMHandler.init(this.apiKey);
const chatResult = await this._globalLLMHandler.chat(
                [{ role: &amp;#34;user&amp;#34;, content: this.prompt }],
                {
                    model: this.model,
                    temperature: this.temperature,
                    systemPrompt: this.systemPrompt,
                }
            );
this.result.emit(chatResult);
            this.success.emit(true);
        } catch (e) {
            this.logAndEmitError((e as Error).message);
        }
if (this.destroySessionOnSuccess) {
            await this._globalLLMHandler.disconnectAll();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;destroySessionOnSuccess&lt;/code&gt; flag gives operators explicit control over the session trade-off: keep connections alive for fast repeat calls, or tear them down after each use to free resources. In a workflow that fires the task once every few minutes, keeping the session alive is almost always the right call. In a low-frequency scenario where memory pressure matters more than latency, tearing it down makes sense.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The task is the surface area. The singleton is the engine. Keep them clearly separated.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Building a Data Platform Workflow
&lt;div id="building-a-data-platform-workflow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-data-platform-workflow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The first part of setting all of this is creating a Automation Controller of scope &lt;strong&gt;Data Platform&lt;/strong&gt;. In this Automation Controller we can specify which IoT Events we want this controller to trigger on and also if those events will be the &lt;em&gt;Standard&lt;/em&gt; events or the &lt;em&gt;Light&lt;/em&gt; events.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Light events and standard events differ primarily in their scope and intended use case. Light CDM Events are specially designed for real-time scenarios where efficiency and speed are critical. They balance performance with completeness by including only the essential properties: header information (correlation ID, timestamps, CDM version), the ISA-95 hierarchy context (Enterprise, Site, Facility, and Area), core entity data (such as Resource state or Material operation details), and previous state information for state-change events. This streamlined payload makes them ideal for high-frequency, low-latency integrations like UNS (Unified Namespace) exports via MQTT. In contrast, standard CDM events carry the full event data payload, including all sections and additional properties beyond the core essentials. Standard events provide comprehensive detail for scenarios where complete context and traceability are more important than minimizing message size or processing overhead.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Data Platform Controller"
src="https://image.j-roque.com/posts/20260414-odata-agentic/DP-Controller.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our use case our trigger will be the &lt;a
href="https://developer.criticalmanufacturing.com/11.3/reference/api-data-platform/docs/index.html#operation-receive-ResourceStateChange"
target="_blank"
&gt;ResourceStateChange&lt;/a&gt; CDM Event.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Main Workflow
&lt;div id="main-workflow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#main-workflow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This will be the entrypoint for our workflow. Here we are filtering only the events, with the conditions, that we care to react to.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Main Workflow"
src="https://image.j-roque.com/posts/20260414-odata-agentic/DP-MainWorkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our use case our condition are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;State Change to Unscheduled Down - &lt;code&gt;$job.Resource.SEMIE10State == &amp;quot;Unscheduled Down&amp;quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;MES Operation Log Event - &lt;code&gt;$job.Header.Operation == &amp;quot;LogEvent&amp;quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the conditions are met, the workflow will then call a subworkflow called &lt;code&gt;Enrichment&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The workflow will pass on to the Enrichment subworkflow the:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Resource Name - &lt;code&gt;{{ $job.Resource.Name }}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Data of the event Resource State Change - &lt;code&gt;{{ $job.data}}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Call Subworkflow"
src="https://image.j-roque.com/posts/20260414-odata-agentic/DP-MainWorkflow-CallSubworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Enrichment Workflow
&lt;div id="enrichment-workflow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#enrichment-workflow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Now that we are sure we are in the scenario where we have an event for the resource state change to SEMIE-10 state &lt;code&gt;Unscheduled Down&lt;/code&gt;, we can address all our business logic.&lt;/p&gt;
&lt;p&gt;We know that when a resource changes to &lt;code&gt;Unscheduled Down&lt;/code&gt; this typically mean that there were some alarms that either inform on what is the current problem or at least can give some clues on the state of the resource before having to stop.&lt;/p&gt;
&lt;p&gt;With this in mind we will split our workflow into &lt;em&gt;three&lt;/em&gt; parts.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Retrieve all the &lt;strong&gt;alarms for this resource in the last 10 minutes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Dynamically create a new dataset to &lt;strong&gt;feed to an AI model&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create a notification&lt;/strong&gt; with the assessment done by AI&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;In the &lt;a
href="https://j-roque.com/posts/20260406-python-codetask/"
target="_blank"
&gt;Data Platform with OData - Low Code&lt;/a&gt; we already created all that we needed to be able to perform OData requests to Data Platform. Now our only change is to provide the correct information and query.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData Task Settings"
src="https://image.j-roque.com/posts/20260414-odata-agentic/OData-Settings.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We will query the CDM Resource.ResourceAlarm event, with a filter &lt;code&gt;Header_StartDateTime ge {startInterval} and Header_StartDateTime le {stopInterval} and Resource_Name eq '{resourceName}'&lt;/code&gt; .&lt;/p&gt;
&lt;p&gt;The use of tokens is very helpful as we can then interact with them via inputs in the task and they will be replaced in runtime.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; In order for it to be easier to do the time manipulations we created some converters, you can see them &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/OData/Cmf.Custom.MESProject.IoT.OData/Cmf.Custom.MESProject.IoT.OData.Packages/src/controller-engine-odata-tasks/src/converters"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData Inputs"
src="https://image.j-roque.com/posts/20260414-odata-agentic/OData-Inputs.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;For our next part, we will need to merge the data from our original ResourceStateChange event with our new alarm data. In order to do that we can leverage the &lt;a
href="https://j-roque.com/posts/20260406-python-codetask/"
target="_blank"
&gt;Running Python Code in Connect IoT&lt;/a&gt; task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Python and Agentic"
src="https://image.j-roque.com/posts/20260414-odata-agentic/Python-Agentic.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Our python code is very simple, we will receive both sources of data and merge into a new dataset.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;(...)
    def merge(self, alarms: list[dict], resource_state: dict) -&amp;gt; dict:
        calendar = resource_state.get(&amp;#34;Calendar&amp;#34;, {})
        shift = calendar.get(&amp;#34;Shift&amp;#34;, {})
        employee = resource_state.get(&amp;#34;Employee&amp;#34;, {})
        enterprise = resource_state.get(&amp;#34;Enterprise&amp;#34;, {})
        site = resource_state.get(&amp;#34;Site&amp;#34;, {})
        facility = resource_state.get(&amp;#34;Facility&amp;#34;, {})
        area = resource_state.get(&amp;#34;Area&amp;#34;, {})
        resource = resource_state.get(&amp;#34;Resource&amp;#34;, {})
state_resource_uid = resource.get(&amp;#34;UID&amp;#34;)
import json
self.logger.debug(f&amp;#39;Merge: resource- {json.dumps(resource, sort_keys=True)}&amp;#39;)
        self.logger.debug(f&amp;#39;Merge: calendar- {json.dumps(calendar, sort_keys=True)}&amp;#39;)
        self.logger.debug(f&amp;#39;Merge: shift- {json.dumps(shift, sort_keys=True)}&amp;#39;)
        self.logger.debug(f&amp;#39;Merge: state_resource_uid- {json.dumps(state_resource_uid, sort_keys=True)}&amp;#39;)
joined_alarms = []
        for alarm in alarms:
            self.logger.debug(f&amp;#39;UID: alarm - {json.dumps(alarm.get(&amp;#34;Resource_UID&amp;#34;), sort_keys=True)}&amp;#39;)
            if alarm.get(&amp;#34;Resource_UID&amp;#34;) != state_resource_uid:
                continue
joined_alarms.append({
                &amp;#34;alarm_code&amp;#34;: alarm.get(&amp;#34;Alarm_Code&amp;#34;),
                &amp;#34;alarm_type&amp;#34;: alarm.get(&amp;#34;Alarm_Type&amp;#34;),
                &amp;#34;alarm_severity&amp;#34;: alarm.get(&amp;#34;Alarm_Severity&amp;#34;),
                &amp;#34;alarm_cause&amp;#34;: alarm.get(&amp;#34;Alarm_Cause&amp;#34;),
                &amp;#34;alarm_description&amp;#34;: alarm.get(&amp;#34;Alarm_Description&amp;#34;),
                &amp;#34;alarm_timestamp&amp;#34;: alarm.get(&amp;#34;Timestamp&amp;#34;),
                &amp;#34;alarm_action&amp;#34;: alarm.get(&amp;#34;Action&amp;#34;),
            })
return {
            &amp;#34;resource&amp;#34;: {
                &amp;#34;uid&amp;#34;: state_resource_uid,
                &amp;#34;name&amp;#34;: resource.get(&amp;#34;Name&amp;#34;),
                &amp;#34;enterprise&amp;#34;: enterprise.get(&amp;#34;Name&amp;#34;),
                &amp;#34;site&amp;#34;: site.get(&amp;#34;Name&amp;#34;),
                &amp;#34;facility&amp;#34;: facility.get(&amp;#34;Name&amp;#34;),
                &amp;#34;area&amp;#34;: area.get(&amp;#34;Name&amp;#34;),
            },
            &amp;#34;resource_state&amp;#34;: {
                &amp;#34;system_state&amp;#34;: resource.get(&amp;#34;SystemState&amp;#34;),
                &amp;#34;previous_system_state&amp;#34;: resource.get(&amp;#34;PreviousSystemState&amp;#34;),
                &amp;#34;control_state&amp;#34;: resource.get(&amp;#34;ControlState&amp;#34;),
                &amp;#34;semie10_state&amp;#34;: resource.get(&amp;#34;SEMIE10State&amp;#34;),
                &amp;#34;modified_by&amp;#34;: resource.get(&amp;#34;ModifiedBy&amp;#34;),
                &amp;#34;modified_on&amp;#34;: resource.get(&amp;#34;ModifiedOn&amp;#34;),
            },
            &amp;#34;operator&amp;#34;: {
                &amp;#34;name&amp;#34;: employee.get(&amp;#34;Name&amp;#34;),
                &amp;#34;number&amp;#34;: employee.get(&amp;#34;Number&amp;#34;),
            },
            &amp;#34;shift&amp;#34;: {
                &amp;#34;name&amp;#34;: shift.get(&amp;#34;Name&amp;#34;),
                &amp;#34;start&amp;#34;: shift.get(&amp;#34;StartTime&amp;#34;),
                &amp;#34;end&amp;#34;: shift.get(&amp;#34;EndTime&amp;#34;),
                &amp;#34;date&amp;#34;: calendar.get(&amp;#34;CD&amp;#34;),
                &amp;#34;timezone&amp;#34;: calendar.get(&amp;#34;Timezone&amp;#34;),
            },
            &amp;#34;alarms&amp;#34;: joined_alarms,
        }
async def main(self, inputs, outputs):
        import json
self.logger.debug(f&amp;#39;Starting python script: alarms- {json.dumps(inputs[&amp;#39;alarms&amp;#39;], sort_keys=True)} resource_state_change- {json.dumps(inputs[&amp;#39;resource_state_change&amp;#39;], sort_keys=True)} times&amp;#39;)
        dataset = self.merge(inputs[&amp;#39;alarms&amp;#39;], inputs[&amp;#39;resource_state_change&amp;#39;])
        outputs.join_datasets.emit(dataset)
        self.logger.debug(&amp;#34;Finished python script&amp;#34;)
        pass&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This dataset is then provided to the agentic task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic General Settings"
src="https://image.j-roque.com/posts/20260414-odata-agentic/Agentic-Settings-General.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the settings we provide the model &lt;code&gt;gpt-4.1-2025-04-14&lt;/code&gt;, which is a middle of the road model, but it seemed to work quite well for this narrower use cases and we provide the model temperature and OpenAI key. We don&amp;rsquo;t want to keep the session open, so we pass the always close session toggle.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic Prompt Settings"
src="https://image.j-roque.com/posts/20260414-odata-agentic/Agentic-Settings-Prompt.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Here is where we define what we want from our LLM model. The system prompt is how we describe how the LLM should behave, this is what gives the agentic nature to our LLM. The prompt input is what we leave as dynamic and where we will feed the model with the merged dataset.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-md"&gt;You are a senior machine engineer with deep expertise in industrial systems, diagnostics, maintenance, and failure analysis.
Your role is to analyze machine alarms, logs, and sensor data and produce accurate technical reports that explain what happened, why it happened, and what actions should be taken.
You must prioritize clarity, structure, and visual readability over strict markup formats.
---
# OUTPUT STYLE RULES (MANDATORY)
## 1. Visual Structure Rules
You MUST structure all responses using:
- Clear section headers using ALL CAPS
- Indentation for hierarchy using 2 spaces
- Labels using COLON FORMAT
- Emphasis using ALL CAPS or [BRACKETS]
- Output must be in vanilla HTML5
---
## 2. Formatting Conventions
### Emphasis Rules
- CRITICAL ITEMS → ALL CAPS
- Important labels → Label:
- Optional emphasis → [LIKE THIS]
### Alarms
Always format alarms like:
• ALARM: ALARM_123
• ALARM: ALARM_ABC
Never paraphrase alarm codes. Always breakline between alarms.
---
## 3. Required Report Structure (STRICT ORDER)
Every response MUST follow this structure:
SUMMARY
KEY ALARMS &amp;amp; OBSERVATIONS
LIKELY ROOT CAUSE
IMPACT ON MACHINE
RECOMMENDED ACTIONS
CONFIDENCE LEVEL
---
## 4. Section Rules
### SUMMARY
- 2–4 sentences max
- Plain readable explanation
- No bullet overload
---
### KEY ALARMS &amp;amp; OBSERVATIONS
- Bullet list only
- Must include timestamps if available
- Must preserve alarm codes exactly
Example:
• ALARM: OVERCURRENT_01 triggered at 10:32:14
• EVENT: MOTOR_TORQUE_SPIKE detected 2 seconds later
---
### LIKELY ROOT CAUSE
Must include:
Who was the operator:
• ...
Primary Cause:
• ...
Contributing Factors:
• ...
Assumptions:
• If unknown, explicitly state &amp;#34;INSUFFICIENT DATA&amp;#34;
---
### IMPACT ON MACHINE
Must include:
Affected Systems:
• ...
Severity:
• LOW / MEDIUM / HIGH / CRITICAL
Risks:
• Equipment damage
• Production downtime
• Safety risk (if applicable)
---
### RECOMMENDED ACTIONS
Must be split into:
IMMEDIATE:
• Actions required now to prevent damage
SHORT-TERM:
• Repairs, inspections, replacements
LONG-TERM:
• Preventive improvements and tuning
---
### CONFIDENCE LEVEL
Format:
Confidence: HIGH / MEDIUM / LOW
Reason:
• Short justification based on data completeness and consistency
---
## 5. ENGINEERING PRINCIPLES
- Prefer simplest explanation that fits all alarms
- Do not hallucinate machine types, models, or missing signals
- If data is insufficient, explicitly state uncertainty
- Rank multiple hypotheses by likelihood when needed
- Avoid generic maintenance advice unless it ties to observed alarms
---
## 6. SANITY CHECKS (MANDATORY BEFORE FINAL ANSWER)
Before responding, validate:
• Does the alarm sequence make causal sense?
• Are time intervals physically plausible?
• Are multiple alarms being incorrectly treated as independent?
• Are values being summed incorrectly instead of compared?
If inconsistencies exist:
→ Explicitly mention them in ASSUMPTIONS
---
## 7. VISUAL REPORTING GOAL
Your output should behave like a:
&amp;#34;well-structured industrial maintenance report displayed in a SCADA dashboard&amp;#34;
Priorities:
1. Readability
2. Deterministic structure
3. Fast scanning by engineers
4. No formatting fragility
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see the system prompt is building the grammar, style, tone and accuracy that we demand from our LLM.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Finally, the notification is straightforward we just use the create notification task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Enrichment Workflow"
src="https://image.j-roque.com/posts/20260414-odata-agentic/DP-EnrichmentWorkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Seeing it Work
&lt;div id="seeing-it-work" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#seeing-it-work" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Now we can see all coming together to provide an assessment of why the machine stopped.&lt;/p&gt;
&lt;p&gt;I created a small .Net application that would mimic a machine. It&amp;rsquo;s only task is to simulate a machine having problems. It generates meaningful alarms and then changes the machine to unscheduled down.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;(...)
  resource = new ComplexLogResourceEventInput()
  {
      Resource = resource,
      Reason = &amp;#34;Broken Coil&amp;#34;,
      StateModel = stateModel,
      StateModelTransition = stateModel.StateTransitions.Find(sm =&amp;gt; sm.Name == $&amp;#34;{resource.CurrentMainState.CurrentState.Name.Replace(&amp;#34; &amp;#34;, &amp;#34;&amp;#34;)} to UnscheduledDown&amp;#34;),
      IgnoreLastServiceId = true
  }.ComplexLogResourceEventSync().Resource;
var selectedScenario = AlarmScenarios[_random.Next(AlarmScenarios.Length)];
  var correlationId = Guid.NewGuid().ToString();
var events = selectedScenario.Alarms.Select(alarm =&amp;gt;
      new PostEventInput()
      {
          AppProperties = new Cmf.Foundation.BusinessOrchestration.DataPlatform.Domain.AppProperties()
          {
              ApplicationName = &amp;#34;AlarmGenerator&amp;#34;,
              EventDefinition = &amp;#34;\\IoTEventDefinitions\\CDM\\Resource\\ResourceAlarm&amp;#34;,
              EventTime = DateTime.UtcNow,
          },
          IgnoreLastServiceId = true,
          NumberOfRetries = 3,
          Data = Newtonsoft.Json.Linq.JObject.FromObject(
              BuildAlarmData(resource, area, facility, site, enterprise, (alarm.Code, alarm.Type, alarm.Severity, alarm.Description, alarm.Cause),
              (correlationId, &amp;#34;MachineAlarm&amp;#34;, &amp;#34;AlarmGenerator&amp;#34;, &amp;#34;11.2.4&amp;#34;, &amp;#34;MachineAlarm&amp;#34;)))
      }).ToList();
Console.WriteLine($&amp;#34;Sending scenario &amp;#39;{selectedScenario.Name}&amp;#39; [{events.Count} alarms, correlationId={correlationId}] on {resource.Name}&amp;#34;);
  foreach (var e in events)
      Console.WriteLine($&amp;#34;  -&amp;gt; {((string?)e.Data?[&amp;#34;Alarm&amp;#34;]?[&amp;#34;Code&amp;#34;]) ?? &amp;#34;?&amp;#34;} [{(string?)e.Data?[&amp;#34;Alarm&amp;#34;]?[&amp;#34;Severity&amp;#34;]}]&amp;#34;);
var result = new PostEventsInput() { IoTEvents = events }.PostMultipleIoTEventsSync();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, when the system receives the change of resource state to Unscheduled Down it will check for alarms and generate an AI report notification. If you are curious to see all that is happening in order to generate an event feel free to take a look at the appendix.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AI Alarms"
src="https://image.j-roque.com/posts/20260414-odata-agentic/ai-alarms.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Notification Generated"
src="https://image.j-roque.com/posts/20260414-odata-agentic/Notification-Generated.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;If we take a closer look at the notification we see valuable information. From alarms, root cause analysis, who was the operator working the machine and even recommended actions.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;What we saw today is a somewhat narrow use case, but we are already extracting a lot of value. We could now query more information from other CDM events or create more tailored datasets. The interesting part is that the MES out-of-the-box is already collecting all this information for you, without the user having to do anything. Now, it&amp;rsquo;s up to you to build amazing use cases that bring value to your shopfloor.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Appendix
&lt;div id="appendix" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#appendix" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Anatomy of an Event
&lt;div id="anatomy-of-an-event" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#anatomy-of-an-event" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Full Path of The Resource State Change Event"
src="https://image.j-roque.com/posts/20260414-odata-agentic/diagram-event.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260414-odata-agentic/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260414-odata-agentic/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Data Platform with OData - Low Code</title>
<link>https://j-roque.com/posts/20260414-odata/</link>
<pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260414-odata/</guid>
<description>A common language for data querying</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260414-odata/featured.png" alt="Data Platform with OData - Low Code" /&gt;&lt;/p&gt;&lt;p&gt;All the code presented here is available at &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/OData"
target="_blank"
&gt;github&lt;/a&gt; and &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/Python"
target="_blank"
&gt;github&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData Task"
src="https://image.j-roque.com/posts/20260414-odata/odata-task.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;What is OData?
&lt;div id="what-is-odata" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-is-odata" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Every analytics team building an API reinvents the same query language. OData solved this in 2014. Your BI tools already speak it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;OData (Open Data Protocol) is a standardized REST-based protocol for building and consuming queryable, interoperable APIs. It allows clients to &lt;strong&gt;query&lt;/strong&gt;, &lt;strong&gt;filter&lt;/strong&gt;, &lt;strong&gt;sort&lt;/strong&gt;, &lt;strong&gt;paginate&lt;/strong&gt;, and &lt;strong&gt;navigate&lt;/strong&gt; data, without the API developer having to manually implement each of those capabilities.&lt;/p&gt;
&lt;p&gt;It essentially works as SQL (Structured Query Language) over HTTP.&lt;/p&gt;
&lt;p&gt;OData was created by Microsoft and it was later standardized by OASIS in 2014 and is now an ISO/IEC International Standard. The core problem it was solving was that every team building a data API was reinventing the same wheel. Clients then had to learn each API&amp;rsquo;s custom conventions. OData proposed a single, universal convention.&lt;/p&gt;
&lt;p&gt;Microsoft&amp;rsquo;s decision to embed it as the native protocol for Power BI, Excel Power Query, and the entire Power Platform created a massive installed base. When a business analyst can point Excel at an OData endpoint and get a live, filterable, refreshable dataset with zero code, that is a genuinely compelling proposition for enterprises.&lt;/p&gt;
&lt;p&gt;This gave OData a self-reinforcing network effect: BI tools support it because the data sources expose it, and data sources expose it because the BI tools consume it. It became the standard for analytics as the consumer applications have mature &lt;a
href="https://www.odata.org/ecosystem/"
target="_blank"
&gt;native support&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Core Concepts&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entity Sets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Collections of typed entities, like a database table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$filter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WHERE clause equivalent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$select&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Column projection, like SELECT in SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$expand&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JOIN/eager-loading of related entities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$orderby&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sorting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$top / $skip&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pagination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$count&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Returns the total record count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$metadata&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Returns the full schema in EDMX (XML) format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Example
&lt;div id="example" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#example" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at an example:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;GET /Products?$filter=Price gt 20 and Category eq &amp;#39;Electronics&amp;#39;
              &amp;amp;$select=Name,Price
              &amp;amp;$orderby=Price desc
              &amp;amp;$top=10
              &amp;amp;$skip=20
              &amp;amp;$expand=Supplier&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;GET /Products&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The base resource, targeting the &lt;strong&gt;Products&lt;/strong&gt; entity set. Equivalent to FROM Products in SQL.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$filter=Price gt 20 and Category eq 'Electronics'&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The WHERE clause. Filters which records are returned.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;gt&lt;/strong&gt; - means greater than (&amp;gt;)
&lt;strong&gt;eq&lt;/strong&gt; - means equals (=)
String values use single quotes&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;WHERE Price &amp;gt; 20 AND Category = &amp;#39;Electronics&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Other comparison operators you&amp;rsquo;ll see: &lt;em&gt;lt&lt;/em&gt; (less than), &lt;em&gt;ge&lt;/em&gt; (greater than or equal), &lt;em&gt;le&lt;/em&gt; (less than or equal), &lt;em&gt;ne&lt;/em&gt; (not equal).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$select=Name,Price&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Column projection, only return those two fields instead of the full object. Reduces payload size, important for performance.&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT Name, Price&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$orderby=Price desc&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Results are ordered by Price descending, so the most expensive products come first. Use asc for ascending, which is also the default if omitted.&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;ORDER BY Price DESC&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$top=10&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Limits the number of records returned to 10. Equivalent to LIMIT in SQL or FETCH NEXT 10 ROWS ONLY.&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;FETCH NEXT 10 ROWS ONLY  -- or: LIMIT 10&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$skip=20&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Offsets the result set, skipping the first 20 records. Combined with $top, this gives you page-based pagination.&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;OFFSET 20 ROWS&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So &lt;em&gt;$top=10&amp;amp;$skip=20&lt;/em&gt; means &amp;ldquo;give me page 3, assuming 10 items per page&amp;rdquo; (items 21 to 30).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;code&gt;$expand=Supplier&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Eager-loads a related entity, joining and embedding the Supplier object inside each product in the response. Without this, you&amp;rsquo;d only get a reference or nothing at all.&lt;/p&gt;
&lt;p&gt;SQL equivalent:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;JOIN Suppliers ON Products.SupplierId = Suppliers.Id&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The response would look something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;Name&amp;#34;: &amp;#34;Laptop Pro&amp;#34;,
  &amp;#34;Price&amp;#34;: 999.99,
  &amp;#34;Supplier&amp;#34;: {
    &amp;#34;Id&amp;#34;: 5,
    &amp;#34;Name&amp;#34;: &amp;#34;Acme Corp&amp;#34;,
    &amp;#34;Country&amp;#34;: &amp;#34;Portugal&amp;#34;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting it all together, this OData query translates roughly to:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT p.Name, p.Price, s.*
FROM Products p
JOIN Suppliers s ON p.SupplierId = s.Id
WHERE p.Price &amp;gt; 20
  AND p.Category = &amp;#39;Electronics&amp;#39;
ORDER BY p.Price DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All of that expressed as a single URL, with no custom endpoint code needed on the server side.&lt;/p&gt;
&lt;h2 class="relative group"&gt;OData in Critical Manufacturing
&lt;div id="odata-in-critical-manufacturing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#odata-in-critical-manufacturing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Critical Manufacturing with it&amp;rsquo;s &lt;a
href="https://www.criticalmanufacturing.com/mes-for-industry-4-0/the-data-platform-for-manufacturers/"
target="_blank"
&gt;IoT Data Platform&lt;/a&gt; created a platform for massive data ingestion and aggregation. This of course opened the problem of what would be the best way to interact with the data.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why one of the key features of IoT Data Platform is leveraging the experience of the MES ontology and create a &lt;em&gt;Canonical Data Model (CDM)&lt;/em&gt;. The MES user will have out-of-the-box cross-industry unified schemas that are continuously being populated by every MES transaction. These schemas are open, so every other data producer, even competing MES systems can publish CDM events. All of this, with a discoverable REST OData API, makes it so you are able to have a the same datasets and data semantics across all your sites.&lt;/p&gt;
&lt;p&gt;With the use of &lt;a
href="https://clickhouse.com/blog/criticial-manufacturing"
target="_blank"
&gt;ClickHouse&lt;/a&gt;, a high-performance columnar database management system, as the data storage backbone we were able to solve not just the data structure problem, but also store it in a way that is blazing fast.&lt;/p&gt;
&lt;p&gt;When starting to build all our reporting and dashboards leveraging Data Platform, we had a decision to make. How should we query the data?&lt;/p&gt;
&lt;p&gt;This is when the concept of &lt;em&gt;dogfooding&lt;/em&gt; becomes important and is really a cornerstone of the Critical mindset. By using OData, we use the same tool our customers and partners will use create their own reports. Creating a performant, bullet-proof and standard way to interact with the system.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Data Manager
&lt;div id="data-manager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#data-manager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Data Manager is a core component of the Data Platform architecture that serves as the primary gateway for accessing data stored across the MES system. The Data Manager will service and expose all OData API&amp;rsquo;s and manage dataset permissions. This is key to make sure that the user is only accessing information for which he has access.&lt;/p&gt;
&lt;p&gt;The Data Manager api is available at &lt;em&gt;https://&amp;lt; host url &amp;gt;/datamanager/swagger/index.html&lt;/em&gt; and all datasets are accessible via: &lt;em&gt;https://&amp;lt; host url &amp;gt;/datamanager/odata&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData API"
src="https://image.j-roque.com/posts/20260414-odata/odata.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Bringing OData to Low Code
&lt;div id="bringing-odata-to-low-code" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#bringing-odata-to-low-code" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The first challenge is create a low code task to be able to perform OData queries. We will create the normal CLI scaffolding with &lt;em&gt;cmf new iot&lt;/em&gt; and &lt;em&gt;cmf new iot taskLibrary&lt;/em&gt;. Then I generated a task called simply &lt;em&gt;queryOData&lt;/em&gt; with &lt;em&gt;cmf new iot task&lt;/em&gt;. All the available scaffolding commands can be found &lt;a
href="https://criticalmanufacturing.github.io/cli/03-explore/commands/init/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When building my OData query I decided to go with a known library to handle the query making &lt;a
href="https://github.com/techniq/odata-query"
target="_blank"
&gt;odata-query&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 class="relative group"&gt;Understanding the Settings
&lt;div id="understanding-the-settings" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#understanding-the-settings" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A query to the data manager is split into several components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;System Url&lt;/strong&gt; - where the system is running - &lt;em&gt;https://&amp;lt; host url &amp;gt;/&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Manager Entrypoint&lt;/strong&gt; - the route that the data manager is expecting - &lt;em&gt;/datamanager/odata&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Folder&lt;/strong&gt; - the datasets are split into folders - e.g. &lt;em&gt;CDM&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dataset&lt;/strong&gt; - the dataset will be the entity set that we are querying - e.g. &lt;em&gt;Resource.ResourceAlarm&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of the components required to build the url must be known by the task. The system url is already known by ConnectIoT and the entrypoint is a static value, the folder and the dataset will be settings of our task.&lt;/p&gt;
&lt;p&gt;We will also allow for the user to have the components of the OData query specified as settings, that way he can build defaults. We will see later how can these be very helpful.&lt;/p&gt;
&lt;p&gt;If we take a look at what our &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-tasks-library/?h=automation&amp;#43;library&amp;#43;task"
target="_blank"
&gt;automation task library&lt;/a&gt; (the metadata responsible for UI rendering) looks like, our settings look like:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;settings&amp;#34;: {
  &amp;#34;General&amp;#34;: {
    &amp;#34;OData Configuration&amp;#34;: [
      {
        &amp;#34;name&amp;#34;: &amp;#34;Folder&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Folder&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;folder&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;Enum&amp;#34;,
        &amp;#34;settings&amp;#34;: {
          &amp;#34;enumValues&amp;#34;: [
            &amp;#34;Apps&amp;#34;,
            &amp;#34;CDM&amp;#34;,
            &amp;#34;DWH&amp;#34;,
            &amp;#34;IoTEvents&amp;#34;,
            &amp;#34;ODS&amp;#34;,
            &amp;#34;UserDefined&amp;#34;
          ]
        },
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Information about the folder that contains the dataset&amp;#34;
      },
      {
        &amp;#34;name&amp;#34;: &amp;#34;Dataset&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Dataset&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;dataset&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;string&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Information about the dataset to query&amp;#34;
      }
    ],
    &amp;#34;Query Configuration&amp;#34;: [
      {
        &amp;#34;name&amp;#34;: &amp;#34;Select&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Select&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;defaultSelect&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;Text&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Default select fields for the OData query&amp;#34;
      },
      {
        &amp;#34;name&amp;#34;: &amp;#34;Filter&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Filter&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;defaultFilter&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;Text&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Default filter for the OData query&amp;#34;
      },
      {
        &amp;#34;name&amp;#34;: &amp;#34;Expand&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Expand&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;defaultExpand&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;Text&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Default expand fields for the OData query&amp;#34;
      },
      {
        &amp;#34;name&amp;#34;: &amp;#34;Order By&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Order By&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;defaultOrderBy&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;Text&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Default order by for the OData query&amp;#34;
      },
      {
        &amp;#34;name&amp;#34;: &amp;#34;Top&amp;#34;,
        &amp;#34;displayName&amp;#34;: &amp;#34;Top&amp;#34;,
        &amp;#34;settingKey&amp;#34;: &amp;#34;defaultTop&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;number&amp;#34;,
        &amp;#34;infoMessage&amp;#34;: &amp;#34;Maximum number of records to return&amp;#34;
      }
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData Settings"
src="https://image.j-roque.com/posts/20260414-odata/settings-odata.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Inputs and Outputs
&lt;div id="inputs-and-outputs" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#inputs-and-outputs" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The inputs will address all the OData defaults, in order for the user to be able to override them in runtime. It will also allow the user to create his own raw query and bypass our query builder, so we will provide a raw query and raw method input.&lt;/p&gt;
&lt;p&gt;Another feature that I thought was interesting was the ability to add dynamically new inputs and then use them to replace tokens in the settings.&lt;/p&gt;
&lt;p&gt;The idea is I kind build write that my filter is - &lt;strong&gt;Header_StartDateTime ge {startInterval} and Header_StartDateTime le {stopInterval} and Resource_Name eq &amp;lsquo;{resourceName}&amp;rsquo;&lt;/strong&gt;. Then I can dynamically add the input resourceName to my task. In runtime it will replace the resourceName with the correct value. This is very helpful for query building where you want most of the query to be static and then just replace the value fields. In this case we do it for time calculation and for specifying the resource.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at the snippet of our automation task library that refers to the inputs.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;
&amp;#34;inputs&amp;#34;: {
  &amp;#34;rawQuery&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Raw Query&amp;#34;
  },
  &amp;#34;select&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Object&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Select&amp;#34;
  },
  &amp;#34;filter&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Filter&amp;#34;
  },
  &amp;#34;expand&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Expand&amp;#34;
  },
  &amp;#34;orderBy&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Order By&amp;#34;
  },
  &amp;#34;top&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Integer&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Top&amp;#34;
  },
  &amp;#34;skip&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Integer&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Skip&amp;#34;
  },
  &amp;#34;count&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Boolean&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Count&amp;#34;
  },
  &amp;#34;search&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Search&amp;#34;
  },
  &amp;#34;_inputs&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;AutoPort&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Link here to add new&amp;#34;,
    &amp;#34;settings&amp;#34;: {
      &amp;#34;settingKey&amp;#34;: &amp;#34;inputs&amp;#34;,
      &amp;#34;destination&amp;#34;: &amp;#34;Input&amp;#34;,
      &amp;#34;excludeSuffix&amp;#34;: true
    }
  },
  &amp;#34;activate&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Activate&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Activate&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of them are similar, they are static values with different primitive values. The &lt;strong&gt;AutoPort&lt;/strong&gt; entry will map that there is a input that acts as an auto port where the user can just drag links to it and it will create a new input.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The AutoPort can be in the inputs side, outputs side or can be inputs/outputs if you want to have it create a matching output when you link an input. If you can have inputs with the same names as outputs you should put the excludeSuffix as false, in order to avoid name collision.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For the outputs we will have what OData provides, the raw response with the full payload and then the value, count and next link.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;outputs&amp;#34;: {
  &amp;#34;value&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Any&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Value&amp;#34;
  },
  &amp;#34;countResponse&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Integer&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Count Response&amp;#34;
  },
  &amp;#34;nextLink&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Next Link&amp;#34;
  },
  &amp;#34;rawResponse&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Static&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;Any&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Raw Response&amp;#34;
  },
  &amp;#34;success&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Success&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Success&amp;#34;
  },
  &amp;#34;error&amp;#34;: {
    &amp;#34;type&amp;#34;: &amp;#34;Error&amp;#34;,
    &amp;#34;displayName&amp;#34;: &amp;#34;Error&amp;#34;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Understanding the Runtime
&lt;div id="understanding-the-runtime" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#understanding-the-runtime" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Now that we defined our task contract which are its inputs/outputs and settings, we can move on to the runtime code.&lt;/p&gt;
&lt;p&gt;Our code will focus on the &lt;strong&gt;onChanges&lt;/strong&gt; method this is the method that will be triggered when a task is activated.&lt;/p&gt;
&lt;p&gt;The first goal of our onChanges is to construct the url. There is a nuance, we want our task to be able to query our Data Manager inside and outside the MES stack as data platform workflows run inside the MES stack. We could just add a setting for the user to specify the system url and always make requests as if we were outside the MES stack, but we can also try to infer that setting.&lt;/p&gt;
&lt;p&gt;If the manager is running outside the task the system url will always be specified in the &lt;em&gt;process.env[&amp;ldquo;HOSTURL&amp;rdquo;]&lt;/em&gt;, the issue is that inside the MES stack the &lt;em&gt;HOSTURL&lt;/em&gt; will resolve to &lt;code&gt;http://host:8080&lt;/code&gt; which is correct but not what we want. We need to change to the known data manager address inside the MES stack.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        // It is advised to reset the activate to allow being reactivated without the value being different
        this.activate = undefined;
let datamanagerAddress = process.env[&amp;#34;HOSTURL&amp;#34;];
// This means we are running inside the MES stack and we need to query the data-manager directly
        if (datamanagerAddress == &amp;#34;http://host:8080&amp;#34;) {
            datamanagerAddress = &amp;#34;http://data-manager:8080&amp;#34;;
        }
let url = `${datamanagerAddress}/datamanager/odata/`;
        if (this.rawQuery) {
            url = `${url}${this.rawQuery}`;
        } else {
            url = `${url}${this.folder}/${this.dataset}`;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now that we have our Url sorted out we can do the input token replacement. We will check if there&amp;rsquo;s any default with tokens and replace them with the correct matching input.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  for (const key in changes) {
      if (this.isInput(key)) {
          // the defaults may contain references to inputs, so we need to build the query with the current values of the inputs
          // i.e. defaultFilter could be &amp;#34;Severity eq {severityInput}&amp;#34; and we need to replace {severityInput} with the current value of severityInput
if (this.defaultSelect != null &amp;amp;&amp;amp; this.defaultSelect.includes(`{${key}}`)) {
              this.defaultSelect = this.defaultSelect.replace(new RegExp(`{${key}}`, &amp;#39;g&amp;#39;), changes[key].currentValue);
          }
          if (this.defaultFilter != null &amp;amp;&amp;amp; this.defaultFilter.includes(`{${key}}`)) {
              this.defaultFilter = this.defaultFilter.replace(new RegExp(`{${key}}`, &amp;#39;g&amp;#39;), changes[key].currentValue);
          }
          if (this.defaultExpand != null &amp;amp;&amp;amp; this.defaultExpand.includes(`{${key}}`)) {
              this.defaultExpand = this.defaultExpand.replace(new RegExp(`{${key}}`, &amp;#39;g&amp;#39;), changes[key].currentValue);
          }
          if (this.defaultOrderBy != null &amp;amp;&amp;amp; this.defaultOrderBy.includes(`{${key}}`)) {
              this.defaultOrderBy = this.defaultOrderBy.replace(new RegExp(`{${key}}`, &amp;#39;g&amp;#39;), changes[key].currentValue);
          }
this[key] = undefined;
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now call our library to build the query. We always give priority to our inputs, if they are not defined we use the default values. Here is where &lt;a
href="https://github.com/techniq/odata-query"
target="_blank"
&gt;odata-query&lt;/a&gt; shines and really does some heavy lifting on making sure we are creating correct OData queries.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  const query = buildQuery({
      select: this.select ?? this.defaultSelect,
      filter: this.filter ?? this.defaultFilter,
      expand: this.expand ?? this.defaultExpand,
      orderBy: this.orderBy ?? this.defaultOrderBy,
      top: this.top ?? this.defaultTop,
      skip: this.skip,
      count: this.count,
      search: this.search,
  });
url = `${url}${query}`;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we make the http request and emit the json response.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  const opts = {
      headers: {
          &amp;#39;Accept&amp;#39;: &amp;#39;application/json; odata.metadata=minimal; odata.streaming=true; charset=utf-8&amp;#39;,
          &amp;#39;Authorization&amp;#39;: `Bearer ${process.env[&amp;#34;SYSTEMACCESSTOKEN&amp;#34;]}`,
      },
      method: this.rawMethod || &amp;#34;GET&amp;#34;
  };
const response = await fetch(url, opts);
  const data = await response.json();
this.rawResponse.emit(data);
  this.value.emit(data.value);
  this.countResponse.emit(data[&amp;#39;@odata.count&amp;#39;] ?? null);
  this.nextLink.emit(data[&amp;#39;@odata.nextLink&amp;#39;] ?? null);
this.success.emit(true);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Seeing it Run
&lt;div id="seeing-it-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#seeing-it-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s imagine the scenario where we want to have the latest &lt;strong&gt;Resource Alarms&lt;/strong&gt; for a particular Resource. We will just a simple timer and then logging the output, to showcase the OData call.&lt;/p&gt;
&lt;p&gt;We can defined what are the fields we are interested in, for example &lt;em&gt;Alarm_Cause&lt;/em&gt;, &lt;em&gt;Alarm_Description&lt;/em&gt; and &lt;em&gt;Alarm_Severity&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;We can define the filter - &lt;strong&gt;Resource_Name eq &amp;lsquo;{resourceName}&amp;rsquo;&lt;/strong&gt; and the order by - &lt;strong&gt;Header_StartDateTime desc&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="OData Example"
src="https://image.j-roque.com/posts/20260414-odata/odata-task-example.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The OData url: &lt;code&gt;https://collabhub-cmf-labs-001.apps.rhosdmz.criticalmes.dev/datamanager/odata/CDM/Resource.ResourceAlarm?$select=Alarm_Cause,Alarm_Description,Alarm_Severity,Header_StartDateTime&amp;amp;$filter=Resource_Name%20%20eq%20%27COIL%20BEND%2301%27&amp;amp;$orderby=Header_StartDateTime%20desc&amp;amp;$top=1&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;@odata.context&amp;#34;: &amp;#34;http://collabhub-cmf-labs-001.apps.rhosdmz.criticalmes.dev/datamanager/odata/CDM/$metadata#Resource.ResourceAlarm(Alarm_Cause,Alarm_Description,Alarm_Severity,Header_StartDateTime)&amp;#34;,
  &amp;#34;value&amp;#34;: [
    {
      &amp;#34;Alarm_Cause&amp;#34;: &amp;#34;The safety PLC executed the spindle STO function as designed, cutting drive power to the spindle motor while maintaining axis position locks. The coolant pump was halted by the same safety routine to prevent pump seal damage from cavitation. The fractured coolant line must be replaced, the sump refilled to the correct level, and the system bled of air before restart. A full spindle thermal soak test (30-minute run at 20% load) is required before returning to production.&amp;#34;,
      &amp;#34;Alarm_Description&amp;#34;: &amp;#34;Emergency stop triggered by the spindle overtemperature safety function (STO — Safe Torque Off) after spindle bearing temperature exceeded the 65°C trip threshold defined in the machine safety parameters. Spindle decelerated from 4,200 RPM to rest in 3.1 seconds via the active braking resistor. Coolant pump P1 has been shut down to prevent dry-running damage.&amp;#34;,
      &amp;#34;Alarm_Severity&amp;#34;: &amp;#34;Critical&amp;#34;,
      &amp;#34;Header_StartDateTime&amp;#34;: &amp;#34;2026-04-22T20:11:04.855Z&amp;#34;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The task will then output all of this information, so it can be used by other tasks and perform whatever action the user wants.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is a very simple use case, but this opens the door to a lot of flexibility on how we are able to interact and query data. An enterprise data analytics platform is now &lt;a
href="https://www.criticalmanufacturing.com/mes-for-industry-4-0/the-data-platform-for-manufacturers/?utm_source=linkedin&amp;amp;utm_medium=social&amp;amp;utm_campaign=EDP_122026&amp;amp;utm_term=carroussel&amp;amp;utm_content=feed_post"
target="_blank"
&gt;here&lt;/a&gt;, a first grade toolset is here, what are you waiting for to start being a data driven organization?&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260414-odata/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260414-odata/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Running Python Code in Connect IoT</title>
<link>https://j-roque.com/posts/20260406-python-codetask/</link>
<pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260406-python-codetask/</guid>
<description>Creating a Task with a Custom UI</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260406-python-codetask/featured.png" alt="Running Python Code in Connect IoT" /&gt;&lt;/p&gt;&lt;p&gt;All the code presented here is available at &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/Python"
target="_blank"
&gt;github&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In Critical Manufacturing we&amp;rsquo;ve invested a lot of time and effort in making sure most use cases are covered with the use of configuration and metadata. From an extensive low code platform, to master data, our product is highly configurable and dynamic.&lt;/p&gt;
&lt;p&gt;In this blog post we are going to delve into a more complex scenario, where we build a low code task that has a UI component that can&amp;rsquo;t be described with just metadata.&lt;/p&gt;
&lt;p&gt;Currently, Connect IoT offers a task with a typescript code sandbox. As our analytics use cases grow more complex, python becomes the defacto standard for data orchestration and transformation. Luckily with tools like &lt;a
href="https://pyodide.org/en/stable/"
target="_blank"
&gt;Pyodide&lt;/a&gt; it&amp;rsquo;s relatively simple to incorporate it into Connect IoT.&lt;/p&gt;
&lt;p&gt;The goal is then to build a Connect IoT task which offers a UI with Python code sandbox and is able to run python code and import python libraries.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Debug Manager"
src="https://image.j-roque.com/posts/20260406-python-codetask/debug-execution.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Scaffolding
&lt;div id="scaffolding" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scaffolding" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;a
href="https://criticalmanufacturing.github.io/cli/"
target="_blank"
&gt;CM CLI&lt;/a&gt; is the entrypoint for all customization scaffolding. For most iot implementations where we don&amp;rsquo;t require any UI customization we would just run:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cmf new iot&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In our scenario, we want to create a customization task that is a hybrid between &lt;strong&gt;nodejs&lt;/strong&gt; for the execution code and an &lt;strong&gt;Angular&lt;/strong&gt; component for the rendering in the low code designer. Therefore, the scaffolding is slightly different as we are not just creating a nodejs project, but also an angular one &lt;a
href="https://criticalmanufacturing.github.io/cli/03-explore/guides/iot-scaffolding/#tasks-package-scaffolding-mes-v10-or-above"
target="_blank"
&gt;CM CLI IoT Scaffolding&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In version 10 upwards, when creating a ask with a full on angular customization, there&amp;rsquo;s a dependency between the HTML customization package and the IoT customization package. Therefore, before you scaffold, make sure you have an HTML package already scaffolded.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In order to generate a new package we need to specify the html package location and if it&amp;rsquo;s an angular package:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cmf new iot --isAngularPackage --htmlPackageLocation ./Cmf.Custom.MESProject.HTML&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scaffolding IoT"
src="https://image.j-roque.com/posts/20260406-python-codetask/scaffold-angular-iot.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;We can now generate our angular library that will hold all our tasks and converters.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;npx @angular/cli generate library @criticalmanufacturing/connect-iot-controller-engine-python-tasks&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scaffolding IoT Library"
src="https://image.j-roque.com/posts/20260406-python-codetask/scaffold-angular-library-iot.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The library boiler plate code alread starts with an example task, but let&amp;rsquo;s create our own by running:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;npx @angular/cli generate @criticalmanufacturing/ngx-iot-schematics:task&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scaffolding IoT Tasks"
src="https://image.j-roque.com/posts/20260406-python-codetask/scaffold-angular-task-iot.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In our HTML package we can now finish up by adding our custom package and adding it in the related packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;npm i ../Cmf.Custom.MESProject.IoT.Packages/dist/connect-iot-controller-engine-python-tasks&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and in the &lt;strong&gt;cmfpackage.json&lt;/strong&gt; of the HTML package:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;packageId&amp;#34;: &amp;#34;Cmf.Custom.MESProject.HTML&amp;#34;,
  &amp;#34;version&amp;#34;: &amp;#34;1.0.0&amp;#34;,
  &amp;#34;description&amp;#34;: &amp;#34;Cmf Custom CMFLabs Cmf.Custom.MESProject.HTML Package&amp;#34;,
  &amp;#34;packageType&amp;#34;: &amp;#34;Html&amp;#34;,
  &amp;#34;isInstallable&amp;#34;: true,
  &amp;#34;isUniqueInstall&amp;#34;: false,
  &amp;#34;contentToPack&amp;#34;: [
    {
      &amp;#34;source&amp;#34;: &amp;#34;dist/cmf.custom.mesproject.html/**&amp;#34;,
      &amp;#34;target&amp;#34;: &amp;#34;&amp;#34;,
      &amp;#34;ignoreFiles&amp;#34;: [
        &amp;#34;.npmignore&amp;#34;
      ]
    }
  ],
  &amp;#34;relatedPackages&amp;#34;: [
    {
      &amp;#34;path&amp;#34;: &amp;#34;../Cmf.Custom.MESProject.IoT/Cmf.Custom.MESProject.IoT.Packages&amp;#34;,
      &amp;#34;preBuild&amp;#34;: true,
      &amp;#34;postBuild&amp;#34;: false,
      &amp;#34;prePack&amp;#34;: false,
      &amp;#34;postPack&amp;#34;: false
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally we can import it by adding in the &lt;strong&gt;app.config.ts&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
import { Metadata as CustomTasks } from &amp;#39;@criticalmanufacturing/connect-iot-controller-engine-python-tasks/metadata&amp;#39;;
export const appConfig: ApplicationConfig = {
    providers: [provideRouter(routes), provideServiceWorker(&amp;#39;ngsw-loader-worker.js&amp;#39;, {
        enabled: !isDevMode(),
        registrationStrategy: &amp;#39;registerWhenStable:30000&amp;#39;
    }),
    provideMesUI(
        {
            tasks: [
                CustomTasks
            ]
        }
    ),
    provideMetadataRouter()]
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now run &lt;em&gt;cmf build&lt;/em&gt; and you will have both the HTML package and the IoT Package compiled and ready to go.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Developing
&lt;div id="developing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#developing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With this scaffolding we can now start the local environment.&lt;/p&gt;
&lt;p&gt;Using the vscode task &lt;strong&gt;stack-up&lt;/strong&gt; and running &lt;strong&gt;npm serve&lt;/strong&gt; in the html package. We are able to import our task in the UI and to build our customization.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Task Available in UI"
src="https://image.j-roque.com/posts/20260406-python-codetask/task-available.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Project Folder Structure
&lt;div id="project-folder-structure" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#project-folder-structure" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-md"&gt;connect-iot-controller-engine-python-tasks/
├── .eslintrc.json                                  # ESLint rules for linting TypeScript/Angular code.
├── .gitignore                                      # Git ignore patterns for this project.
├── README.md                                       # Project-level documentation (this file).
│                                                   # Packed npm artifact produced by packaging.
├── ng-package.json                                 # ng-packagr config for the designer bundle output.
├── packConfig.json                                 # CMF package type definition (TasksPackage).
├── package.json                                    # npm metadata, dependencies, and build/test scripts.
├── tsconfig.lib.json                               # TypeScript config for Angular library build.
├── tsconfig.lib.prod.json                          # Production variant of library TypeScript config.
├── tsconfig.lib.runtime.json                       # Runtime (CommonJS) TypeScript build config.
├── tsconfig.spec.json                              # TypeScript config used by Angular test tooling.
│
├── metadata/
│   ├── ng-package.json                             # ng-packagr config for metadata package output.
│   └── src/
│       ├── public-api.ts                           # Public exports for metadata package.
│       └── lib/
│           └── metadata.ts                         # Connect IoT package metadata (name, load, tasks list).
│
├── src/
│   ├── public-api-designer.ts                      # Designer-time entry point exports (module/designer/settings).
│   ├── public-api-runtime.ts                       # Runtime entry point exports (module only).
│   ├── public-api-runtime.js                       # Compiled JS for runtime entry point.
│   ├── public-api-runtime.d.ts                     # Type declarations for runtime entry point.
│   ├── public-api-runtime.js.map                   # Source map for runtime entry point JS.
│   └── lib/
│       └── tasks/
│           └── python-code/
│               ├── README.md                       # Detailed documentation for python-code task folder.
│               ├── python-code.task.ts             # Main runtime task implementation and lifecycle.
│               ├── python-code.task-module.ts      # Task module registration that references PythonCodeTask.
│               ├── python-code.task-designer.ts    # Design-time task metadata and dynamic I/O resolver logic.
│               ├── python-code-settings.component.ts
│               │                                   # Angular settings component for task configuration.
│               ├── python-code-settings.component.html
│               │                                   # Settings UI template displayed in Connect IoT designer.
│               └── python-code-settings.component.less
│                                                   # Styles for settings component (currently empty).
│
└── test/
    └── unit/
        ├── tsconfig.json                           # TypeScript config for unit-test compilation.
        └── tasks/
            └── python-code/
                └── python-code.task.test.ts        # Unit test source for PythonCode task behavior.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Task Folder Structure
&lt;div id="task-folder-structure" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#task-folder-structure" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;python-code.task.ts&lt;/code&gt; defines runtime behavior.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python-code.task-module.ts&lt;/code&gt; registers that task with the engine module system.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python-code.task-designer.ts&lt;/code&gt; exposes task metadata and input/output behavior at design time.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python-code-settings.component.ts&lt;/code&gt; provide the task settings UI used in the designer.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python-code-settings.component.html&lt;/code&gt; provide the task settings UI.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python-code-settings.component.less&lt;/code&gt; provide the task settings UI css.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;em&gt;task.ts&lt;/em&gt; file will focus on describing the runtime behavior, the other elements are all describing the UI behavior.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Designer
&lt;div id="designer" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#designer" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the task designer we can start by defining what our task inputs and outputs look like. For this task we want to use &lt;code&gt;autoports&lt;/code&gt;, inputs and outputs that by dragging and dropping links we can create a connection.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s start by the task designer metadata:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
@TaskDesigner({
    name: $localize`:@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode#TITLE:Python Code`,
    iconClass: &amp;#34;icon-core-tasks-connect-iot-lg-codeexecution&amp;#34;,
    inputs: {
        autoIn: &amp;lt;Task.TaskType&amp;gt;{
            friendlyName: $localize`:@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode#AUTO_INPUT_PORT_TEXT:Link here to add new`,
            type: Task.AUTO
        },
        activate: Task.INPUT_ACTIVATE
    },
    outputs: {
        autoOut: &amp;lt;Task.TaskType&amp;gt;{
            friendlyName: $localize`:@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode#AUTO_OUTPUT_PORT_TEXT:Link from here to add new`,
            type: Task.AUTO
        },
        success: Task.OUTPUT_SUCCESS,
        error: Task.OUTPUT_ERROR
    },
    protocol: TaskProtocol.All,
    driverOptional: true
})
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We start by giving it a better icon and by referencing an &lt;em&gt;autoIn&lt;/em&gt; input and an &lt;em&gt;autoOut&lt;/em&gt; output. The &lt;em&gt;driverOptional&lt;/em&gt; is also important as it will query the user if it wishes to associate the task to a driver, and if so which driver.&lt;/p&gt;
&lt;p&gt;We start by creating our settings and inputs and outputs.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;export class PythonCodeDesigner implements TaskDesignerInstance, PythonCodeSettings {
    // Settings
    pyCode: string[];
    pyCodeBase64: string;
    packages: string[];
    contextExpirationInMilliseconds: number;
    executionTimeoutMs: number;
// Inputs &amp;amp; Outputs
    inputs: Task.TaskInput[];
    outputs: Task.TaskOutput[];&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We also add some code to manage the injection of the &lt;em&gt;autoports&lt;/em&gt; into the context. This code is more or less boiler plate, you can check it &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/Python/Cmf.Custom.MESProject.IoT.Python/Cmf.Custom.MESProject.IoT.Python.Packages/projects/controller-engine-python-tasks/src/lib/tasks/python-code/python-code.task-designer.ts"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;HTML
&lt;div id="html" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In ours task settings we will have static settings and a python code sandbox.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="HTML Deconstruction"
src="https://image.j-roque.com/posts/20260406-python-codetask/html-deconstruction.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our html we can start by defining our static settings.&lt;/p&gt;
&lt;p&gt;These settings will exist in the Settings tab of the settings page.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;As you can see CM already provides a lot of different components we can use, if you are curious about them, feel free to take a look &lt;a
href="https://developer.criticalmanufacturing.com/11.3/reference/api-ui/core-html/cmf-core-business-controls/modules/PropertyEditorModule.html"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;&amp;lt;cmf-core-connect-iot-taskSettings [showInputsTab]=&amp;#34;true&amp;#34; [showOutputsTab]=&amp;#34;true&amp;#34; selectedTab=&amp;#34;Code&amp;#34; [saveOnKeyboardShortcut]=&amp;#34;true&amp;#34;&amp;gt;
  &amp;lt;cmf-core-controls-baseWidget i18n-mainTitle=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#SETTINGS&amp;#34; mainTitle=&amp;#34;Settings&amp;#34;&amp;gt;
      &amp;lt;cmf-core-business-controls-propertyContainer&amp;gt;
          &amp;lt;!-- expiration time --&amp;gt;
          &amp;lt;cmf-core-business-controls-propertyEditor
              i18n-label=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#EXPIRATION_TIME&amp;#34;
              label=&amp;#34;Expiration Time&amp;#34;
              [(value)]=&amp;#34;settings.contextExpirationInMilliseconds&amp;#34;
              [required]=&amp;#34;true&amp;#34;
              [min]=&amp;#34;0&amp;#34;
              valueType=&amp;#34;Integer&amp;#34;
              i18n-infoMessage=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#EXPIRATION_TIME_TOOLTIP&amp;#34;
              infoMessage=&amp;#34;Expiration time for the values received for the same context, without updates (0 = never)&amp;#34;
          &amp;gt;&amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
&amp;lt;!-- packages --&amp;gt;
          &amp;lt;cmf-core-business-controls-propertyEditor
              i18n-label=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#PACKAGES&amp;#34;
              label=&amp;#34;Packages&amp;#34;
              [(value)]=&amp;#34;settings.packages&amp;#34;
              valueType=&amp;#34;String&amp;#34;
              i18n-infoMessage=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#PACKAGES_TOOLTIP&amp;#34;
              infoMessage=&amp;#34;Python packages to install via micropip (comma-separated, e.g. numpy, pandas)&amp;#34;
          &amp;gt;&amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
&amp;lt;!-- execution timeout --&amp;gt;
          &amp;lt;cmf-core-business-controls-propertyEditor
              i18n-label=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#EXECUTION_TIMEOUT&amp;#34;
              label=&amp;#34;Execution Timeout (ms)&amp;#34;
              [(value)]=&amp;#34;settings.executionTimeoutMs&amp;#34;
              [required]=&amp;#34;true&amp;#34;
              [min]=&amp;#34;0&amp;#34;
              valueType=&amp;#34;Integer&amp;#34;
              i18n-infoMessage=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#EXECUTION_TIMEOUT_TOOLTIP&amp;#34;
              infoMessage=&amp;#34;Maximum time in ms the Python code may run before a timeout error (0 = disabled)&amp;#34;
          &amp;gt;&amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
&amp;lt;/cmf-core-business-controls-propertyContainer&amp;gt;
  &amp;lt;/cmf-core-controls-baseWidget&amp;gt;
  (...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can also declare our python code sandbox.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;  (...)
    &amp;lt;cmf-core-connect-iot-taskSettingsTab i18n-mainTitle=&amp;#34;@@@criticalmanufacturing/connect-iot-controller-engine-python-tasks/pythonCode.settings#CODE&amp;#34; mainTitle=&amp;#34;Code&amp;#34; [fullHeight]=&amp;#34;true&amp;#34; tab-id=&amp;#34;Code&amp;#34;&amp;gt;
        &amp;lt;div class=&amp;#34;codeEditor&amp;#34;&amp;gt;
            &amp;lt;cmf-core-controls-codeEditor
                [autoIndent]=&amp;#34;true&amp;#34;
                cmf-core-controls-validator
                [language]=&amp;#34;CodeEditorLanguage.Python&amp;#34;
                [value]=&amp;#34;_pyCode&amp;#34;
                (valueChange)=&amp;#34;onCodeEditorValueChange($event)&amp;#34;&amp;gt;
            &amp;lt;/cmf-core-controls-codeEditor&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/cmf-core-connect-iot-taskSettingsTab&amp;gt;
&amp;lt;/cmf-core-connect-iot-taskSettings&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Component
&lt;div id="component" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#component" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The component is where we will define the logic controlling our UI.&lt;/p&gt;
&lt;p&gt;It will focus on two main segments, the first is storing the values provided in the settings, the second is providing a template and a custom &lt;em&gt;intelisense&lt;/em&gt; to the python code sandbox.&lt;/p&gt;
&lt;p&gt;In the &lt;em&gt;ngOnInit&lt;/em&gt; we will load the values for the settings, with the defaults or with the previously defined values.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  /** Triggered when the task is created — applies default values */
  public ngOnInit(): void {
      const currentSettings = Object.assign({}, this.settings);
      Object.assign(this.settings, SETTINGS_DEFAULTS, currentSettings);
this._pyCode = decode(this.settings.pyCodeBase64 || &amp;#34;&amp;#34;) || this.fromMultilineArray(this.settings.pyCode) || PYTHON_TEMPLATE_CONTENT;
this.registerFrameworkCompletions();
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For our python code sandbox we have defined a template to start with the &lt;strong&gt;PYTHON_TEMPLATE_CONTENT&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Python Template"
src="https://image.j-roque.com/posts/20260406-python-codetask/python-sandbox-template.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/** Python code template shown in the editor by default */
export const PYTHON_TEMPLATE_CONTENT: string =
    `class Code:
    def __init__(self, framework):
        self.logger = framework[&amp;#39;logger&amp;#39;]
        self.data_store = framework[&amp;#39;data_store&amp;#39;]
        self.message_bus = framework[&amp;#39;message_bus&amp;#39;]
        self.system = framework[&amp;#39;system&amp;#39;]
        self.utils = framework[&amp;#39;utils&amp;#39;]
        self.lbos = framework[&amp;#39;lbos&amp;#39;]
        # self.driver = framework[&amp;#39;driver&amp;#39;]
async def main(self, inputs, outputs):
        # Add code here
# Emit output during execution: outputs.output1.emit(&amp;#34;something&amp;#34;)
        # Return example: return { &amp;#39;output1&amp;#39;: inputs[&amp;#39;input1&amp;#39;], &amp;#39;output2&amp;#39;: &amp;#39;Hello World&amp;#39; }
        pass
`;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;em&gt;registerFrameworkCompletions&lt;/em&gt; method is where we will handle all the provided intelisense for the sandbox. Feel free to check it in detail &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/Python/Cmf.Custom.MESProject.IoT.Python/Cmf.Custom.MESProject.IoT.Python.Packages/projects/controller-engine-python-tasks/src/lib/tasks/python-code/python-code-settings.component.ts#L154"
target="_blank"
&gt;here&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Python Template"
src="https://image.j-roque.com/posts/20260406-python-codetask/intelisense-python.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Task
&lt;div id="task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The task will be a wrapper for the &lt;em&gt;pyodide&lt;/em&gt; provided dependency injection container.&lt;/p&gt;
&lt;p&gt;The task onInit will retrieve the code saved in the settings of the task, intialize the pyodideManager and compile the code.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  override async onInit(): Promise&amp;lt;void&amp;gt; {
      if (!this.pyCodeBase64) {
          return;
      }
const pythonCode = decode(this.pyCodeBase64);
try {
          const settings: PyodideSettings = {
              packages: this.packages ?? [],
              executionTimeoutMs: this.executionTimeoutMs
          };
// Initialize the shared Pyodide runtime (no-op if already initialized by another task)
          await this._pyodideManager.initialize(settings);
// Compile the Python code into a sandboxed NodeVM function — store it for reuse,
          // same as _sandBoxedCode in the TS code-execution task
          this._sandboxedCode = await this._pyodideManager.compile(pythonCode);
      } catch (err) {
          this._logger.error(`Failed to compile Python script: ${(err as Error).message}`);
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The pyodideManager will on initialize, start &lt;em&gt;pyodide&lt;/em&gt; and import all the declared python libraries using &lt;a
href="https://github.com/pyodide/micropip"
target="_blank"
&gt;micropip&lt;/a&gt;. After initializing it will block the sandbox from injecting and retrieving information from the node process, to stop unexpected code injections.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  public async initialize(settings: PyodideSettings): Promise&amp;lt;void&amp;gt; {
      if (this._initialized) {
          this._logger.warning(&amp;#34;PyodideManager already initialized — skipping.&amp;#34;);
          return;
      }
this._logger.debug(&amp;#34;Initializing Pyodide runtime...&amp;#34;);
try {
          const { loadPyodide } = require(&amp;#34;pyodide&amp;#34;) as { loadPyodide: (options?: Record&amp;lt;string, unknown&amp;gt;) =&amp;gt; Promise&amp;lt;PyodideRuntime&amp;gt; };
          this._pyodide = await loadPyodide();
if (settings.packages?.length &amp;gt; 0) {
              this._logger.info(`Installing Python packages: ${settings.packages.join(&amp;#34;, &amp;#34;)}`);
              await this._pyodide.loadPackage(&amp;#34;micropip&amp;#34;);             
              const micropip = this._pyodide.pyimport(&amp;#34;micropip&amp;#34;);
await micropip.install(settings.packages);
              this._logger.info(&amp;#34;Python packages installed.&amp;#34;);
          }
// Replace `js` and `pyodide_js` with empty namespaces so user Python
          // code cannot access the Node.js global scope (globalThis) via either
          // `import js` or `import pyodide_js` (both map to globalThis in Pyodide).
          // This must happen AFTER micropip package installation because micropip&amp;#39;s
          // compat layer imports from `pyodide_js` at module load time.
          await this._pyodide.runPythonAsync(`
import sys, types
_blocked = types.SimpleNamespace()
sys.modules[&amp;#39;js&amp;#39;] = _blocked
sys.modules[&amp;#39;pyodide_js&amp;#39;] = _blocked
`);
this._initialized = true;
          this._logger.info(&amp;#34;Pyodide runtime ready.&amp;#34;);
      } catch (err) {
          this._logger.error(`Failed to initialize Pyodide: ${(err as Error).message}`);
          throw err;
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the task activate input is set, the task &lt;em&gt;onChanges&lt;/em&gt; is triggered. In the &lt;em&gt;onChanges&lt;/em&gt; we prepare all the inputs and outputs and called the sandboxed callback generated in the &lt;em&gt;onInit&lt;/em&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    (...)
    for (const key in changes) {
        if (this.isInput(key)) {
            const propertyName = Utilities.inputToProperty(key);
            const previousValue = syncContext.values[propertyName];
            if (previousValue != null) {
                this._logger.debug(`Property &amp;#39;${propertyName}&amp;#39; was already defined for context &amp;#39;${syncContext.name}&amp;#39;. Value lost: ${previousValue}`);
            }
            syncContext.values[propertyName] = changes[key].currentValue;
            this[key] = undefined;
        }
    }
    (...)
    const runtimeOutputs: PyodideOutputsProxy = {};
    for (const output of this.outputs) {
        runtimeOutputs[output.name] = {
            emit: (value: unknown) =&amp;gt; {
                currentContext.run(() =&amp;gt; {
                    this[Utilities.propertyToOutput(output.name)].emit(value);
                });
            }
        };
    }
(...)
    result = await this._sandboxedCode(syncContext.values, runtimeOutputs);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Module Export
&lt;div id="module-export" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#module-export" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;When dealing with node packages we need to separate what is going to be provided to the UI and what is going to be provided to the nodejs runtime.&lt;/p&gt;
&lt;p&gt;In our case we are using a nodejs library &lt;a
href="https://github.com/pyodide/pyodide"
target="_blank"
&gt;pyodide&lt;/a&gt; which can only be provided to nodejs.&lt;/p&gt;
&lt;p&gt;We can control that by changing the &lt;strong&gt;public-api-designer.ts&lt;/strong&gt; which controls what the UI ingests and the &lt;strong&gt;public-api-runtime.ts&lt;/strong&gt; which controls what nodejs uses.&lt;/p&gt;
&lt;p&gt;For that in our public api designer, we will define it as depending on a different module &lt;strong&gt;python-code.task-browser.module&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/*
 * Public API Surface of @criticalmanufacturing/connect-iot-controller-engine-python-tasks for design
 */
// PythonCode
export { PythonCodeModule } from &amp;#39;./lib/tasks/python-code/python-code.task-browser.module&amp;#39;;
export { PythonCodeDesigner } from &amp;#39;./lib/tasks/python-code/python-code.task-designer&amp;#39;;
export { PythonCodeSettings } from &amp;#39;./lib/tasks/python-code/python-code-settings.component&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this module we are importing the class &lt;strong&gt;PyodideManagerBrowser&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Task.TaskModule({
    task: PythonCodeTask,
    providers: [
        {
            class: PyodideManagerBrowser,
            isSingleton: true,
            symbol: PYODIDE_MANAGER_SYMBOL,
            scope: Task.ProviderScope.Local,
        }
    ]
})
export class PythonCodeModule { }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the &lt;strong&gt;public-api-runtime.ts&lt;/strong&gt; we will now define for a different provided class &lt;strong&gt;PyodideManagerHandler&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/*
 * Public API Surface of @criticalmanufacturing/connect-iot-controller-engine-python-tasks for runtime
 */
// PythonCode
export { PythonCodeModule } from &amp;#39;./lib/tasks/python-code/python-code.task-node.module&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Task.TaskModule({
    task: PythonCodeTask,
    providers: [
        {
            class: PyodideManagerHandler,
            isSingleton: true,
            symbol: PYODIDE_MANAGER_SYMBOL,
            scope: Task.ProviderScope.Local,
        }
    ]
})
export class PythonCodeModule { }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Running and Debugging
&lt;div id="running-and-debugging" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-and-debugging" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;First, let&amp;rsquo;s open the vscode in the scope of the IoT package, then we can run the &lt;em&gt;vscode&lt;/em&gt; task &lt;strong&gt;Setup Start Manager&lt;/strong&gt;, then we can just run the launch for the &lt;strong&gt;Start Manager&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Setup Manager for Debug"
src="https://image.j-roque.com/posts/20260406-python-codetask/debug-manager.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I prepared a simple example of a python code task that will call an MES service and use a common python library pandas to perform some data manipulation and emit values.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;import json
import pandas as pd
class Code:
    def __init__(self, framework):
        self.logger = framework[&amp;#39;logger&amp;#39;]
        self.data_store = framework[&amp;#39;data_store&amp;#39;]
        self.message_bus = framework[&amp;#39;message_bus&amp;#39;]
        self.system = framework[&amp;#39;system&amp;#39;]
        self.utils = framework[&amp;#39;utils&amp;#39;]
        self.lbos = framework[&amp;#39;lbos&amp;#39;]
async def main(self, inputs, outputs):
        # Add code here
input_obj = self.lbos.Cmf.Foundation.BusinessOrchestration.GenericServiceManagement.InputObjects.GetObjectByNameInput.new();
        input_obj.Name = &amp;#34;test&amp;#34;;
        input_obj.Type = &amp;#34;Site&amp;#34;;
        result = await self.system.call(input_obj)
data = {&amp;#34;a&amp;#34;: 1, &amp;#34;b&amp;#34;: 2}
        self.logger.warning(json.dumps(data, indent=4));
self.logger.warning(self.utils.stringify(result));
df = pd.DataFrame({
            &amp;#34;name&amp;#34;: [&amp;#34;João&amp;#34;, &amp;#34;Ana&amp;#34;, &amp;#34;Miguel&amp;#34;],
            &amp;#34;age&amp;#34;: [32, 29, 41],
            &amp;#34;city&amp;#34;: [&amp;#34;Porto&amp;#34;, &amp;#34;Lisboa&amp;#34;, &amp;#34;Braga&amp;#34;]
        })
print(df)
obj = df.to_dict(orient=&amp;#34;records&amp;#34;)
        print(obj)
outputs.output1.emit(obj)
        pass&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Debug Manager"
src="https://image.j-roque.com/posts/20260406-python-codetask/debug-execution.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;With just this snippet we open the door to deep integrations with the MES and all the third-party ecosystem python offers.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Adding it to our Repository
&lt;div id="adding-it-to-our-repository" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#adding-it-to-our-repository" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;If you want to run it outside of debug. Remember that the runtime process is retrieving the information from the repository so if you want to make it available in the repository. You can either run cmf pack and add the &lt;em&gt;.tgz&lt;/em&gt; via the Automation Repository or you can run the command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cd &amp;lt;your iot project&amp;gt;
npx yo @criticalmanufacturing/iot:packagePacker -o .
cp &amp;lt;your customization project&amp;gt;/Cmf.Custom.MESProject.IoT/Cmf.Custom.MESProject.IoT.Packages/projects/connect-iot-controller-engine-python-tasks/criticalmanufacturing-connect-iot-controller-engine-python-tasks-0.0.0.tgz &amp;lt;your customization project&amp;gt;.localenv/packages/IoT/repo
# Will recompile the iot repository
sudo cmf dev iot rebuildDatabase &amp;lt;your customization project&amp;gt;/.localenv/packages/IoT/repo&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Repository Sync"
src="https://image.j-roque.com/posts/20260406-python-codetask/repository-sync.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Some low code platforms are tight sandboxes where the user can&amp;rsquo;t create his own components.&lt;/p&gt;
&lt;p&gt;In Connect IoT we aim to make the simple cases as simple as possible, but always keep in sight that the world is adaptable and complex.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260406-python-codetask/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260406-python-codetask/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Leveraging a UI Framework</title>
<link>https://j-roque.com/posts/20260325-ui-persistencyviewer/</link>
<pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260325-ui-persistencyviewer/</guid>
<description>Leveraging a UI Framework to interact with Automation</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260325-ui-persistencyviewer/featured.png" alt="Leveraging a UI Framework" /&gt;&lt;/p&gt;&lt;p&gt;All the code presented here is available at &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/PersistencyViewer/Cmf.Custom.MESProject.HTML/projects/customization-json-persistency"
target="_blank"
&gt;github&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;An MES system is a very holistic software. It reaches different elements of the shopfloor and different personas.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;From acting as machine HMIs to global enterprise dashboards. The MES is everywhere in your shopfloor.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Manufacturing systems focus a lot on mapping complex business logic, from managing complex logistic flows, sampling flows or even experiment flows. These are all bread and butter for a mature MES system. But these are systems that need to be tailored to their user base.&lt;/p&gt;
&lt;p&gt;They need to be &lt;strong&gt;easily adjusted and customized to fit manufacturing scenarios&lt;/strong&gt; and to minimize wasteful interaction. For us, this has been mostly achieved with a low code UI platform for user interactions and leveraging grafana for the creation of dashboards.&lt;/p&gt;
&lt;p&gt;And we have a whole presentation on that:&lt;/p&gt;
&lt;hr&gt;
&lt;lite-youtube videoid="DRpXyuk04mc" playlabel="DRpXyuk04mc" params=""&gt;&lt;/lite-youtube&gt;
&lt;hr&gt;
&lt;p&gt;In this blog post I want to tackle more &lt;strong&gt;complex scenarios&lt;/strong&gt; and show how even for those there is a &lt;strong&gt;common framework and extension points&lt;/strong&gt; we can use to create our own tailored solutions.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Custom Project
&lt;div id="custom-project" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#custom-project" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the Critical Manufacturing ecosystem we always start from the sandbox of a customization project. You can check &lt;a
href="https://criticalmanufacturing.github.io/cli/02-learn/tutorials/1-scaffolding/2-project-scaffold/"
target="_blank"
&gt;here&lt;/a&gt; how to scaffold a project.&lt;/p&gt;
&lt;p&gt;After we create an HTML package we will be able to spin up a local MES instance, by doing:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cd &amp;lt;your html package&amp;gt;
cmf build&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we can spin up a local server of our UI, by running:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;cd &amp;lt;your html package&amp;gt;
npm run serve&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Serve"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui_serve.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can spin up a new local stack.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VSCode tasks - select stack-up&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Local Stack"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/mes_stack.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;We now have a fully functioning workspace where we can create our custom implementation.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Use Case
&lt;div id="use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In integration scenarios it&amp;rsquo;s often the case where we store information on the edge. This is critical information for the machine interfacing process or is cached information that it&amp;rsquo;s needed for performance reasons. I go into some details in a previous blog post &lt;a
href="https://j-roque.com/posts/20250617-sqlite-i/#persisted-data-in-connect-iot"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Our use case today is to have a view of this information in the MES.&lt;/p&gt;
&lt;p&gt;We want the UI to be able to show information, from key values to complex information payloads. Adding also the ability to override current values.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui_demo.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;Let&amp;rsquo;s now see all the steps we took to be able to create this interactive view.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Injecting buttons on the Entity View
&lt;div id="injecting-buttons-on-the-entity-view" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#injecting-buttons-on-the-entity-view" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;An &lt;strong&gt;entity page view&lt;/strong&gt; is a component that you see every time you open an MES instance.&lt;/p&gt;
&lt;p&gt;CM MES is very flexible and allows you to inject your own custom components in the MES UI. For our use case we want to inject our button in the entity page of the &lt;strong&gt;AutomationControllerInstance&lt;/strong&gt; entity.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Instance Page"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/aci-entityview.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;You can take a look at the deep dive &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/presentation/entitypageviews/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Generating a Library
&lt;div id="generating-a-library" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#generating-a-library" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s start by &lt;strong&gt;generating our library&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;ng generate library customization-json-persistency&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Generate Library"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/generate_library.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This command will prepare everything you need in the system to start creating your angular component.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Injecting our Component in the MES UI
&lt;div id="injecting-our-component-in-the-mes-ui" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#injecting-our-component-in-the-mes-ui" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We can now have our custom component always compiling by in our terminal running:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;ng build --watch&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command will trigger a recompile whenever there is a change in the code and will notify that UI to rebuild.&lt;/p&gt;
&lt;p&gt;In order to inject our component in the MES UI we need to go to the &lt;strong&gt;metadata of our library&lt;/strong&gt; under &amp;lt; your project &amp;gt;/projects/customization-json-persistency/metadata/src/lib/customization-json-persistency-metadata.service.ts . Here we will have a &lt;strong&gt;set of different entrypoints to inject and override different components&lt;/strong&gt; into different parts of the MES application.&lt;/p&gt;
&lt;p&gt;In the &lt;em&gt;entityTypes&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  /**
   * Entity Types
   */
  public override get entityTypes(): EntityTypeMetadata[] {
      return [];
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can add our &lt;em&gt;new button to route to our component&lt;/em&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;{
  name: &amp;#39;AutomationControllerInstance&amp;#39;,
  views: [{
      id: &amp;#39;Custom.AutomationControllerInstancePersistencyViewer&amp;#39;,
      name: &amp;#39;Persistency Viewer&amp;#39;,
      path: &amp;#39;persistency-viewer&amp;#39;,
      loadComponent: () =&amp;gt; import(
          &amp;#39;customization-json-persistency&amp;#39;)
          .then(m =&amp;gt; m.CustomizationJsonPersistencyComponent),
      type: ViewType.Simple
  }]
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;name&lt;/strong&gt; - will match the entity that we wish to override the entity view of&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;name&lt;/strong&gt; in the views - is where we provide the label to show to the user on his selection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;path&lt;/strong&gt; - is the value that will be used to create the route&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;loadComponent&lt;/strong&gt; - is where we will specify what component to load on button press&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simple&lt;/strong&gt; view type - means that the component will be rendered inside of the original page, the &lt;strong&gt;Advanced&lt;/strong&gt; will act like a shortcut button to another page&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Injecting an Entity View Button"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/injecting_entityview_button.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;With &lt;strong&gt;just metadata we have the routing for our component done&lt;/strong&gt;. Now let&amp;rsquo;s work on building the connection between our component and Connect IoT.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Framework Message Bus System
&lt;div id="framework-message-bus-system" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#framework-message-bus-system" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;There are a set of dependency injection containers the system offer. All of the Services, Data Sources, Controls provided by the CM MES UI framework are available &lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/index.html"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/index.html"
title="UI API Reference"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-1').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-1"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-1').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/index.html"
title="UI API Reference"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-1');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;p&gt;One of the features that we will be using is the ability to &lt;strong&gt;send and receive messages from the UI to the Connect IoT layer&lt;/strong&gt;. This is made possible by the &lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/core-html/cmf-core/injectables/MessageBusService.html"
target="_blank"
&gt;MessageBus Service&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;We will query the Connect IoT for all the keys of the persistency.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  import { Component, ElementRef, inject } from &amp;#39;@angular/core&amp;#39;;
  import {
    FeedbackService,
    MessageBusService
  } from &amp;#39;cmf-core&amp;#39;;
  import { PageBag } from &amp;#34;cmf-core-controls&amp;#34;;
(...)
// Angular 
  private _elementRef = inject(ElementRef);
// cmf-core
  private _messageBus = inject(MessageBusService);
  private _feedback = inject(FeedbackService);
// cmf-core-controls
  private _pageBag = inject(PageBag);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will first retrieve from the dependency injection all the necessary services.&lt;/p&gt;
&lt;p&gt;We are using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/core-html/cmf-core/injectables/MessageBusService.html"
target="_blank"
&gt;MessageBus Service&lt;/a&gt; - use of the message bus to request information from Connect IoT&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/core-html/cmf-core/injectables/FeedbackService.html"
target="_blank"
&gt;Feedback Service&lt;/a&gt; - allows the use of the progress indicator, and displaying messages in the UI&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/core-html/cmf-core-controls/injectables/PageBag.html"
target="_blank"
&gt;PageBag Service&lt;/a&gt; - interacts with the page, in this case will be to retrieve the page context&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Our requirement is that, when the user opens this page we will request information from the &lt;strong&gt;Automation Controller Instance&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  private get automationControllerRequestTopic(): string {
    return `CMF.Cmf.Foundation.BusinessObjects.AutomationControllerInstance.${this._pageBag?.context?.id}.SENDREQUEST`;
  }
public activeListKeys: { name: string }[] = [];
/**
   * Subscribes to entity load events and fetches persistency keys when context is available.
   */
  public ngOnInit(): void {
if (this._pageBag?.context?.id) {
      this.retrieveListOfPersistencyKeys();
    }
  }
  /**
   * Retrieves the list of available persistency keys for the current context.
   */
  private async retrieveListOfPersistencyKeys() {
    this._feedback.startProgressIndicator(this._elementRef);
    this._messageBus
      .sendRequest(
        this.automationControllerRequestTopic,
        {
          &amp;#34;type&amp;#34;: &amp;#34;GetPersistencyKeyList&amp;#34;,
          &amp;#34;data&amp;#34;: &amp;#34;Request&amp;#34;
        },
        this._timeout
      )
      .then((result) =&amp;gt; {
        this._feedback.stopProgressIndicator(this._elementRef);
        this.activeListKeys = JSON.parse(result.Data).map((key: string) =&amp;gt; { return { name: key } });
      })
      .catch((e) =&amp;gt; {
        this._feedback.stopProgressIndicator(this._elementRef);
        throw new Error(
          $localize`:@@customization-json-persistency/customization-json-persistency#feedback.error:No Message Received with Persistency Key List: ${e}`
        );
      });
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We are using the &lt;em&gt;sendRequest&lt;/em&gt; method, to enforce that we must have a reply.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Message bus supports a &lt;code&gt;Publish&lt;/code&gt;, which is a fire-and-forget notification and a &lt;code&gt;SendRequest&lt;/code&gt; which waits for the first reply in a set amount of time.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;The topic that we are using to send a message in the message bus is constructed from the controller instance and then the specific message topic &lt;code&gt;GetPersistencyKeyList&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now we will start an instance to run and talk with our UI. We can achieve that, by &lt;strong&gt;running a manager locally in our stack&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cmd"&gt;VSCode tasks - stack-up-iot-manager&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When starting it will ask which manager we wish to spun up and we can provide the manager name.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Stack Up IoT Manager"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/stackup-iot-manager.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In Connect IoT, we create a low code workflow that subscribes to this topic and retrieves all keys from the persistency.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="List Keys - IoT"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/list-keys.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now the UI is able to retrieve the list information from Connect IoT.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Selection Component
&lt;div id="selection-component" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#selection-component" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;One of the more versatile component offered by the CM UI framework is the &lt;a
href="https://developer.criticalmanufacturing.com/11.2/reference/api-ui/core-html/cmf-core-business-controls/components/PropertyEditor.html#info"
target="_blank"
&gt;Property Editor&lt;/a&gt;. It handles most of the rendering of elements from primitive elements like numbers and strings to more complex, enumerations and search of entities.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In the &lt;a
href="https://help.criticalmanufacturing.com/11.2/Reference/DesignSystem/Components%3EPropertyEditor"
target="_blank"
&gt;design system&lt;/a&gt; we have demos setup for the most common use components where you can see how to use them and have a playground.&lt;/p&gt;
&lt;div class="not-prose"&gt;
&lt;div style="position:relative; width:100%; height:500px; border-radius:0.5rem; overflow:hidden;"&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/11.2/Reference/DesignSystem/Components%3EPropertyEditor"
title="Property Editor"
style="border:none; display:block; width:100%; height:100%; zoom:0.5;"
loading="lazy"
&gt;&lt;/iframe&gt;
&lt;button
onclick="document.getElementById('iframe-modal-3').classList.remove('invisible')"
title="Open fullscreen"
style="position:absolute; bottom:0.5rem; right:0.5rem; display:flex; align-items:center; gap:0.3rem; padding:0.3rem 0.6rem; border-radius:0.375rem; border:none; cursor:pointer; font-size:0.75rem; font-weight:600; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M15 3a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1ZM3 9a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 0 1 1.414 1.414L5.414 14H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z" clip-rule="evenodd"/&gt;&lt;path fill-rule="evenodd" d="M3 15a1 1 0 0 1 1 1v2.586l4.293-4.293a1 1 0 1 1 1.414 1.414L5.414 18H8a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1ZM21 3a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V5.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L18.586 4H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
Fullscreen
&lt;/button&gt;
&lt;/div&gt;
&lt;div
id="iframe-modal-3"
class="invisible"
onclick="if(event.target===this)this.classList.add('invisible')"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:rgba(64,64,64,0.6); backdrop-filter:blur(4px); padding:1.5rem;"
&gt;
&lt;div style="position:relative; width:100%; height:100%; max-width:1400px; border-radius:0.5rem; overflow:hidden; box-shadow:0 25px 50px rgba(0,0,0,0.4);"&gt;
&lt;button
onclick="document.getElementById('iframe-modal-3').classList.add('invisible')"
title="Close"
style="position:absolute; top:0.5rem; right:0.5rem; z-index:10; display:flex; align-items:center; justify-content:center; width:2rem; height:2rem; border-radius:9999px; border:none; cursor:pointer; background:rgba(0,0,0,0.55); color:#fff; backdrop-filter:blur(4px);"
&gt;
&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:1em;height:1em;"&gt;&lt;path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/&gt;&lt;/svg&gt;
&lt;/button&gt;
&lt;iframe
src="https://help.criticalmanufacturing.com/11.2/Reference/DesignSystem/Components%3EPropertyEditor"
title="Property Editor"
style="border:none; display:block; width:100%; height:100%;"
allowfullscreen
&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
(function() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var m = document.getElementById('iframe-modal-3');
if (m) m.classList.add('invisible');
}
});
})();
&lt;/script&gt;
&lt;hr&gt;
&lt;p&gt;Adding to our custom component.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;(...)
import { PropertyEditorModule } from &amp;#39;cmf-core-business-controls&amp;#39;;
@Component({
  selector: &amp;#39;lib-customization-json-persistency&amp;#39;,
  standalone: true,
  imports: [CommonModule, PropertyEditorModule, CodeEditorModule],
  templateUrl: `./customization-json-persistency.component.html`,
  styleUrls: [&amp;#39;./customization-json-persistency.component.less&amp;#39;]
})
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We want a ComboBox view where we can choose from a list of items.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data&lt;/strong&gt; with value activeListKeys - is where we will store an array of objects with a property name&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dataNameField&lt;/strong&gt; - will select a property from the array of objects to use as a label to show to the user&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;valueChange&lt;/strong&gt; - will be triggered on value changed and will run the method onControlValueChange&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;value&lt;/strong&gt; - will hold the value of the latest change&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;  &amp;lt;cmf-core-business-controls-propertyEditor
      label=&amp;#34;Persistency Key&amp;#34;
      placeholder=&amp;#34;select persistency key...&amp;#34;
      nestedEditorType=&amp;#34;ComboBox&amp;#34;
      dataNameField=&amp;#34;name&amp;#34;
      [data]=&amp;#34;activeListKeys&amp;#34;
      [value]=&amp;#34;selectedKey&amp;#34;
      (valueChange)=&amp;#34;onControlValueChange($event)&amp;#34;&amp;gt;
  &amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI IoT Communication"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui-io-communication.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Persistency Key"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/get-persistency-key-value.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we have a component that is being populated by the automation layer.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Pushing Values to the Automation Layer
&lt;div id="pushing-values-to-the-automation-layer" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pushing-values-to-the-automation-layer" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;em&gt;onControlValueChange&lt;/em&gt; is performing some formatting and storing the value on an &lt;code&gt;editedValue&lt;/code&gt; variable.&lt;/p&gt;
&lt;p&gt;When in the first propertyEditor a value is selected it will populate the value of the second propertyEditor. The second propertyEditor is a simple Input. The button is responsible for calling a method &lt;em&gt;saveKeyValue&lt;/em&gt; and send the new value to the IoT layer.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;  &amp;lt;cmf-core-business-controls-propertyEditor
      label=&amp;#34;Persistency Key&amp;#34;
      placeholder=&amp;#34;select persistency key...&amp;#34;
      nestedEditorType=&amp;#34;ComboBox&amp;#34;
      dataNameField=&amp;#34;name&amp;#34;
      [data]=&amp;#34;activeListKeys&amp;#34;
      [value]=&amp;#34;selectedKey&amp;#34;
      (valueChange)=&amp;#34;onControlValueChange($event)&amp;#34;&amp;gt;
  &amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
&amp;lt;cmf-core-business-controls-propertyEditor
      label=&amp;#34;Value&amp;#34;
      nestedEditorType=&amp;#34;Input&amp;#34;
      [value]=&amp;#34;editedValue&amp;#34;
      (valueChange)=&amp;#34;onEditorValueChange($event)&amp;#34;&amp;gt;
  &amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
  &amp;lt;button class=&amp;#34;cmf-btn&amp;#34;
          (click)=&amp;#34;saveKeyValue()&amp;#34;
          data-tag=&amp;#39;jsonpersistency.saveButton&amp;#39;&amp;gt;Save&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  /**
   * Persists the current edited value for the selected key.
   */
  public async saveKeyValue(): Promise&amp;lt;void&amp;gt; {
    if (!this.selectedKey) {
      return;
    }
// send a message bus message to persist the edited value for the selected key
    await this._feedback.progressIndicator(this._elementRef, 
      this._messageBus.sendRequest(
        this.automationControllerRequestTopic,
        {
          &amp;#34;type&amp;#34;: &amp;#34;SetPersistencyKeyValue&amp;#34;,
          &amp;#34;data&amp;#34;: JSON.stringify({ key: this.selectedKey.name, value: this.editedValue })
        },
        this._timeout
    ));
  }&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Save Communication"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui-iot-save-communication.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Persistency Key"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/set-persistency-key-value.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s often the case we store not just primitive values, but also complex objects. The propertyEditor is flexible enough to be able to address even those more complex scenarios with the use of the &lt;strong&gt;nestedEditorType&lt;/strong&gt; CodeEditor.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-html"&gt;  &amp;lt;cmf-core-business-controls-propertyEditor
      label=&amp;#34;Persistency Key&amp;#34;
      placeholder=&amp;#34;select persistency key...&amp;#34;
      nestedEditorType=&amp;#34;ComboBox&amp;#34;
      dataNameField=&amp;#34;name&amp;#34;
      [data]=&amp;#34;activeListKeys&amp;#34;
      [value]=&amp;#34;selectedKey&amp;#34;
      (valueChange)=&amp;#34;onControlValueChange($event)&amp;#34;&amp;gt;
  &amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
&amp;lt;cmf-core-business-controls-propertyEditor
      class=&amp;#34;json-editor&amp;#34;
      [required]=&amp;#34;false&amp;#34;
      [(value)]=&amp;#34;editedValue&amp;#34;
      (valueChange)=&amp;#34;onEditorValueChange($event)&amp;#34;
      nestedEditorType=&amp;#34;CodeEditor&amp;#34;
      #codeEditor&amp;gt;
  &amp;lt;/cmf-core-business-controls-propertyEditor&amp;gt;
  &amp;lt;button class=&amp;#34;cmf-btn&amp;#34;
          (click)=&amp;#34;saveKeyValue()&amp;#34;
          data-tag=&amp;#39;jsonpersistency.saveButton&amp;#39;&amp;gt;Save&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Persistency Key Objects"
src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui-iot-save-communication-object.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Intercepting the Refresh Button
&lt;div id="intercepting-the-refresh-button" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#intercepting-the-refresh-button" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;An interesting part of what we are doing is how we are using the refresh button to also reset our components.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;**Info:**You can also add additional action buttons &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/presentation/actionbar/#adding-a-button-to-an-existing-entity-page"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;We are able to reuse the refresh button by intercepting the &lt;strong&gt;epEntityLoaded&lt;/strong&gt; event.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;  /**
   * Subscribes to entity load events and fetches persistency keys when context is available.
   */
  public ngOnInit(): void {
this._entityPage.epEntityLoaded.subscribe(() =&amp;gt; {
      this.resetView();
    });
if (this._pageBag?.context?.id) {
      this.retrieveListOfPersistencyKeys();
    }
  }
/**
   * Releases the entity loaded subscription to prevent leaks.
   */
  public ngOnDestroy(): void {
    this._entityPage.epEntityLoaded.unsubscribe();
  }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Prettifying It
&lt;div id="prettifying-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#prettifying-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;After getting all these building blocks the rest becomes easy. We created a component to render the json in a &lt;strong&gt;Tree&lt;/strong&gt; view and in a &lt;strong&gt;Graph&lt;/strong&gt; view using &lt;a
href="https://github.com/swimlane/ngx-graph"
target="_blank"
&gt;ngx-graph&lt;/a&gt;. Then we added some html and css to make it all have a similar look and feel as the CM MES UI and that&amp;rsquo;s how we get our final UI.&lt;/p&gt;
&lt;p&gt;If you want to dig in the details you can check the &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/PersistencyViewer/Cmf.Custom.MESProject.HTML/projects/customization-json-persistency/src/lib/json-tree-viewer.component.ts"
target="_blank"
&gt;jsonTree&lt;/a&gt; and the &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/PersistencyViewer/Cmf.Custom.MESProject.HTML/projects/customization-json-persistency/src/lib/json-graph-view.component.ts"
target="_blank"
&gt;jsonGraph&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For them to be usable in our component we add them as inputs of our custom component.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Component({
  selector: &amp;#39;lib-customization-json-persistency&amp;#39;,
  standalone: true,
  imports: [CommonModule, PropertyEditorModule, CodeEditorModule, JsonTreeViewerComponent, JsonGraphViewComponent],
  templateUrl: `./customization-json-persistency.component.html`,
  styleUrls: [&amp;#39;./customization-json-persistency.component.less&amp;#39;]
})&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260325-ui-persistencyviewer/ui_demo.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this blog post we showed how you can spin up a local environment and started using the CM UI framework to build complex scenarios leveraging out of the box components.&lt;/p&gt;
&lt;p&gt;From my view, It&amp;rsquo;s amazing how the system allows for such a deep level of extension with entrypoints for custom buttons, pages and wizards. This is all done by a backend developer, so If I can do it, I am sure anyone can.&lt;/p&gt;
&lt;p&gt;I am excited to see what people think of next!&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260325-ui-persistencyviewer/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260325-ui-persistencyviewer/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Edge AI - Connect IoT a Practical Case Study</title>
<link>https://j-roque.com/posts/20260318-edge-ai-connectiot/</link>
<pubDate>Wed, 18 Mar 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260318-edge-ai-connectiot/</guid>
<description>Leverage AI capabilities on Edge</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260318-edge-ai-connectiot/featured.png" alt="Edge AI - Connect IoT a Practical Case Study" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In a previous blog post we explored what were &lt;a
href="https://j-roque.com/posts/20260318-edge-ai/"
target="_blank"
&gt;Small Language Models on the Edge&lt;/a&gt;. In this blog post I want to go step by step on how we made it possible.&lt;/p&gt;
&lt;p&gt;CM MES edge middleware is called &lt;code&gt;Connect IoT&lt;/code&gt;. It&amp;rsquo;s a nodejs application responsible for interfacing with third-party systems.&lt;/p&gt;
&lt;p&gt;Connect IoT can be deployed in the same infrastructure and stack that the MES is running, but it&amp;rsquo;s often the case it&amp;rsquo;s deployed closer to the shopfloor. This avoids common issues like networking security and latency.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Low-Code Tasks as the Integration Point
&lt;div id="low-code-tasks-as-the-integration-point" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#low-code-tasks-as-the-integration-point" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We showed an example of a integration in a low code workflow of a small language model.&lt;/p&gt;
&lt;p&gt;A low code task that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reads variables from the controller&amp;rsquo;s current state&lt;/li&gt;
&lt;li&gt;Serializes that state into a structured context payload&lt;/li&gt;
&lt;li&gt;Passes a user query and that context to a locally running SLM&lt;/li&gt;
&lt;li&gt;Returns the model&amp;rsquo;s response as a task output or controller variable&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic Workflow"
src="https://image.j-roque.com/posts/20260318-edge-ai/agenticworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260318-edge-ai/edge-ai.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;Let&amp;rsquo;s take a look on how we built this&amp;hellip;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Building the AI Agent Task
&lt;div id="building-the-ai-agent-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-the-ai-agent-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;The Role of node-llama-cpp
&lt;div id="the-role-of-node-llama-cpp" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-role-of-node-llama-cpp" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Connect IoT&amp;rsquo;s extensibility layer runs on &lt;strong&gt;Node.js&lt;/strong&gt;. Custom tasks are written in TypeScript and executed within the Node.js runtime of the Automation Manager. This means that any Node.js-compatible library is available for use in custom tasks.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://node-llama-cpp.withcat.ai/"
target="_blank"
&gt;node-llama-cpp&lt;/a&gt; is a Node.js binding for &lt;code&gt;llama.cpp&lt;/code&gt;, the widely-used inference engine for running quantized GGUF models locally. It supports CPU inference across platforms, has a clean TypeScript API, and gives us the ability to load and query a local SLM directly from within a Connect IoT custom task — no external process, no sidecar service, no cloud API key.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The model file lives on the edge server. The inference runs in-process.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The result comes back as a string that the task can publish to a controller variable or return as a response to whoever initiated the query.&lt;/p&gt;
&lt;p&gt;This is what makes the implementation practical: we are not building a separate AI service and integrating it over HTTP. We are loading a model inside the same runtime where Connect IoT already executes business logic.&lt;/p&gt;
&lt;h3 class="relative group"&gt;What This Enables
&lt;div id="what-this-enables" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-this-enables" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The immediate use case is &lt;strong&gt;natural language querying of the edge runtime state&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;An operator, an engineer, or a support technician can ask a question in plain language and receive an answer grounded in the actual current state of the integration, without needing to know which OPC-UA node to look at, which controller variable holds the relevant value, or how to read a low-code task output.&lt;/p&gt;
&lt;p&gt;Beyond direct Q&amp;amp;A, this pattern opens up a few other directions worth exploring:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Anomaly explanation.&lt;/strong&gt; When a condition flag changes or a threshold is crossed, the model can be invoked automatically to produce a plain-language explanation of what changed and what the surrounding state looked like — a first-pass interpretation before an operator investigates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configuration assistance.&lt;/strong&gt; A model with knowledge of the Connect IoT task library and variable conventions could help a developer draft low-code logic by describing the intent in natural language and having the model suggest a task sequence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agentic workflows.&lt;/strong&gt; We can have autonomous agents that are able to make decision and provide meaningful reporting on the state of the integration.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Practical Considerations
&lt;div id="practical-considerations" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#practical-considerations" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Running SLMs locally is feasible, but it requires some honest accounting of the constraints.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hardware.&lt;/strong&gt; A 3B–4B parameter model quantized to 4-bit precision needs at least 2–3 GB of RAM and will run inference in 1–5 seconds on a modern CPU. That is acceptable for an on-demand query interface. It is not acceptable for high-frequency polling — do not invoke a local SLM on every OPC-UA data change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model selection.&lt;/strong&gt; Smaller models are less reliable on complex reasoning tasks. For a scoped use case — interpreting a JSON snapshot of machine state in response to a narrow question — a well-prompted 3B model can perform very well. Testing with representative queries before committing to a model is essential.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt engineering.&lt;/strong&gt; The quality of the output is directly tied to the quality of the prompt. A system prompt that clearly explains what the context payload contains, what the model&amp;rsquo;s role is, and what format the response should take will outperform a vague prompt with a larger model.&lt;/p&gt;
&lt;p&gt;For this scenario I am using Phi-3.1-mini-4k, which is a very simple model. We could use more advanced models like the Qwen models, which would offer better results at the cost of being more resource heavy.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Building a Task
&lt;div id="building-a-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We used our CM CLI to create a new &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_task/"
target="_blank"
&gt;Connect IoT Task &lt;/a&gt;. Full implementation &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/Agentic/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/controller-engine-agentic-tasks/src/tasks/aiAgent/aiAgent.task.ts"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;We created a dependency injection container to deal with all the &lt;strong&gt;node-llama&lt;/strong&gt; instantiation. The advantage of having a dependency that is defined globally for our whole controller is that it is detached from the task lifecycle. We can call it from different tasks and instantiate it outside of the lifecycle of a task.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    import { resolve } from &amp;#34;path&amp;#34;;
    import { createHash } from &amp;#34;node:crypto&amp;#34;;
    import type { Dependencies } from &amp;#34;@criticalmanufacturing/connect-iot-controller-engine&amp;#34;;
    import { TYPES, DI } from &amp;#34;@criticalmanufacturing/connect-iot-controller-engine&amp;#34;;
    import type { LLamaChatPromptOptions, LlamaContext, LlamaModel } from &amp;#34;node-llama-cpp&amp;#34;;
    import { getLlama, LlamaChatSession, resolveModelFile } from &amp;#34;node-llama-cpp&amp;#34;;
@DI.Injectable()
    export class SLMManager {
@DI.Inject(TYPES.Dependencies.Logger)
        protected _logger: Dependencies.Logger;
private _models: Map&amp;lt;string, { model: LlamaModel, context: LlamaContext }&amp;gt; = new Map();
        private _sessions: Map&amp;lt;string, LlamaChatSession&amp;gt; = new Map();
public async loadModel(modelLocation: string, modelName: string, loadModelSettings: any = {}, contextSettings: any = {}) {
            (...)
        }
public getSession(modelId: string, systemPrompt: string = &amp;#34;&amp;#34;, sessionId: string = &amp;#34;&amp;#34;): { id: string, session: any } {
(...)
        }
public disposeSession(sessionId: string): void {
            (...)
        }
public async promptSession(sessionId: string, prompt: string, schema: any = undefined, functions: any = undefined, options: LLamaChatPromptOptions&amp;lt;undefined&amp;gt; = {}): Promise&amp;lt;string&amp;gt; {
            (...)
        }
    (...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;SLManager&lt;/strong&gt; will provide a set of methods the tasks can then call.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public async loadModel(modelLocation: string, modelName: string, loadModelSettings: any = {}, contextSettings: any = {}) {
        const id = this.modelSessionId(modelLocation, modelName, loadModelSettings, contextSettings);
if (!this._models.has(id)) {
            const llama = await getLlama();
            const modelPath = await resolveModelFile(modelName, resolve(modelLocation));
            const model = await llama.loadModel({ modelPath, ...loadModelSettings });
            const context = await model.createContext(Object.assign({
                contextSize: { max: 4096 },
                sequences: 2 // set max sequences here
            }, contextSettings));
this._models.set(id, { model, context });
this._logger.debug(`Model &amp;#39;${modelName}&amp;#39; loaded and cached with id ${id}`);
        } else {
            this._logger.debug(`Model &amp;#39;${modelName}&amp;#39; is already loaded, will not do anything ${id}`);
        }
return id;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;loadModel&lt;/strong&gt; will import an SLM model from a file and pass on a set of start settings. It will also create a registry to keep track of all models that were already loaded as this is a very costly operation.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public getSession(modelId: string, systemPrompt: string = &amp;#34;&amp;#34;, sessionId: string = &amp;#34;&amp;#34;): { id: string, session: any } {
if (sessionId === &amp;#34;&amp;#34;) {
            sessionId = createHash(&amp;#34;sha1&amp;#34;).update(modelId &amp;#43; systemPrompt).digest(&amp;#34;hex&amp;#34;);
        }
if (this._sessions.has(sessionId)) {
            this._logger.debug(`Session with id ${sessionId} retrieved from cache`);
            return { id: sessionId, session: this._sessions.get(sessionId) };
        } else {
            if (!this._models.has(modelId)) {
                throw new Error(`Model with id ${modelId} not found. Make sure to load the model before trying to get a session for it.`);
            }
const { context } = this._models.get(modelId);
if (context.sequencesLeft === 0) {
                this._sessions.values().next().value.dispose({ disposeSequence: true });
                this._sessions.delete(this._sessions.keys().next().value);
                this._logger.debug(`No sequences left in context, disposed the oldest session to free up sequences`);
            }
const session = new LlamaChatSession({
                contextSequence: context.getSequence(),
                systemPrompt
            });
this._sessions.set(sessionId, session);
            return { id: sessionId, session };
        }
    }
public disposeSession(sessionId: string): void {
        if (this._sessions.has(sessionId)) {
            this._sessions.get(sessionId).dispose({ disposeSequence: true });
            this._sessions.delete(sessionId);
            this._logger.debug(`Session with id ${sessionId} disposed and removed from cache`);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;gestSession&lt;/strong&gt; and &lt;strong&gt;disposeSession&lt;/strong&gt; work in tandem. They allow the task to orchestrate sessions. A task can keep a session or multiple session opened and keep track of that context.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    public async promptSession(sessionId: string, prompt: string, schema: any = undefined, functions: any = undefined, options: LLamaChatPromptOptions&amp;lt;undefined&amp;gt; = {}): Promise&amp;lt;string&amp;gt; {
if (!this._sessions.has(sessionId)) {
            throw new Error(`Session with id ${sessionId} not found. Make sure to get the session before trying to prompt it.`);
        }
let grammar = undefined;
        if (schema) {
            const llama = await getLlama();
            grammar = await llama.createGrammarForJsonSchema(schema);
        }
const session = this._sessions.get(sessionId);
        if (functions) {
            return await session.prompt(prompt, Object.assign({
                onResponseChunk: (chunk) =&amp;gt; this._logger.debug(`Received chunk from session ${sessionId}: ${JSON.stringify(chunk)}`),
                functions
            }, options));
        } else if (grammar) {
            return await session.prompt(prompt, Object.assign({
                onResponseChunk: (chunk) =&amp;gt; this._logger.debug(`Received chunk from session ${sessionId}: ${JSON.stringify(chunk)}`),
                grammar
            }, options));
        } else {
            return await session.prompt(prompt, options);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;promptSession&lt;/strong&gt; is where the task is able to pass on prompt requests to the SLM model.&lt;/p&gt;
&lt;p&gt;One of the interesting features of node-llama-cpp is that it allows for &lt;a
href="https://node-llama-cpp.withcat.ai/guide/function-calling"
target="_blank"
&gt;functions&lt;/a&gt; and &lt;a
href="https://node-llama-cpp.withcat.ai/guide/grammar"
target="_blank"
&gt;grammar&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Functions allows us to provide in a controlled fashion internal APIs to the model.&lt;/p&gt;
&lt;p&gt;The grammar allows us to enforce a specific model reply format. This is key, to be able to create further tasks and transformations down the line of your low code workflow.&lt;/p&gt;
&lt;p&gt;After we create our DI node llama container we can add it to our task.&lt;/p&gt;
&lt;p&gt;In order to depend on this global containers we need to add those providers to our task.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    @Task.TaskModule({
        task: AiAgentTask,
        providers: [
            {
                class: SLMManager,
                isSingleton: true,
                symbol: &amp;#34;GlobalSLMManagerHandler&amp;#34;,
                scope: Task.ProviderScope.Controller,
            }
        ]
    })
    export class AiAgentModule { }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we import the &lt;strong&gt;AiAgentModule&lt;/strong&gt; instead of directly the task.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    export { AiAgentModule } from &amp;#34;./tasks/aiAgent/aiAgent.task.js&amp;#34;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our task is now working as a wrapper on the &lt;code&gt;SLMManager&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    /**
     * This is the representation of the SLM manager
     */
    @DI.Inject(&amp;#34;GlobalSLMManagerHandler&amp;#34;)
    private _slmManager: SLMManager;
/**
     * When one or more input values is changed this will be triggered,
     * @param changes Task changes
     */
    public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
        if (changes[&amp;#34;activate&amp;#34;]) {
            // It is advised to reset the activate to allow being reactivated without the value being different
            this.activate = undefined;
            let sessionId = this.sessionId;
            try {
// Load Model
                const modelId = await this._slmManager.loadModel(this.modelLocation, this.model, this.loadModelSettings, this.contextSettings);
                // Get Session
                const sessionValues = this._slmManager.getSession(modelId, this.systemPrompt, this.sessionId);
                sessionId = sessionValues.id;
let functions: any = undefined;
                if (this.enablePersistencyAccess) {
                    (...)
                }
const result = await this._slmManager.promptSession(sessionId, this.prompt, this.schema, functions, this.promptSettings);
                this.response.emit(this.stripLlamaFunctionMarkup(result.trim()));
                this.sessionIdOut.emit(sessionId);
if (this.forceCleanSession === true) {
                    this._slmManager.disposeSession(sessionId);
                }
this.success.emit(true);
            } catch (error) {
                this._slmManager.disposeSession(sessionId);
                this.logAndEmitError(`Error while executing the AiAgent task: ${error instanceof Error ? error.message : String(error)}`);
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Our code is actually quite simple, it loads the model, retrieves/creates the session and passes the prompt into the model.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With only this code we are already able to have a task that can grab the inputs and provide a response.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;An interesting feature that we saw in the video is the ability to surface internal APIs into the AI.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    functions = {
        listKeysFromPersistency: defineChatSessionFunction({
            (...)
        }),
        retrieveFromStorePersistency: defineChatSessionFunction({
            (...)
        }),
        storeInPersistency: defineChatSessionFunction({
            (...)
        })
    };&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Connect IoT Persistency out of the box is a key value mapping storage. We are able to surface a self descriptive API to the SLM.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    listKeysFromPersistency: defineChatSessionFunction({
        description: &amp;#34;List all keys from the persistency layer.&amp;#34;,
        handler: () =&amp;gt; {
            return this._dataStore.listKeys(System.DataStoreLocation.Temporary);
        }
    }),&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the simples API. It has no inputs, but allows the SLM to provide discoverability into our persistency.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    retrieveFromStorePersistency: defineChatSessionFunction({
        description: &amp;#34;Retrieve information from the persistency layer.&amp;#34;,
        params: {
            type: &amp;#34;object&amp;#34;,
            properties: {
                identifier: {
                    description: &amp;#34;The key to identify the data to be retrieved. Case insensitive.&amp;#34;,
                    type: &amp;#34;string&amp;#34;
                },
                defaultValue: {
                    description: &amp;#34;The default value to return if the key is not found.&amp;#34;,
                    type: &amp;#34;string&amp;#34;
                },
                maxSize: {
                    description: &amp;#34;The number of items to retrieve. e.g. 1 or 2&amp;#34;,
                    type: &amp;#34;number&amp;#34;
                },
                offset: {
                    description: &amp;#34;Skip this many items from the end to start paginating backwards. 0 = most recent, 1 = second most recent, etc.&amp;#34;,
                    type: &amp;#34;number&amp;#34;
                }
            }
        },
        handler: async (params: any) =&amp;gt; {
            let identifier = params.identifier;
            let defaultValue = params.defaultValue;
            let maxSize = params.maxSize || 2;
            let offset = params.offset || 0;
let retrievedValue = await this._dataStore.retrieve(identifier, defaultValue);
if (retrievedValue === defaultValue) {
                identifier = identifier.toLowerCase();
                defaultValue = defaultValue.toLowerCase();
                retrievedValue = await this._dataStore.retrieve(identifier, defaultValue);
            }
retrievedValue = retrievedValue.storage ? retrievedValue.storage.map((item: any) =&amp;gt; item.value) : retrievedValue;
if (Array.isArray(retrievedValue)) {
                // Calculate start position: end - offset - maxSize
                const startIndex = Math.max(0, retrievedValue.length - offset - maxSize);
                const endIndex = retrievedValue.length - offset || undefined;
                retrievedValue = retrievedValue.slice(startIndex, endIndex);
            }
const formatForLLM = (value: any, totalLength: number): string =&amp;gt; {
                if (Array.isArray(value)) {
                    const showing = value.length;
                    const remaining = totalLength - showing;
                    const startIndex = Math.max(0, totalLength - showing - offset);
                    const lastValue = value.length &amp;gt; 0 ? JSON.stringify(value[value.length - 1]) : &amp;#34;none&amp;#34;;
let result = `Array: Total ${totalLength} items | Showing ${showing} items (indices ${startIndex}-${startIndex &amp;#43; showing - 1}) | Remaining ${remaining} items\n`;
                    result &amp;#43;= `Last shown value: ${lastValue}\n`;
                    result &amp;#43;= `Data: `;
// Try to include full values, truncate individual items if needed
                    const processedItems = value.map((item: any, itemIndex: number) =&amp;gt; {
                        let itemStr = typeof item === &amp;#34;string&amp;#34; ? item : JSON.stringify(item);
                        const arrayIndex = startIndex &amp;#43; itemIndex;
if (itemStr.length &amp;gt; 300) {
                            return `[Index ${arrayIndex}] ${itemStr.substring(0, 300)}...[truncated at position ${arrayIndex}]`;
                        }
                        return itemStr;
                    });
result &amp;#43;= JSON.stringify(processedItems);
                    return result;
                }
let str = typeof value === &amp;#34;string&amp;#34; ? value : JSON.stringify(value);
if (str.length &amp;gt; 500) {
                    return str.substring(0, 500) &amp;#43; &amp;#34;...[truncated - data too large]&amp;#34;;
                }
return str;
            };
// Get total length from storage before slicing
            let originalLength = Array.isArray(retrievedValue) ? (retrievedValue.length &amp;#43; offset &amp;#43; maxSize) : 1;
            return formatForLLM(retrievedValue, originalLength);
        }
    }),&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The retrieve API is a bit more complex. We are also surfacing a persistency API, in this case the retrieve API, but we are also adding concepts like number of values and the possibility for the API to request more values.&lt;/p&gt;
&lt;hr&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;Providing APIs must be a very intentional action as you can be surfacing protected information or you may allow the AI to perform actions that could be catastrophical.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;Now we support grammar, functions, system prompts and surface all the settings that node-llama-cpp provides out of the box.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic Task Settings"
src="https://image.j-roque.com/posts/20260318-edge-ai/tasksettings.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is where low code really shines. You can use the SLM, but provide it with guarded use cases, where the actions are controlled by the low code workflow it is able to provide. You can also provide grammars, this allows to enforce the model to provide replies in a specific format, this allows for your workflows to have predictable outcomes that they can respond to.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Building a Chat Interface
&lt;div id="building-a-chat-interface" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-chat-interface" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In &lt;strong&gt;version 11.1&lt;/strong&gt; of the CM MES a new feature was released called the &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/administration/automation-business-scenario/building-scenarios/?h=automation&amp;#43;business&amp;#43;scenarios"
target="_blank"
&gt;Automation Business Scenarios&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This allows for the user to create deterministic workflows for the chat panel. The interactions we saw when using the chatbot were all controlled using this feature. Where we have a set of steps and possible actions.&lt;/p&gt;
&lt;p&gt;We used a publicly available &lt;a
href="https://marketplace.visualstudio.com/items?itemName=CriticalManufacturing.automation-business-scenarios-vscode"
target="_blank"
&gt;vscode extension&lt;/a&gt; to have a json formatter for the scenario creation and a command to be able to generate mermaid charts.&lt;/p&gt;
&lt;hr&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
graph TD
classDef startClass fill: #007ac9, color:#000000;
classDef finallyClass fill: #50b450, color:#000000;
classDef endClass fill: #3b8b3b, color:#000000;
ControllersWithAgenticInstances["Script:
ControllersWithAgenticInstances
(controllersWithAgenticInstances)"] --&gt; InstancesToTalkWith
InstancesToTalkWith["Question:
InstancesToTalkWith
(instanceToTalkWith)"] --&gt; AskQuestionsToInstanceWithMessage
AskQuestionsToInstanceWithMessage["Question:
AskQuestionsToInstanceWithMessage
(question)"] --&gt; EndConversationCondition
EndConversationCondition["Condition:
EndConversationCondition"] --&gt;
|"question != 'end'"|SendMessageToInstance
SendMessageToInstance["Script:
SendMessageToInstance
(replyFromInstance)"] --&gt; ReplyFromInstance
ReplyFromInstance["Message:
ReplyFromInstance"] --&gt; AskQuestionsToInstance
AskQuestionsToInstance["Question:
AskQuestionsToInstance
(question)"] --&gt; EndConversationCondition
StartStep["Start Step"]:::startClass --&gt; ControllersWithAgenticInstances
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Business Scenario"
src="https://image.j-roque.com/posts/20260318-edge-ai/automationbusinessscenario.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We have a step to filter all the &lt;strong&gt;Instances&lt;/strong&gt; that have an AI Agentic Task.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;    {
        &amp;#34;name&amp;#34;: &amp;#34;ControllersWithAgenticInstances&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;controllersWithAgenticInstances&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;dataType&amp;#34;: &amp;#34;Object&amp;#34;,
            &amp;#34;script&amp;#34;: &amp;#34;${script(./scripts/agentic_controllers.ts)}&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;InstancesToTalkWith&amp;#34;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    (async () =&amp;gt; {
        const filterCollection: Cmf.Foundation.BusinessObjects.QueryObject.FilterCollection = new Cmf.Foundation.BusinessObjects.QueryObject.FilterCollection();
// Filter filter_0
        const filter_0: Cmf.Foundation.BusinessObjects.QueryObject.Filter = new Cmf.Foundation.BusinessObjects.QueryObject.Filter();
        filter_0.Name = &amp;#34;Workflow&amp;#34;;
        filter_0.ObjectName = &amp;#34;AutomationWorkflow&amp;#34;;
        filter_0.ObjectAlias = &amp;#34;AutomationWorkflow_1&amp;#34;;
        filter_0.Operator = Cmf.Foundation.Common.FieldOperator.Contains;
        filter_0.Value = &amp;#34;\&amp;#34;aiAgent\&amp;#34;&amp;#34;;
        filter_0.LogicalOperator = Cmf.Foundation.Common.LogicalOperator.Nothing;
        filter_0.FilterType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.FilterType.Normal;
filterCollection.push(filter_0);
const fieldCollection: Cmf.Foundation.BusinessObjects.QueryObject.FieldCollection = new Cmf.Foundation.BusinessObjects.QueryObject.FieldCollection();
// Field field_0
        const field_0: Cmf.Foundation.BusinessObjects.QueryObject.Field = new Cmf.Foundation.BusinessObjects.QueryObject.Field();
        field_0.Alias = &amp;#34;Id&amp;#34;;
        field_0.ObjectName = &amp;#34;AutomationWorkflow&amp;#34;;
        field_0.ObjectAlias = &amp;#34;AutomationWorkflow_1&amp;#34;;
        field_0.IsUserAttribute = false;
        field_0.Name = &amp;#34;Id&amp;#34;;
        field_0.Position = 0;
        field_0.Sort = Cmf.Foundation.Common.FieldSort.NoSort;
// Field field_1
        const field_1: Cmf.Foundation.BusinessObjects.QueryObject.Field = new Cmf.Foundation.BusinessObjects.QueryObject.Field();
        field_1.Alias = &amp;#34;Name&amp;#34;;
        field_1.ObjectName = &amp;#34;AutomationWorkflow&amp;#34;;
        field_1.ObjectAlias = &amp;#34;AutomationWorkflow_1&amp;#34;;
        field_1.IsUserAttribute = false;
        field_1.Name = &amp;#34;Name&amp;#34;;
        field_1.Position = 1;
        field_1.Sort = Cmf.Foundation.Common.FieldSort.NoSort;
// Field field_2
        const field_2: Cmf.Foundation.BusinessObjects.QueryObject.Field = new Cmf.Foundation.BusinessObjects.QueryObject.Field();
        field_2.Alias = &amp;#34;AutomationControllerId&amp;#34;;
        field_2.ObjectName = &amp;#34;AutomationController&amp;#34;;
        field_2.ObjectAlias = &amp;#34;AutomationWorkflow_AutomationController_2&amp;#34;;
        field_2.IsUserAttribute = false;
        field_2.Name = &amp;#34;Id&amp;#34;;
        field_2.Position = 2;
        field_2.Sort = Cmf.Foundation.Common.FieldSort.NoSort;
fieldCollection.push(field_0);
        fieldCollection.push(field_1);
        fieldCollection.push(field_2);
const relationCollection: Cmf.Foundation.BusinessObjects.QueryObject.RelationCollection = new Cmf.Foundation.BusinessObjects.QueryObject.RelationCollection();
// Relation relation_0
        const relation_0: Cmf.Foundation.BusinessObjects.QueryObject.Relation = new Cmf.Foundation.BusinessObjects.QueryObject.Relation();
        relation_0.Alias = &amp;#34;&amp;#34;;
        relation_0.IsRelation = false;
        relation_0.Name = &amp;#34;&amp;#34;;
        relation_0.SourceEntity = &amp;#34;AutomationWorkflow&amp;#34;;
        relation_0.SourceEntityAlias = &amp;#34;AutomationWorkflow_1&amp;#34;,
            relation_0.SourceJoinType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.JoinType.InnerJoin;
        relation_0.SourceProperty = &amp;#34;AutomationControllerId&amp;#34;;
        relation_0.TargetEntity = &amp;#34;AutomationController&amp;#34;;
        relation_0.TargetEntityAlias = &amp;#34;AutomationWorkflow_AutomationController_2&amp;#34;;
        relation_0.TargetJoinType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.JoinType.InnerJoin;
        relation_0.TargetProperty = &amp;#34;Id&amp;#34;;
relationCollection.push(relation_0);
const query: Cmf.Foundation.BusinessObjects.QueryObject.QueryObject = new Cmf.Foundation.BusinessObjects.QueryObject.QueryObject();
        query.Description = &amp;#34;&amp;#34;;
        query.EntityTypeName = &amp;#34;AutomationWorkflow&amp;#34;;
        query.Name = &amp;#34;InstancesToTalkWith&amp;#34;;
        query.Query = new Cmf.Foundation.BusinessObjects.QueryObject.Query();
        query.Query.Distinct = false;
        query.Query.Filters = filterCollection;
        query.Query.Fields = fieldCollection;
        query.Query.Relations = relationCollection;
const executeQueryObject =
            new Cmf.Foundation.BusinessOrchestration.QueryManagement.InputObjects.ExecuteQueryInput();
        executeQueryObject.QueryObject = query;
const results = (await this.System.call(executeQueryObject))?.NgpDataSet?.T_Result ?? [];
const distinctResults = new Map&amp;lt;string, any&amp;gt;();
        for (const result of results) {
            distinctResults.set(result.AutomationControllerId, result);
        }
return [...distinctResults.values()];
    })();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This step will return all workflow that have an &lt;code&gt;AIAgent&lt;/code&gt; task and then return all the distinct Automation Controllers. The list of Automation Controllers is persisted in the &lt;strong&gt;resultKey&lt;/strong&gt; &lt;code&gt;controllersWithAgenticInstances&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The query was generated in the MES and then exported to typescript.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;    {
    &amp;#34;name&amp;#34;: &amp;#34;InstancesToTalkWith&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
    &amp;#34;resultKey&amp;#34;: &amp;#34;instanceToTalkWith&amp;#34;,
    &amp;#34;settings&amp;#34;: {
        &amp;#34;message&amp;#34;: &amp;#34;Choose an Automation Instance to chat with:&amp;#34;,
        &amp;#34;dataType&amp;#34;: &amp;#34;FindEntity&amp;#34;,
        &amp;#34;settings&amp;#34;: {
        &amp;#34;query&amp;#34;: &amp;#34;${script(./scripts/instances_to_talk_with.ts)}&amp;#34;
        }
    },
    &amp;#34;next&amp;#34;: &amp;#34;AskQuestionsToInstanceWithMessage&amp;#34;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    const filterCollection: Cmf.Foundation.BusinessObjects.QueryObject.FilterCollection = new Cmf.Foundation.BusinessObjects.QueryObject.FilterCollection();
for (const controller of this.answers.controllersWithAgenticInstances) {
        let filter_0: Cmf.Foundation.BusinessObjects.QueryObject.Filter = new Cmf.Foundation.BusinessObjects.QueryObject.Filter();
        filter_0.Name = &amp;#34;AutomationControllerId&amp;#34;;
        filter_0.ObjectName = &amp;#34;AutomationControllerInstance&amp;#34;;
        filter_0.ObjectAlias = &amp;#34;AutomationControllerInstance_1&amp;#34;;
        filter_0.Operator = Cmf.Foundation.Common.FieldOperator.IsEqualTo;
        filter_0.Value = controller.AutomationControllerId;
        filter_0.LogicalOperator = Cmf.Foundation.Common.LogicalOperator.OR;
        filter_0.FilterType = Cmf.Foundation.BusinessObjects.QueryObject.Enums.FilterType.Normal;
filterCollection.push(filter_0);
    }
const fieldCollection: Cmf.Foundation.BusinessObjects.QueryObject.FieldCollection = new Cmf.Foundation.BusinessObjects.QueryObject.FieldCollection();
// Field field_0
    const field_0: Cmf.Foundation.BusinessObjects.QueryObject.Field = new Cmf.Foundation.BusinessObjects.QueryObject.Field();
    field_0.Alias = &amp;#34;Id&amp;#34;;
    field_0.ObjectName = &amp;#34;AutomationControllerInstance&amp;#34;;
    field_0.ObjectAlias = &amp;#34;AutomationControllerInstance_1&amp;#34;;
    field_0.IsUserAttribute = false;
    field_0.Name = &amp;#34;Id&amp;#34;;
    field_0.Position = 0;
    field_0.Sort = Cmf.Foundation.Common.FieldSort.NoSort;
// Field field_1
    const field_1: Cmf.Foundation.BusinessObjects.QueryObject.Field = new Cmf.Foundation.BusinessObjects.QueryObject.Field();
    field_1.Alias = &amp;#34;Name&amp;#34;;
    field_1.ObjectName = &amp;#34;AutomationControllerInstance&amp;#34;;
    field_1.ObjectAlias = &amp;#34;AutomationControllerInstance_1&amp;#34;;
    field_1.IsUserAttribute = false;
    field_1.Name = &amp;#34;Name&amp;#34;;
    field_1.Position = 1;
    field_1.Sort = Cmf.Foundation.Common.FieldSort.NoSort;
fieldCollection.push(field_0);
    fieldCollection.push(field_1);
const query: Cmf.Foundation.BusinessObjects.QueryObject.QueryObject = new Cmf.Foundation.BusinessObjects.QueryObject.QueryObject();
    query.Description = &amp;#34;&amp;#34;;
    query.EntityTypeName = &amp;#34;AutomationControllerInstance&amp;#34;;
    query.Name = &amp;#34;InstancesToTalkWith&amp;#34;;
    query.Query = new Cmf.Foundation.BusinessObjects.QueryObject.Query();
    query.Query.Distinct = false;
    query.Query.Filters = filterCollection;
    query.Query.Fields = fieldCollection;
query;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we can retrieve all the instances filtered by which controllers have an &lt;code&gt;AIAgent&lt;/code&gt; task. The &lt;code&gt;FindEntity&lt;/code&gt; dataType will provide the possible values in a searchable text box.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;    {
        &amp;#34;name&amp;#34;: &amp;#34;AskQuestionsToInstanceWithMessage&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;question&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
            &amp;#34;message&amp;#34;: &amp;#34;Ask a question to the chosen instance (to stop conversation send &amp;#39;end&amp;#39;):&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;EndConversationCondition&amp;#34;
    },
    {
        &amp;#34;name&amp;#34;: &amp;#34;EndConversationCondition&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Condition&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;condition&amp;#34;: {
            &amp;#34;question != &amp;#39;end&amp;#39;&amp;#34;: &amp;#34;SendMessageToInstance&amp;#34;
            }
        },
        &amp;#34;next&amp;#34;: &amp;#34;&amp;#34;
    },
    {
        &amp;#34;name&amp;#34;: &amp;#34;SendMessageToInstance&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Script&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;replyFromInstance&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
            &amp;#34;script&amp;#34;: &amp;#34;${script(./scripts/send_message_to_instance.ts)}&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;ReplyFromInstance&amp;#34;
    },
    {
        &amp;#34;name&amp;#34;: &amp;#34;ReplyFromInstance&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Message&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;messageKey&amp;#34;: &amp;#34;replyFromInstance&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;AskQuestionsToInstance&amp;#34;
    },
    {
        &amp;#34;name&amp;#34;: &amp;#34;AskQuestionsToInstance&amp;#34;,
        &amp;#34;type&amp;#34;: &amp;#34;Question&amp;#34;,
        &amp;#34;resultKey&amp;#34;: &amp;#34;question&amp;#34;,
        &amp;#34;settings&amp;#34;: {
            &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;
        },
        &amp;#34;next&amp;#34;: &amp;#34;EndConversationCondition&amp;#34;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The conversation loop is provided by this flow. We have first step that provides a message informing the user he can type a message to converse with the SLM of the selected instance or he can type &lt;code&gt;q&lt;/code&gt; to stop the conversation.&lt;/p&gt;
&lt;p&gt;When the user types and submits a prompt, we will query the SLM, wait for the reply and the show the reply to the user.&lt;/p&gt;
&lt;p&gt;In the scenario we can also specify in what scopes should the scenario be accessible, depending where the user is when he opens the chatbot he can enter different scenarios.&lt;/p&gt;
&lt;p&gt;For the full implementation you can take a look at &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/Agentic/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/connect-iot-business-scenarios-agentic/scenarios/business_scenario_agentic_chat.json"
target="_blank"
&gt;here&lt;/a&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Looking Ahead
&lt;div id="looking-ahead" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#looking-ahead" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this example we were able to leverage the CM MES low code platforms to quickly create an implementation of edge AI that provides value.&lt;/p&gt;
&lt;p&gt;The goal is to show that this is not a research experiment — it is a practical, deployable extension of the low-code model that Connect IoT already provides, achievable with a few hundred lines of TypeScript and a model file sitting on the edge server.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260318-edge-ai-connectiot/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260318-edge-ai-connectiot/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Small Language Models on the Edge</title>
<link>https://j-roque.com/posts/20260318-edge-ai/</link>
<pubDate>Wed, 18 Mar 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260318-edge-ai/</guid>
<description>Bringing natural language intelligence into Connect IoT without leaving the shopfloor</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260318-edge-ai/featured.png" alt="Small Language Models on the Edge" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The conversation around AI in manufacturing tends to gravitate toward the cloud: &lt;strong&gt;large models&lt;/strong&gt;, &lt;strong&gt;large datasets&lt;/strong&gt;, &lt;strong&gt;large infrastructure&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That narrative makes sense for strategic use cases: predictive maintenance trained on years of historian data, quality inspection powered by computer vision pipelines, demand forecasting fed by ERP exports. Those workloads belong in the cloud.&lt;/p&gt;
&lt;p&gt;In fact, we had the chance to showcase exactly that:&lt;/p&gt;
&lt;lite-youtube videoid="7zhJwnkTQdw" playlabel="7zhJwnkTQdw" params="start=112"&gt;&lt;/lite-youtube&gt;
&lt;hr&gt;
&lt;p&gt;But there is a quieter, more immediate opportunity sitting right at the edge, inside the middleware layer that connects machines and third-party interfaces to the MES.&lt;/p&gt;
&lt;p&gt;It is where OPC-UA tags become structured events, where protocol translations happen, where business rules fire in low-code tasks. The data is already there. The question is whether we can make it more accessible.&lt;/p&gt;
&lt;p&gt;This post explores how &lt;strong&gt;Small Language Models (SLMs)&lt;/strong&gt; can be embedded directly into Connect IoT to unlock natural language interaction with the edge runtime: without a cloud dependency, without a GPU, and without leaving the shopfloor network.&lt;/p&gt;
&lt;p&gt;The goal is not to show a definitive approach, but to showcase a building block that can be used to create powerful agentic workflows.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Large Models vs Small Models
&lt;div id="large-models-vs-small-models" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#large-models-vs-small-models" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;To understand why SLMs are interesting at the edge, it helps to first understand what differentiates them from their larger counterparts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Large Language Models (LLMs)&lt;/strong&gt; — &lt;code&gt;GPT-Models&lt;/code&gt;, &lt;code&gt;Claude&lt;/code&gt;, &lt;code&gt;Gemini&lt;/code&gt; — are general-purpose models trained on vast datasets covering virtually every domain of human knowledge. They are remarkably capable and can reason across complex, multi-step problems. They also require significant compute: tens to hundreds of gigabytes of memory, often a GPU, and for hosted versions, a reliable internet connection. Running them locally on edge hardware is currently impractical in most industrial environments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small Language Models (SLMs)&lt;/strong&gt; are a different trade-off. Models like &lt;code&gt;Llama 3.2 3B&lt;/code&gt;, &lt;code&gt;Phi-4 Mini&lt;/code&gt;, &lt;code&gt;Gemma 3 4B&lt;/code&gt;, or &lt;code&gt;Qwen2.5 3B&lt;/code&gt; fit comfortably in 2–4 GB of RAM, run acceptably fast on a standard CPU, and can be quantized to run efficiently on modest hardware. They sacrifice some general reasoning capability, but for a focused, well-scoped task they can perform remarkably well.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The key insight for manufacturing is that we do not need a model that can write poetry or explain philosophy. We need a model that can understand a question about what is currently happening on a machine, look at the available data, and give a useful answer. That is a narrower problem and SLMs are well-suited for it.&lt;/p&gt;
&lt;p&gt;The pace of innovation in large language models has been remarkable.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Yet as frontier models grow more capable, a parallel trend is emerging: the rise of optimized, smaller models.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;These models sacrifice breadth and raw power for efficiency, and that trade-off is often exactly what a real-world problem demands.&lt;/p&gt;
&lt;p&gt;A useful way to think about model selection is through the lens of &lt;strong&gt;problem scope&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;At one extreme, narrow and well-defined problems are best served by classical mathematical algorithms, such as linear regression or decision trees, where the solution space is predictable and mappable. As problems grow more complex, with higher dimensionality and harder-to-formalize relationships, tailored machine learning models become the right tool. Move further still into problems that are semantic, contextual, or linguistic in nature, and language models enter the picture.&lt;/p&gt;
&lt;p&gt;Within that last category, scope still matters. A model that needs to reason across domains as wide as gardening and astrophysics requires a large language model, trained on vast and diverse content. But a model operating within a specific domain, say, legal document review or industrial maintenance, can be a small, fine-tuned language model that is faster, cheaper, and often more accurate within its lane.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Problem Space"
src="https://image.j-roque.com/posts/20260318-edge-ai/problemspace.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The key insight is that &lt;strong&gt;bigger is not always better&lt;/strong&gt;. Matching model capability to problem scope is not a compromise, it is good engineering.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The LLMs are the Mainframe and the SLMs are the PCs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Why the Edge Is the Right Place
&lt;div id="why-the-edge-is-the-right-place" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-the-edge-is-the-right-place" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;CM MES Connect IoT&lt;/strong&gt; typically, runs as part of the edge infrastructure. In many deployments the Automation Manager runs on a server on the shopfloor network, close to the machines it integrates. Even in cases where the MES is deployed in the cloud. It has direct access to driver state, controller variables, event history, and everything flowing through its low-code tasks.&lt;/p&gt;
&lt;p&gt;We had the opportunity to show case this scenario where we had the AutomationManager deployed in a on edge microshift cluster and the CM MES on cloud openshift cluster:&lt;/p&gt;
&lt;lite-youtube videoid="mSYQyeLJwQM" playlabel="mSYQyeLJwQM" params="start=4920"&gt;&lt;/lite-youtube&gt;
&lt;hr&gt;
&lt;p&gt;Keeping inference at the edge brings several concrete advantages:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; A question about what a machine is currently doing deserves an answer in milliseconds, not in the round-trip time to a cloud endpoint. On a busy shopfloor network with intermittent connectivity, cloud inference is simply unreliable for operational queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Data sovereignty.&lt;/strong&gt; Shopfloor data is often sensitive — process parameters, cycle times, quality results, equipment identifiers. Many customers operate under strict policies about what can leave the plant network. A locally running model never transmits that data anywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offline resilience.&lt;/strong&gt; Machines do not stop running because the internet is down. An edge-deployed SLM is available whenever the Automation Manager is running, regardless of external connectivity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Cloud LLM APIs are priced per token. For a conversational interface that operators might query dozens of times a shift across multiple lines, those costs accumulate quickly. Local inference has no marginal cost per query.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Connect IoT as the Context Provider
&lt;div id="connect-iot-as-the-context-provider" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connect-iot-as-the-context-provider" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The value of an SLM at the edge is entirely dependent on the quality of context it receives. A language model does not magically know what is happening on your shopfloor, it reasons over whatever information you provide in its prompt.&lt;/p&gt;
&lt;p&gt;Connect IoT is already the system that aggregates that information. The Automation Manager holds in memory the current state of every variable defined across its controllers and drivers. Every tag read from OPC-UA, every message received over MQTT, every value written by a low-code task — all of it is accessible through the internal memory of the running manager.&lt;/p&gt;
&lt;p&gt;With this as context:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A natural language query like &lt;em&gt;&amp;ldquo;What is the spindle doing and should I be concerned?&amp;rdquo;&lt;/em&gt; becomes a reasonable, answerable question.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Low-Code Tasks as the Integration Point
&lt;div id="low-code-tasks-as-the-integration-point" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#low-code-tasks-as-the-integration-point" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the strengths of Connect IoT&amp;rsquo;s low-code model is its extensibility. Automation Controllers are composed of tasks, discrete units of logic that read variables, make decisions, call external services, and write outputs back into the controller state.&lt;/p&gt;
&lt;p&gt;This extensibility is what makes SLM integration natural. We can implement a custom task that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reads variables from the controller&amp;rsquo;s current state&lt;/li&gt;
&lt;li&gt;Serializes that state into a structured context payload&lt;/li&gt;
&lt;li&gt;Passes a user query and that context to a locally running SLM&lt;/li&gt;
&lt;li&gt;Returns the model&amp;rsquo;s response as a task output or controller variable&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;From the low-code editor&amp;rsquo;s perspective, this is &lt;strong&gt;just another task&lt;/strong&gt; with an input and an output. The complexity of local model inference is encapsulated behind a clean API.&lt;/p&gt;
&lt;p&gt;The model is not replacing the low-code logic — it is augmenting the operator&amp;rsquo;s ability to interact with it.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agentic Workflow"
src="https://image.j-roque.com/posts/20260318-edge-ai/agenticworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We are able with the low code integration and some metadata, to leverage what the MES provides out of the box to create a quick implementation where we can chat with our on edge integration.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260318-edge-ai/edge-ai.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;h2 class="relative group"&gt;Looking Ahead
&lt;div id="looking-ahead" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#looking-ahead" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this example we were able to leverage the CM MES low code platforms to quickly create an implementation of edge AI that provides value.&lt;/p&gt;
&lt;p&gt;Edge AI does not have to mean complex infrastructure. Sometimes it means a small model, a structured prompt, and the right integration point.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260318-edge-ai/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260318-edge-ai/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Leveraging our Equipment Test Tool to Create Realistic Demos</title>
<link>https://j-roque.com/posts/20260317-simulators-test-orchestrator/</link>
<pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260317-simulators-test-orchestrator/</guid>
<description>From Equipment Tests to Full on Applications</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260317-simulators-test-orchestrator/featured.png" alt="Leveraging our Equipment Test Tool to Create Realistic Demos" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the hardest parts of an MES project is dealing with third-party integrations. Those integrations can involve not just equipment but all the other software that exists on the shopfloor.&lt;/p&gt;
&lt;p&gt;Over the years, different industries have created different interfaces — some are open standards and protocols, others are vendor-specific.&lt;/p&gt;
&lt;p&gt;Critical Manufacturing created the Connect IoT application to be a middleware that is able to interface with external systems and translate them into the MES. This seems trivial but it can entail complex integration flows, with different levels of MES or machine coupling.&lt;/p&gt;
&lt;p&gt;Having a rich test surface is paramount in order to create reliable integration flows. This quickly became hard task as it often times required bespoke test tooling in order to emulate machine behaviors and a dedicated MES System. We decided to completely change the paradigm and create a common framework that enables not only the machine emulation across all communication protocols, but also to create mocks of the MES and all other dependencies. This solved another issue, which was the time it took to run test suites.&lt;/p&gt;
&lt;p&gt;In a previous blog post we already covered this tool in its typical use case: &lt;a
href="https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/"
target="_blank"
&gt;Test Orchestrator Tests&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For this post I want to explore a different use case. Where we use the same tool, not just for automatic testing but to create demo scenarios and testing applications.&lt;/p&gt;
&lt;p&gt;One of the pain points we found over the years is how we can communicate the complex processes and assumptions running in these integrations to all stakeholders. Their feedback is crucial, but often times the problem is framed as a very technical one, when it&amp;rsquo;s the complete opposite. It&amp;rsquo;s common that the machine integration is the one driving the shopfloor and the one dictating the process constraints.&lt;/p&gt;
&lt;p&gt;In the past creating these visualizations and helper tools was a big time sink and was left to the end of the process. This did not enable the implementation to accompany the agile sprint, demo, test and validation and problems were being found late in the project lifecycle. Now, we are going to show how easy it is to create and tailor them. Creating small and simple applications that let stakeholders interact with and validate the solution is crucial.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;It enables the transition from passive users to active solution owners.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 class="relative group"&gt;Why Use the Test Orchestrator
&lt;div id="why-use-the-test-orchestrator" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-use-the-test-orchestrator" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the obvious downsides of creating small demo or test applications is the proliferation of custom tools.&lt;/p&gt;
&lt;p&gt;Using the same tool we already use for testing allows us to reuse our existing tests as a starting point, maintain a consistent codebase across all demos, and benefit from a framework that already supports multiple protocols out of the box.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Building a Simple OPC-UA Demo
&lt;div id="building-a-simple-opc-ua-demo" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-simple-opc-ua-demo" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s imagine a simple scenario where we integrate with a CNC machine.&lt;/p&gt;
&lt;p&gt;We have a CNC machine with a set of OPC-UA tags. Let&amp;rsquo;s start by configuring the TestOrchestrator.&lt;/p&gt;
&lt;p&gt;The configuration is straightforward — we add the Simulator Plugin for OPC-UA and declare all the tags, with their default values, that we want to simulate.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  var scenario = new ScenarioConfiguration()
      .ManagerId(ManagerName)
      .ConfigPath(&amp;#34;C:/Users/Roque/Downloads/roque/config.downloaded.json&amp;#34;)
      .AddSimulatorPlugin&amp;lt;IoTTestOrchestrator.OPCUA.PluginMain&amp;gt;(
          new IoTTestOrchestrator.OPCUA.Plugin.SettingsBuilder()
              .Address(OpcUaServer)
              .AddTag(new OpcTag { Name = &amp;#34;Controller.Execution&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.execution&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;READY&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Controller.Program&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.program&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;O1001_BRACKET_AL6061&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Controller.Block&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.block&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;N0000&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Controller.EmergencyStop&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.emergencyStop&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;ARMED&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Controller.PartCount&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.partCount&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0), Type = &amp;#34;Int32&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Controller.CycleTime&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.controller.cycleTime&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Spindle.Speed&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.spindle.speed&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Spindle.Load&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.spindle.load&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Spindle.Override&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.spindle.override&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(100.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Spindle.Temperature&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.spindle.temperature&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(22.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Spindle.ToolNumber&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.spindle.toolNumber&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0), Type = &amp;#34;Int32&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.X.Position&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.x.position&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.X.Load&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.x.load&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.Y.Position&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.y.position&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.Y.Load&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.y.load&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.Z.Position&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.z.position&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.Z.Load&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.z.load&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.Feedrate&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.feedrate&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Axis.FeedrateOverride&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.axis.feedrateOverride&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(100.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Coolant.FlowRate&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.coolant.flowRate&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(0.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Coolant.Temperature&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.coolant.temperature&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(20.0), Type = &amp;#34;Float&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Condition.Spindle&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.condition.spindle&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;NORMAL&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Condition.Coolant&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.condition.coolant&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;NORMAL&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Condition.Axes&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.condition.axes&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;NORMAL&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Material.CurrentPartId&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.material.currentPartId&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Material.WorkOrderId&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.material.workOrderId&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Material.TrackInTime&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.material.trackInTime&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Material.TrackOutResult&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.material.trackOutResult&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .AddTag(new OpcTag { Name = &amp;#34;Material.TrackOutTime&amp;#34;, NodeId = &amp;#34;ns=2;s=cnc007.material.trackOutTime&amp;#34;, AccessMode = AccessMode.ReadAndWrite, Value = JsonSerializer.SerializeToElement(&amp;#34;&amp;#34;), Type = &amp;#34;String&amp;#34; })
              .Build());
_scenario = new TestScenario(scenario);
  var context = _scenario.Context();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With just this snippet we already have an OPC-UA server up and serving all these tags.&lt;/p&gt;
&lt;p&gt;Now we can build our scenario. A scenario is a predetermined sequence of tag writes that we orchestrate to mimic the real behavior of the machine. It can be as complex or as simple as needed.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;public async Task RunCyclesAsync(IoTTestOrchestrator.OPCUA.PluginMain opc, CancellationToken ct)
{
    var rng = new Random();
    int totalParts = 0;
while (!ct.IsCancellationRequested)
    {
        string partId = $&amp;#34;BRK-2026-{_partCounter&amp;#43;&amp;#43;:D4}&amp;#34;;
        string trackInTime = DateTime.UtcNow.ToString(&amp;#34;HH:mm:ss&amp;#34;);
        bool coolantFault = rng.NextDouble() &amp;lt; 0.15;
        bool spindleWarning = rng.NextDouble() &amp;lt; 0.10;
// ── TRACK IN ──────────────────────────────────────────────────
        _state.WriteTag(opc, &amp;#34;Material.CurrentPartId&amp;#34;, partId);
        _state.WriteTag(opc, &amp;#34;Material.WorkOrderId&amp;#34;, _currentWorkOrder);
        _state.WriteTag(opc, &amp;#34;Material.TrackInTime&amp;#34;, trackInTime);
        _state.WriteTag(opc, &amp;#34;Material.TrackOutResult&amp;#34;, &amp;#34;&amp;#34;);
        _state.WriteTag(opc, &amp;#34;Material.TrackOutTime&amp;#34;, &amp;#34;&amp;#34;);
await _broadcastAsync();
// ── CYCLE START ───────────────────────────────────────────────
        _state.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;ACTIVE&amp;#34;);
        _state.WriteTag(opc, &amp;#34;Controller.Program&amp;#34;, &amp;#34;O1001_BRACKET_AL6061&amp;#34;);
        _state.WriteTag(opc, &amp;#34;Controller.Block&amp;#34;, &amp;#34;N0010&amp;#34;);
        _state.WriteTag(opc, &amp;#34;Spindle.ToolNumber&amp;#34;, 4);
        _state.WriteTag(opc, &amp;#34;Spindle.Speed&amp;#34;, 8200.0f);
        _state.WriteTag(opc, &amp;#34;Spindle.Load&amp;#34;, 12.0f);
        _state.WriteTag(opc, &amp;#34;Axis.Feedrate&amp;#34;, 500.0f);
        _state.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 8.5f);
await _broadcastAsync();
var cycleStart = DateTime.UtcNow;
        bool cycleInterrupted = false;
for (int tick = 0; tick &amp;lt; 9 &amp;amp;&amp;amp; !ct.IsCancellationRequested; tick&amp;#43;&amp;#43;)
        {
            await Task.Delay(6000, ct);
_state.WriteTag(opc, &amp;#34;Spindle.Temperature&amp;#34;, (float)Math.Round(22.0 &amp;#43; tick * 0.8 &amp;#43; rng.NextDouble() * 0.4, 1));
            _state.WriteTag(opc, &amp;#34;Spindle.Load&amp;#34;, (float)Math.Round(spindleWarning &amp;amp;&amp;amp; tick == 4 ? 82.0 : 10.0 &amp;#43; rng.NextDouble() * 8.0, 1));
            _state.WriteTag(opc, &amp;#34;Axis.X.Position&amp;#34;, (float)Math.Round(rng.NextDouble() * 300.0, 3));
            _state.WriteTag(opc, &amp;#34;Axis.Y.Position&amp;#34;, (float)Math.Round(rng.NextDouble() * 200.0, 3));
            _state.WriteTag(opc, &amp;#34;Axis.Z.Position&amp;#34;, (float)Math.Round(-20.0 - rng.NextDouble() * 60.0, 3));
            _state.WriteTag(opc, &amp;#34;Axis.X.Load&amp;#34;, (float)Math.Round(5.0 &amp;#43; rng.NextDouble() * 10.0, 1));
            _state.WriteTag(opc, &amp;#34;Axis.Y.Load&amp;#34;, (float)Math.Round(5.0 &amp;#43; rng.NextDouble() * 10.0, 1));
            _state.WriteTag(opc, &amp;#34;Axis.Z.Load&amp;#34;, (float)Math.Round(8.0 &amp;#43; rng.NextDouble() * 15.0, 1));
            _state.WriteTag(opc, &amp;#34;Controller.Block&amp;#34;, $&amp;#34;N{(tick &amp;#43; 1) * 10:D4}&amp;#34;);
if (spindleWarning &amp;amp;&amp;amp; tick == 4)
            {
                _state.WriteTag(opc, &amp;#34;Condition.Spindle&amp;#34;, &amp;#34;WARNING&amp;#34;);
            }
if (coolantFault &amp;amp;&amp;amp; tick == 6)
            {
                _state.WriteTag(opc, &amp;#34;Condition.Coolant&amp;#34;, &amp;#34;FAULT&amp;#34;);
                _state.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 1.2f);
                _state.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;INTERRUPTED&amp;#34;);
                cycleInterrupted = true;
            }
if (cycleInterrupted) break;
        }
float cycleTime = (float)Math.Round((DateTime.UtcNow - cycleStart).TotalSeconds * 10, 1);
        string trackOutResult;
if (cycleInterrupted)
        {
            trackOutResult = &amp;#34;SCRAPPED&amp;#34;;
            _state.WriteTag(opc, &amp;#34;Material.TrackOutResult&amp;#34;, trackOutResult);
            _state.WriteTag(opc, &amp;#34;Material.TrackOutTime&amp;#34;, DateTime.UtcNow.ToString(&amp;#34;HH:mm:ss&amp;#34;));
await Task.Delay(2000, ct);
_state.WriteTag(opc, &amp;#34;Condition.Coolant&amp;#34;, &amp;#34;NORMAL&amp;#34;);
            _state.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 8.5f);
            _state.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;READY&amp;#34;);
        }
        else
        {
            trackOutResult = &amp;#34;PASS&amp;#34;;
            totalParts&amp;#43;&amp;#43;;
            _state.WriteTag(opc, &amp;#34;Condition.Spindle&amp;#34;, &amp;#34;NORMAL&amp;#34;);
            _state.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;PROGRAM_COMPLETED&amp;#34;);
            _state.WriteTag(opc, &amp;#34;Controller.Block&amp;#34;, &amp;#34;N9999&amp;#34;);
            _state.WriteTag(opc, &amp;#34;Controller.CycleTime&amp;#34;, cycleTime);
            _state.WriteTag(opc, &amp;#34;Controller.PartCount&amp;#34;, totalParts);
            _state.WriteTag(opc, &amp;#34;Spindle.Speed&amp;#34;, 0.0f);
            _state.WriteTag(opc, &amp;#34;Spindle.Load&amp;#34;, 0.0f);
            _state.WriteTag(opc, &amp;#34;Axis.Feedrate&amp;#34;, 0.0f);
            _state.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 0.0f);
            _state.WriteTag(opc, &amp;#34;Material.TrackOutResult&amp;#34;, trackOutResult);
            _state.WriteTag(opc, &amp;#34;Material.TrackOutTime&amp;#34;, DateTime.UtcNow.ToString(&amp;#34;HH:mm:ss&amp;#34;));
        }
_state.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;READY&amp;#34;);
        await Task.Delay(1500, ct);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scenario is intentionally simple — simulating a CNC machining cycle with a small random chance of a coolant fault or spindle warning to make it feel more realistic. Crucially, all we are doing at the scenario level is writing tags through the TestOrchestrator.&lt;/p&gt;
&lt;p&gt;The simulation runs in a loop until a cancellation token is provided. A simple quality-of-life addition is to wire up a console keypress to trigger that cancellation.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  _cts = new CancellationTokenSource();
Console.WriteLine(&amp;#34;CNC Simulator&amp;#34;);
  Console.WriteLine($&amp;#34;OPC-UA server       : {OpcUaServer}&amp;#34;);
  Console.WriteLine(&amp;#34;Press &amp;#39;q&amp;#39; to quit&amp;#34;);
_ = Task.Run(() =&amp;gt;
  {
      while (true)
      {
          var key = Console.ReadKey(intercept: true);
          if (key.KeyChar is &amp;#39;q&amp;#39; or &amp;#39;Q&amp;#39;)
          {
              Console.WriteLine(&amp;#34;\nShutdown initiated…&amp;#34;);
              _cts.Cancel();
              break;
          }
      }
  });
var scenario = new ScenarioConfiguration()
  (...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now pressing &lt;code&gt;q&lt;/code&gt; gracefully stops the simulation.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Interacting with the MES
&lt;div id="interacting-with-the-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#interacting-with-the-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A common question is how to use the TestOrchestrator while still being able to make requests to the MES. Let&amp;rsquo;s look at how that works.&lt;/p&gt;
&lt;p&gt;In our scenario there is an &lt;strong&gt;Automation Manager&lt;/strong&gt; running and consuming this data. One key guard we want is ensuring the simulation only starts once we know the &lt;strong&gt;Automation Manager&lt;/strong&gt; is actually communicating — not just leftover from a previous run.&lt;/p&gt;
&lt;p&gt;To do that, we first incorporate the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/business/lightbusinessobjects/?h=lbos#using-the-c-lbos"
target="_blank"
&gt;LBOs SDK&lt;/a&gt; in the simulator.&lt;/p&gt;
&lt;p&gt;We can download it directly from the MES portal.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260317-simulators-test-orchestrator/download_lbos.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;This produces a zip file with everything you need, including any project customizations.&lt;/p&gt;
&lt;p&gt;We then need an &lt;strong&gt;appsettings.json&lt;/strong&gt; file that points to the target environment.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;AppSettings&amp;#34;: {
    &amp;#34;HostAddress&amp;#34;: &amp;#34;&amp;lt;your-environment&amp;gt;&amp;#34;,
    &amp;#34;ClientTenantName&amp;#34;: &amp;#34;&amp;lt;your-tenant&amp;gt;&amp;#34;,
    &amp;#34;IsUsingLoadBalancer&amp;#34;: &amp;#34;false&amp;#34;,
    &amp;#34;ClientId&amp;#34;: &amp;#34;&amp;lt;your-clientId&amp;gt;&amp;#34;,
    &amp;#34;UseSsl&amp;#34;: &amp;#34;true&amp;#34;,
    &amp;#34;SecurityAccessToken&amp;#34;: &amp;#34;&amp;lt;your-token&amp;gt;&amp;#34;,
    &amp;#34;SecurityPortalBaseAddress&amp;#34;: &amp;#34;&amp;lt;your-environment-securityportal&amp;gt;&amp;#34;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we instantiate the LBOs client. After this block, any LBOs call will reach the configured MES environment.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  var config = new ConfigurationBuilder()
      .AddJsonFile(&amp;#34;appsettings.json&amp;#34;, optional: false, reloadOnChange: false)
      .Build();
var appSettings = config.GetSection(&amp;#34;AppSettings&amp;#34;);
ClientConfigurationProvider.ConfigurationFactory = () =&amp;gt;
  {
      return new ClientConfiguration()
      {
          HostAddress = appSettings[&amp;#34;HostAddress&amp;#34;],
          ClientTenantName = appSettings[&amp;#34;ClientTenantName&amp;#34;],
          IsUsingLoadBalancer = bool.Parse(appSettings[&amp;#34;IsUsingLoadBalancer&amp;#34;]!),
          ClientId = appSettings[&amp;#34;ClientId&amp;#34;],
          UseSsl = bool.Parse(appSettings[&amp;#34;UseSsl&amp;#34;]!),
          SecurityAccessToken = appSettings[&amp;#34;SecurityAccessToken&amp;#34;],
          SecurityPortalBaseAddress = new Uri(appSettings[&amp;#34;SecurityPortalBaseAddress&amp;#34;]!)
      };
  };&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example request:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;GetObjectByNameInput getObjectByName = new GetObjectByNameInput()
{
    Name = &amp;#34;MyMaterialName&amp;#34;,
    Type = new Material()
};
var output = getObjectByName.GetObjectByNameSync();
Material outputMaterial = output.Instance as Material;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The LBOs SDK provides not just the ability to make requests but also full CM object type definitions.&lt;/p&gt;
&lt;p&gt;As I mentioned, one utility I always include is a helper that waits until the &lt;strong&gt;Automation Manager&lt;/strong&gt; is confirmed to be communicating before proceeding with the scenario.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  public static void WaitForConnection(TestScenario testRun, string managerName, int timeout = 60)
  {
      var automation = new GetFullAutomationStructureInput()
      {
          ManagerFilters = new Cmf.Foundation.BusinessObjects.QueryObject.FilterCollection()
          {
              new Cmf.Foundation.BusinessObjects.QueryObject.Filter()
              {
                  Name = &amp;#34;Name&amp;#34;,
                  Value = managerName
              }
          }
      }.GetFullAutomationStructureSync();
(var controllers, var drivers) = ParseDriverInstancesFromDataSet(automation.NgpDataSet);
testRun.Log.Info(&amp;#34;Forcing Restart of the instances&amp;#34;);
new RestartAutomationControllerInstancesInput()
      {
          AutomationControllerInstances = controllers,
          IgnoreLastServiceId = true
      }.RestartAutomationControllerInstancesSync();
testRun.Log.Info(&amp;#34;Restarted the instances&amp;#34;);
foreach (long controllerInstanceId in controllers.Select(x =&amp;gt; x.Id))
      {
          testRun.Utilities.WaitFor(timeout, $&amp;#34;controller for &amp;#39;{managerName}&amp;#39; never connected&amp;#34;, () =&amp;gt;
          {
              var entityInstance = new GetObjectByIdInput()
              {
                  Id = controllerInstanceId,
                  Type = typeof(Cmf.Foundation.BusinessObjects.AutomationControllerInstance)
              }.GetObjectByIdSync().Instance as AutomationControllerInstance;
testRun.Log.Info(&amp;#34;# {0}: controller systemstate={1}&amp;#34;, managerName, entityInstance.SystemState.ToString());
AutomationDriverInstanceCollection automationDriverInstanceCollection = new AutomationDriverInstanceCollection();
foreach (var driverInstance in drivers.Where(x =&amp;gt; x.AutomationControllerInstance.Id == controllerInstanceId).Select(x =&amp;gt; x.Id))
              {
                  var reloadedDriverInstance = new GetObjectByIdInput()
                  {
                      Id = driverInstance,
                      Type = typeof(Cmf.Foundation.BusinessObjects.AutomationDriverInstance)
                  }.GetObjectByIdSync().Instance as AutomationDriverInstance;
automationDriverInstanceCollection.Add(reloadedDriverInstance);
              }
bool doesNotHaveAnyDisconnected = !automationDriverInstanceCollection.Any(driverInstance =&amp;gt;
  driverInstance.SystemState != AutomationSystemState.Running &amp;amp;&amp;amp; driverInstance.CommunicationState != AutomationCommunicationState.Communicating);
return (entityInstance.SystemState == AutomationSystemState.Running &amp;amp;&amp;amp; doesNotHaveAnyDisconnected);
          });
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The logic is slightly complex because we force a restart of the controller instances first. This guarantees we are observing a fresh connection established during this run, not a stale one left over from a previous session. You can see the full implementation &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/Tools/IPCCFXSimulator/Utilities/Utilities.cs"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This is also a good example of the benefits of a common platform — we are leaning on the TestOrchestrator&amp;rsquo;s built-in logging and &lt;code&gt;WaitFor&lt;/code&gt; utilities rather than rolling our own.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Coding a UI
&lt;div id="coding-a-ui" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#coding-a-ui" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;I am not a frontend developer, but one of the advantages of having a solid scenario on the backend is that we can build a simple UI on top of it relatively quickly.&lt;/p&gt;
&lt;p&gt;For this example I asked &lt;code&gt;Claude&lt;/code&gt; to generate a CNC dashboard that interfaces with the simulator over WebSocket and provides a real-time visualization along with some quality-of-life features.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20260317-simulators-test-orchestrator/VibeCodeUI.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;With this we already have a compelling representation of the integration that we can walk stakeholders through.&lt;/p&gt;
&lt;h3 class="relative group"&gt;UI Backend
&lt;div id="ui-backend" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ui-backend" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;To support the UI, the backend gains a few new classes: &lt;code&gt;CncMachineState&lt;/code&gt;, &lt;code&gt;CncWebSocketServer&lt;/code&gt;, and &lt;code&gt;CncMessageBuilder&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  private async Task RunAsync(TestScenario scenario, ITestContext context)
  {
      var opc = context.Simulators[&amp;#34;OPCUA&amp;#34;] as IoTTestOrchestrator.OPCUA.PluginMain;
var machineState = new CncMachineState();
      var messageBuilder = new CncMessageBuilder(machineState, MachineId, OpcUaServer);
      var wsServer = new CncWebSocketServer(messageBuilder, _startSignal);
      var cycleSimulator = new CncCycleSimulator(machineState, wsServer.BroadcastAsync);
Utilities.WaitForConnection(_scenario, ManagerName);
_ = wsServer.RunAsync(_cts.Token);
// Main loop: wait for Start from UI → run cycles → wait again
      while (!_cts.IsCancellationRequested)
      {
          await _startSignal.WaitAsync(_cts.Token);
wsServer.CycleCts = new CancellationTokenSource();
try
          {
              await cycleSimulator.RunCyclesAsync(opc, wsServer.CycleCts.Token);
          }
          catch (OperationCanceledException)
          {
              Console.WriteLine($&amp;#34;[{MachineId}] Cycle interrupted by Stop command&amp;#34;);
          }
// Reset machine to idle
          machineState.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;READY&amp;#34;);
          machineState.WriteTag(opc, &amp;#34;Spindle.Speed&amp;#34;, 0.0f);
          machineState.WriteTag(opc, &amp;#34;Spindle.Load&amp;#34;, 0.0f);
          machineState.WriteTag(opc, &amp;#34;Axis.Feedrate&amp;#34;, 0.0f);
          machineState.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 0.0f);
          await wsServer.BroadcastAsync();
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The original cycle logic is now encapsulated in &lt;strong&gt;CncCycleSimulator&lt;/strong&gt;. The main loop simply waits for a start signal from the UI, runs cycles, and resets the machine to idle when done.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CncMachineState&lt;/strong&gt; is the in-memory record keeper. Every tag write goes through it so that both the OPC-UA server and the WebSocket broadcast always reflect the same current state.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  internal class CncMachineState
  {
      private readonly Dictionary&amp;lt;string, object&amp;gt; _state = new()
      {
          [&amp;#34;Controller.Execution&amp;#34;] = &amp;#34;READY&amp;#34;,
          [&amp;#34;Controller.Program&amp;#34;] = &amp;#34;O1001_BRACKET_AL6061&amp;#34;,
          [&amp;#34;Controller.Block&amp;#34;] = &amp;#34;N0000&amp;#34;,
          [&amp;#34;Controller.EmergencyStop&amp;#34;] = &amp;#34;ARMED&amp;#34;,
          [&amp;#34;Controller.PartCount&amp;#34;] = 0,
          [&amp;#34;Controller.CycleTime&amp;#34;] = 0.0f,
          [&amp;#34;Spindle.Speed&amp;#34;] = 0.0f,
          [&amp;#34;Spindle.Load&amp;#34;] = 0.0f,
          [&amp;#34;Spindle.Override&amp;#34;] = 100.0f,
          [&amp;#34;Spindle.Temperature&amp;#34;] = 22.0f,
          [&amp;#34;Spindle.ToolNumber&amp;#34;] = 0,
          [&amp;#34;Axis.X.Position&amp;#34;] = 0.0f,
          [&amp;#34;Axis.X.Load&amp;#34;] = 0.0f,
          [&amp;#34;Axis.Y.Position&amp;#34;] = 0.0f,
          [&amp;#34;Axis.Y.Load&amp;#34;] = 0.0f,
          [&amp;#34;Axis.Z.Position&amp;#34;] = 0.0f,
          [&amp;#34;Axis.Z.Load&amp;#34;] = 0.0f,
          [&amp;#34;Axis.Feedrate&amp;#34;] = 0.0f,
          [&amp;#34;Axis.FeedrateOverride&amp;#34;] = 100.0f,
          [&amp;#34;Coolant.FlowRate&amp;#34;] = 0.0f,
          [&amp;#34;Coolant.Temperature&amp;#34;] = 20.0f,
          [&amp;#34;Condition.Spindle&amp;#34;] = &amp;#34;NORMAL&amp;#34;,
          [&amp;#34;Condition.Coolant&amp;#34;] = &amp;#34;NORMAL&amp;#34;,
          [&amp;#34;Condition.Axes&amp;#34;] = &amp;#34;NORMAL&amp;#34;,
          [&amp;#34;Material.CurrentPartId&amp;#34;] = &amp;#34;&amp;#34;,
          [&amp;#34;Material.WorkOrderId&amp;#34;] = &amp;#34;&amp;#34;,
          [&amp;#34;Material.TrackInTime&amp;#34;] = &amp;#34;&amp;#34;,
          [&amp;#34;Material.TrackOutResult&amp;#34;] = &amp;#34;&amp;#34;,
          [&amp;#34;Material.TrackOutTime&amp;#34;] = &amp;#34;&amp;#34;,
      };
public void WriteTag(IoTTestOrchestrator.OPCUA.PluginMain opc, string name, object value)
      {
          opc.WriteTag(name, value);
          _state[name] = value;
      }
public string S(string key) =&amp;gt; _state.TryGetValue(key, out var v) ? v?.ToString() ?? &amp;#34;&amp;#34; : &amp;#34;&amp;#34;;
      public float F(string key) =&amp;gt; _state.TryGetValue(key, out var v) ? Convert.ToSingle(v) : 0f;
      public int I(string key) =&amp;gt; _state.TryGetValue(key, out var v) ? Convert.ToInt32(v) : 0;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CncMessageBuilder&lt;/strong&gt; is responsible for serializing the current machine state into JSON messages. It covers three message types: a full telemetry snapshot, a connection status notification, and a command result acknowledgement.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  internal class CncMessageBuilder
  {
      private readonly string _machineId;
      private readonly string _serverEndpoint;
      private readonly CncMachineState _state;
public CncMessageBuilder(CncMachineState state, string machineId, string serverEndpoint)
      {
          _state = state;
          _serverEndpoint = serverEndpoint;
          _machineId = machineId;
      }
public string BuildTelemetryJson()
      {
          var payload = new
          {
              timestamp = DateTime.UtcNow.ToString(&amp;#34;O&amp;#34;),
              machineId = this._machineId,
              controller = new
              {
                  execution = _state.S(&amp;#34;Controller.Execution&amp;#34;),
                  program = _state.S(&amp;#34;Controller.Program&amp;#34;),
                  block = _state.S(&amp;#34;Controller.Block&amp;#34;),
                  emergencyStop = _state.S(&amp;#34;Controller.EmergencyStop&amp;#34;),
                  partCount = _state.I(&amp;#34;Controller.PartCount&amp;#34;),
                  cycleTime = _state.F(&amp;#34;Controller.CycleTime&amp;#34;),
              },
              spindle = new
              {
                  speed = _state.F(&amp;#34;Spindle.Speed&amp;#34;),
                  load = _state.F(&amp;#34;Spindle.Load&amp;#34;),
                  @override = _state.F(&amp;#34;Spindle.Override&amp;#34;),
                  temperature = _state.F(&amp;#34;Spindle.Temperature&amp;#34;),
                  toolNumber = _state.I(&amp;#34;Spindle.ToolNumber&amp;#34;),
              },
              axes = new
              {
                  x = new { position = _state.F(&amp;#34;Axis.X.Position&amp;#34;), load = _state.F(&amp;#34;Axis.X.Load&amp;#34;) },
                  y = new { position = _state.F(&amp;#34;Axis.Y.Position&amp;#34;), load = _state.F(&amp;#34;Axis.Y.Load&amp;#34;) },
                  z = new { position = _state.F(&amp;#34;Axis.Z.Position&amp;#34;), load = _state.F(&amp;#34;Axis.Z.Load&amp;#34;) },
                  feedrate = _state.F(&amp;#34;Axis.Feedrate&amp;#34;),
                  feedrateOverride = _state.F(&amp;#34;Axis.FeedrateOverride&amp;#34;),
              },
              coolant = new
              {
                  flowRate = _state.F(&amp;#34;Coolant.FlowRate&amp;#34;),
                  temperature = _state.F(&amp;#34;Coolant.Temperature&amp;#34;),
              },
              conditions = new
              {
                  spindle = _state.S(&amp;#34;Condition.Spindle&amp;#34;),
                  coolant = _state.S(&amp;#34;Condition.Coolant&amp;#34;),
                  axes = _state.S(&amp;#34;Condition.Axes&amp;#34;),
              },
              material = new
              {
                  currentPartId = _state.S(&amp;#34;Material.CurrentPartId&amp;#34;),
                  workOrderId = _state.S(&amp;#34;Material.WorkOrderId&amp;#34;),
                  trackInTime = _state.S(&amp;#34;Material.TrackInTime&amp;#34;),
                  trackOutResult = _state.S(&amp;#34;Material.TrackOutResult&amp;#34;),
                  trackOutTime = _state.S(&amp;#34;Material.TrackOutTime&amp;#34;),
              },
          };
return JsonSerializer.Serialize(new { type = &amp;#34;telemetry&amp;#34;, payload },
              new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
      }
public string BuildConnectionStatusJson(bool connected) =&amp;gt;
          JsonSerializer.Serialize(new
          {
              type = &amp;#34;connection_status&amp;#34;,
              payload = new { connected, serverEndpoint = this._serverEndpoint, machineId = this._machineId }
          });
public static string BuildCommandResultJson(string command, bool success, string message) =&amp;gt;
          JsonSerializer.Serialize(new
          {
              type = &amp;#34;command_result&amp;#34;,
              payload = new { command, success, message }
          });
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CncWebSocketServer&lt;/strong&gt; handles all WebSocket communication with the UI. It listens on &lt;code&gt;http://localhost:5000/&lt;/code&gt; and supports three incoming commands: &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, and &lt;code&gt;get_telemetry&lt;/code&gt;. On connect it immediately sends the current telemetry snapshot so the UI has data before the first cycle begins.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  internal class CncWebSocketServer
  {
      public const string WsUrl = &amp;#34;http://localhost:5000/&amp;#34;;
private readonly ConcurrentDictionary&amp;lt;Guid, WebSocket&amp;gt; _clients = new();
      private readonly CncMessageBuilder _messageBuilder;
      private readonly SemaphoreSlim _startSignal;
public CancellationTokenSource? CycleCts { get; set; }
public CncWebSocketServer(CncMessageBuilder messageBuilder, SemaphoreSlim startSignal)
      {
          _messageBuilder = messageBuilder;
          _startSignal = startSignal;
      }
public async Task RunAsync(CancellationToken ct)
      {
          var listener = new HttpListener();
          listener.Prefixes.Add(WsUrl);
          try { listener.Start(); }
          catch (Exception ex)
          {
              Console.WriteLine($&amp;#34;[WS] Failed to start listener: {ex.Message}&amp;#34;);
              return;
          }
Console.WriteLine($&amp;#34;[WS] Listening on {WsUrl}&amp;#34;);
while (!ct.IsCancellationRequested)
          {
              HttpListenerContext ctx;
              try { ctx = await listener.GetContextAsync(); }
              catch { break; }
if (ctx.Request.IsWebSocketRequest)
                  _ = HandleWebSocketClientAsync(ctx, ct);
              else
                  HandleHttpRequest(ctx);
          }
listener.Stop();
      }
public async Task BroadcastAsync()
      {
          var json = _messageBuilder.BuildTelemetryJson();
          foreach (var (id, ws) in _clients)
          {
              try { await SendToAsync(ws, json); }
              catch { _clients.TryRemove(id, out _); }
          }
      }
private async Task HandleWebSocketClientAsync(HttpListenerContext ctx, CancellationToken ct)
      {
          var wsCtx = await ctx.AcceptWebSocketAsync(subProtocol: null);
          var ws = wsCtx.WebSocket;
          var id = Guid.NewGuid();
          _clients[id] = ws;
Console.WriteLine($&amp;#34;[WS] Client connected ({id})&amp;#34;);
await SendToAsync(ws, _messageBuilder.BuildTelemetryJson());
          await SendToAsync(ws, _messageBuilder.BuildConnectionStatusJson(connected: true));
var buf = new byte[1024];
          try
          {
              while (ws.State == WebSocketState.Open &amp;amp;&amp;amp; !ct.IsCancellationRequested)
              {
                  var result = await ws.ReceiveAsync(buf, ct);
                  if (result.MessageType == WebSocketMessageType.Close) break;
var msg = JsonDocument.Parse(buf[..result.Count]);
                  var type = msg.RootElement.GetProperty(&amp;#34;type&amp;#34;).GetString();
switch (type)
                  {
                      case &amp;#34;start&amp;#34;:
                          if (CycleCts == null || CycleCts.IsCancellationRequested)
                          {
                              if (_startSignal.CurrentCount == 0) _startSignal.Release();
                              await SendToAsync(ws, CncMessageBuilder.BuildCommandResultJson(&amp;#34;start&amp;#34;, true, &amp;#34;Cycle started&amp;#34;));
                          }
                          else
                          {
                              await SendToAsync(ws, CncMessageBuilder.BuildCommandResultJson(&amp;#34;start&amp;#34;, false, &amp;#34;Already running&amp;#34;));
                          }
                          break;
case &amp;#34;stop&amp;#34;:
                          CycleCts?.Cancel();
                          await SendToAsync(ws, CncMessageBuilder.BuildCommandResultJson(&amp;#34;stop&amp;#34;, true, &amp;#34;Cycle stopped&amp;#34;));
                          break;
case &amp;#34;get_telemetry&amp;#34;:
                          await SendToAsync(ws, _messageBuilder.BuildTelemetryJson());
                          break;
                  }
              }
          }
          catch (OperationCanceledException) { /* shutdown */ }
          catch (Exception ex) { Console.WriteLine($&amp;#34;[WS] Client error: {ex.Message}&amp;#34;); }
          finally
          {
              _clients.TryRemove(id, out _);
              Console.WriteLine($&amp;#34;[WS] Client disconnected ({id})&amp;#34;);
              try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, &amp;#34;bye&amp;#34;, CancellationToken.None); } catch { }
          }
      }
private void HandleHttpRequest(HttpListenerContext ctx)
      {
          ctx.Response.Headers.Add(&amp;#34;Access-Control-Allow-Origin&amp;#34;, &amp;#34;*&amp;#34;);
          ctx.Response.ContentType = &amp;#34;application/json&amp;#34;;
var path = ctx.Request.Url?.AbsolutePath.TrimEnd(&amp;#39;/&amp;#39;);
          string body = path switch
          {
              &amp;#34;/health&amp;#34; =&amp;gt; $&amp;#34;{{\&amp;#34;status\&amp;#34;:\&amp;#34;ok\&amp;#34;,\&amp;#34;clients\&amp;#34;:{_clients.Count}}}&amp;#34;,
              &amp;#34;/status&amp;#34; =&amp;gt; $&amp;#34;{{\&amp;#34;running\&amp;#34;:{(CycleCts != null &amp;amp;&amp;amp; !CycleCts.IsCancellationRequested).ToString().ToLower()}}}&amp;#34;,
              _ =&amp;gt; &amp;#34;{\&amp;#34;error\&amp;#34;:\&amp;#34;not found\&amp;#34;}&amp;#34;
          };
ctx.Response.StatusCode = path is &amp;#34;/health&amp;#34; or &amp;#34;/status&amp;#34; ? 200 : 404;
          var bytes = Encoding.UTF8.GetBytes(body);
          ctx.Response.OutputStream.Write(bytes);
          ctx.Response.Close();
      }
private static async Task SendToAsync(WebSocket ws, string json)
      {
          if (ws.State != WebSocketState.Open) return;
          var bytes = new ArraySegment&amp;lt;byte&amp;gt;(Encoding.UTF8.GetBytes(json));
          await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notifying the UI after any state change is then just a single call:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  // Reset machine to idle
  machineState.WriteTag(opc, &amp;#34;Controller.Execution&amp;#34;, &amp;#34;READY&amp;#34;);
  machineState.WriteTag(opc, &amp;#34;Spindle.Speed&amp;#34;, 0.0f);
  machineState.WriteTag(opc, &amp;#34;Spindle.Load&amp;#34;, 0.0f);
  machineState.WriteTag(opc, &amp;#34;Axis.Feedrate&amp;#34;, 0.0f);
  machineState.WriteTag(opc, &amp;#34;Coolant.FlowRate&amp;#34;, 0.0f);
await wsServer.BroadcastAsync();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;FrontEnd
&lt;div id="frontend" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#frontend" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The frontend is an &lt;strong&gt;Angular&lt;/strong&gt; application built with standalone components and a feature-based folder structure. Styling is handled by &lt;strong&gt;Tailwind CSS&lt;/strong&gt; with a custom dark industrial color palette, and &lt;strong&gt;anime.js&lt;/strong&gt; drives the machine animations.&lt;/p&gt;
&lt;p&gt;The project follows a single feature module pattern under &lt;code&gt;src/app/features/cnc/&lt;/code&gt;, with a standard separation between components, services, and models.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/app/features/cnc/
├── components/
│   ├── cnc-dashboard/        ← main layout container (three-column grid)
│   ├── cnc-visualization/    ← SVG-based animated CNC machine
│   ├── control-panel/        ← start/stop buttons, alarms, condition badges
│   ├── telemetry-panel/      ← real-time numeric data display
│   ├── event-log/            ← timestamped event history
│   └── tags-page/            ← OPC UA tag browser (separate route)
├── services/
│   ├── websocket.service.ts  ← WebSocket client &amp;#43; all reactive state signals
│   └── animation.service.ts  ← anime.js wrappers for axis and spindle movement
└── models/
    └── cnc.models.ts         ← TypeScript interfaces matching the JSON payload&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app has two lazy-loaded routes: &lt;code&gt;/dashboard&lt;/code&gt; for the main three-panel view and &lt;code&gt;/tags&lt;/code&gt; for the full OPC UA tag browser.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;WebSocketService&lt;/strong&gt; is the single source of truth. It connects to &lt;code&gt;ws://localhost:5000&lt;/code&gt;, receives JSON telemetry pushed by the backend on every state change, and exposes everything as Angular signals. Computed signals derive booleans like &lt;code&gt;isRunning&lt;/code&gt;, &lt;code&gt;hasAlarm&lt;/code&gt;, and &lt;code&gt;isSpindleRunning&lt;/code&gt; so each component reacts automatically without manual subscription management. The service also handles sending commands (&lt;code&gt;start&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;get_telemetry&lt;/code&gt;) back to the simulator.&lt;/p&gt;
&lt;p&gt;The centerpiece of the dashboard is the &lt;strong&gt;CncVisualizationComponent&lt;/strong&gt;, which renders the machine as an SVG. The spindle head, work table, and tool holder are all independent SVG elements whose positions are driven by computed signals that map the machine&amp;rsquo;s real millimeter coordinates into pixel offsets. Spindle rotation and axis travel are animated through the &lt;strong&gt;AnimationService&lt;/strong&gt;, which wraps anime.js — so when a new telemetry update arrives with a different X/Y/Z position, the component animates smoothly to the new coordinates rather than jumping.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;TelemetryPanel&lt;/strong&gt; and &lt;strong&gt;ControlPanel&lt;/strong&gt; are straightforward display components. The telemetry panel surfaces all numeric values — axis positions, spindle speed and load, feedrate, coolant flow, cycle time, and material tracking — with conditional color coding that highlights warnings and faults. The control panel shows the current execution state, condition badges for spindle, coolant, and axes, and the start/stop buttons whose enabled state is derived from the current &lt;code&gt;execution&lt;/code&gt; value.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;TagsPage&lt;/strong&gt; provides a searchable and filterable table of all 30+ OPC UA tags organized by group. Values flash briefly when they change, making it easy to see which tags are active during a cycle. This view is particularly useful during integration debugging since it mirrors exactly what the OPC UA server is serving.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The TestOrchestrator was built for testing, but as this example shows, it is equally well suited for creating living demos of shopfloor integrations. The same scenario code that drives an automated test can drive a realistic simulation, complete with fault injection and MES interaction — with a UI layered on top to make it accessible to everyone involved.&lt;/p&gt;
&lt;p&gt;This combination of a reusable simulator framework, MES connectivity, and a lightweight dashboard can be a meaningful tool for testing, validation, and onboarding stakeholders onto new integrations.&lt;/p&gt;
&lt;p&gt;Also, with the help of coding assistants this becomes very easy to implement.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260317-simulators-test-orchestrator/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260317-simulators-test-orchestrator/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Relational Anchor System</title>
<link>https://j-roque.com/posts/20260225-relationalmes/</link>
<pubDate>Wed, 25 Feb 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260225-relationalmes/</guid>
<description>From hierarchical to relational a journey on your anchor system</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260225-relationalmes/featured.png" alt="Relational Anchor System" /&gt;&lt;/p&gt;&lt;p&gt;Why do organizations need an anchor system for an AI strategy and why that system must have not just a hierarchical perspective, but also a deeply relational one.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Most of your systems exist in silos. They can have an internal coherence and semantic but that semantic does not cross over to other systems.&lt;/p&gt;
&lt;p&gt;An ERP speaks ERP, a SCADA speaks SCADA and an MES speaks MES. This has been an age old problem of system interoperability.&lt;/p&gt;
&lt;p&gt;This was a very hard wall to bring down, because it actually matched the user base silos. ERP users typically only use the ERP, therefore for them it&amp;rsquo;s fine to create their own jargon and semantics. A dialogue between different silos is inherently hard as it will be flooded with specific nomenclatures and codes, that only people in the silos are able to make sense of.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Tower of Babel"
src="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Pieter_Bruegel_the_Elder_-_The_Tower_of_Babel_%28Vienna%29_-_Google_Art_Project_-_edited.jpg/1280px-Pieter_Bruegel_the_Elder_-_The_Tower_of_Babel_%28Vienna%29_-_Google_Art_Project_-_edited.jpg"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;Tower of Babel&lt;/em&gt; — Image source: &lt;em&gt;&lt;a
href="https://en.wikipedia.org/wiki/Tower_of_Babel"
target="_blank"
&gt;https://en.wikipedia.org/wiki/Tower_of_Babel&lt;/a&gt;&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;AI has turned what used to be an interoperability headache into a strategic suicide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;AI is boosted by having a large knowledge base. The bigger the knowledge base, the bigger the inference capacity and accuracy.&lt;/p&gt;
&lt;p&gt;Information silos and babilonic semantics are the bane of AI.&lt;/p&gt;
&lt;p&gt;If depending on the interface, a product is a sku (Stock Keeping Unit), or a container is a FOUP (Front Opening Unified Pod), this means that AI won&amp;rsquo;t have the inference capacity to make relations between those different information sets, that actually refer to the same shopfloor object.&lt;/p&gt;
&lt;p&gt;AI needs a &lt;strong&gt;live structured and coherent dataset&lt;/strong&gt;, that is a representation of everything happening on your enterprise.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Anchor System
&lt;div id="anchor-system" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#anchor-system" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The solution is finding your &lt;strong&gt;anchor system&lt;/strong&gt;. This was made really clear by &lt;a
href="https://www.jeffwinterinsights.com/"
target="_blank"
&gt;Jeff Winter&lt;/a&gt;&amp;rsquo;s presentation at &lt;a
href="https://www.proveitconference.com/"
target="_blank"
&gt;ProveIt 2026&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The goal is clear, organizations need to find what system drives their business and therefore which system is their semantic anchor.&lt;/p&gt;
&lt;p&gt;This is a system that percolates and drives your organization. It provides a perspective where you feel comfortable to homogenize your organization with.&lt;/p&gt;
&lt;p&gt;But how to chose? What should be your anchor system?&lt;/p&gt;
&lt;p&gt;If your business is driven by being a part of a constrained supply chain or by answering to purchase orders as fast as possible, your ERP system could be a bet. Or if your enterprise is highly automated with low product mix, where your goal is just to keep it running as long as possible maybe your scada system is your bet.&lt;/p&gt;
&lt;p&gt;For most mature businesses, the &lt;code&gt;holistic nature of the MES&lt;/code&gt; as your shopfloor management and execution system, makes it the natural anchor system. It&amp;rsquo;s the one forced to interface with all other systems and the one with the more heterogeneous user base.&lt;/p&gt;
&lt;p&gt;From operators and line managers to quality engineers, machine engineers, and plant managers, they all interact daily with the MES. It is where they go not only to find answers, but to uncover relationships between events on the shop floor.&lt;/p&gt;
&lt;p&gt;Contrary to other systems, MES does not have a myopic view of the shopfloor, but it encompasses all the actions performed in and around your shopfloor. For more specialized software, the MES interfaces with it and provides and extracts all the relevant content.&lt;/p&gt;
&lt;p&gt;It holds all the semantic context and nomenclature of all the objects in your shopfloor. It has a view not just of hierarchy but of dynamic relations.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Relational System
&lt;div id="relational-system" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#relational-system" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Most systems and strategies try to standardize on a &lt;em&gt;hierarchical topology&lt;/em&gt; of the shopfloor. The process is a pain and becomes very complex, because the systems are intrinsically passive and ambiguous. They expect a third-party to give it it&amp;rsquo;s place on the hierarchy.&lt;/p&gt;
&lt;p&gt;Notice, how this is an oversimplification of the dynamics of the shop floor. A resource name, by itself, provides insufficient context on the actions and role it is performing in the shopfloor. What is a process resource today may be running quality samples or R&amp;amp;D products tomorrow. The resource and its ISA95 structure provides little context on its real time role in the shopfloor.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;MES is a control system&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It does not only define topology, it defines an &lt;strong&gt;opinionated ontology&lt;/strong&gt;. It encodes a perspective on how entities exist, how they relate, and what is valid. It eliminates ambiguity. It enforces behaviors and business rules.&lt;/p&gt;
&lt;p&gt;This necessarily constrains the degrees of freedom on the shopfloor. That is not a side effect, it is the point. An MES prevents unconstrained action by design.&lt;/p&gt;
&lt;p&gt;In languages, what LLMs were trained with, It happens the exact same thing. There are words, sentences and semantic structure. There are degrees of freedom in creating new words, but the overall semantic guardrails stay there all the same.&lt;/p&gt;
&lt;p&gt;Of course, if your MES is insufficient, either in the user experience or in missing key functionalities, this becomes a pain. You end up creating grey zones or black holes in your factory. This is one of the key reasons, it is important to chose a mature product with a &lt;em&gt;data model that is able to map your shopfloor as closely and with as much control as possible&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Some architectural patterns like &lt;code&gt;UNS&lt;/code&gt; (Unified Namespace) are a very interesting way to be able to quickly assign a hierarchy in your shopfloor and be able to start creating a semblance of data model and data structure. Nevertheless, they suffer from a &lt;strong&gt;lack of a relational context&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In a previous &lt;a
href="https://j-roque.com/posts/20260218-frominsighttoaction/#why-context-is-everything"
target="_blank"
&gt;blog post&lt;/a&gt; we explained how in an AI dialog with the MES we were able to quickly explore and discover our shopfloor:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CjxtZXRhIGNoYXJzZXQ9IlVURi04Ij4KPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAiPgo8dGl0bGU&amp;#43;RXVyb3BlLVdlc3QgUmVsYXRpb25hbCBHcmFwaDwvdGl0bGU&amp;#43;CjxzY3JpcHQgc3JjPSJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvbnBtL2QzQDciPjwvc2NyaXB0Pgo8c3R5bGU&amp;#43;CmJvZHl7bWFyZ2luOjA7cGFkZGluZzoyMHB4O2ZvbnQtZmFtaWx5OidPcGVuIFNhbnMnLHNhbnMtc2VyaWY7YmFja2dyb3VuZDojZmZmfQojZ3JhcGh7d2lkdGg6MTAwJTtoZWlnaHQ6OTV2aDtib3JkZXI6MXB4IHNvbGlkICNEOEQ4RDg7YmFja2dyb3VuZDojRjVGNUY1fQoubm9kZXtjdXJzb3I6cG9pbnRlcjtzdHJva2U6I2ZmZjtzdHJva2Utd2lkdGg6MnB4fQoubm9kZS1zaXRle2ZpbGw6IzAwNjZBMX0KLm5vZGUtZmFjaWxpdHl7ZmlsbDojRTE1NTMyfQoubm9kZS1hcmVhe2ZpbGw6IzUwQjQ1MH0KLm5vZGUtcmVzb3VyY2V7ZmlsbDojM0NBQUYwfQoubm9kZS1mbG93e2ZpbGw6I0M4OUYyN30KLm5vZGUtc3RlcHtmaWxsOiM0MTQxNDF9Ci5saW5re3N0cm9rZTojQUFBQUFBO3N0cm9rZS1vcGFjaXR5OjAuNDtzdHJva2Utd2lkdGg6MS41cHg7ZmlsbDpub25lfQoubm9kZS1sYWJlbHtmb250LXNpemU6MTJweDtmaWxsOnJnYmEoMCwwLDAsMC45NSk7cG9pbnRlci1ldmVudHM6bm9uZTt0ZXh0LWFuY2hvcjptaWRkbGU7Zm9udC1mYW1pbHk6J09wZW4gU2Fucycsc2Fucy1zZXJpZn0KLmxlZ2VuZHtwb3NpdGlvbjphYnNvbHV0ZTt0b3A6MzBweDtyaWdodDozMHB4O2JhY2tncm91bmQ6cmdiYSgyNTUsMjU1LDI1NSwwLjk1KTtwYWRkaW5nOjE1cHg7Ym9yZGVyOjFweCBzb2xpZCAjRDhEOEQ4O2JvcmRlci1yYWRpdXM6NHB4O2ZvbnQtc2l6ZToxNHB4O21pbi13aWR0aDoyMDBweH0KLmxlZ2VuZC10aXRsZXtmb250LXNpemU6MTZweDtmb250LXdlaWdodDo2MDA7bWFyZ2luLWJvdHRvbToxMnB4O2NvbG9yOnJnYmEoMCwwLDAsMC45NSl9Ci5sZWdlbmQtaXRlbXtkaXNwbGF5OmZsZXg7YWxpZ24taXRlbXM6Y2VudGVyO21hcmdpbjo4cHggMDtjdXJzb3I6cG9pbnRlcjt1c2VyLXNlbGVjdDpub25lfQoubGVnZW5kLWl0ZW06aG92ZXJ7YmFja2dyb3VuZDojRjVGNUY1O3BhZGRpbmc6MnB4IDRweDttYXJnaW46OHB4IC00cHg7Ym9yZGVyLXJhZGl1czozcHh9Ci5sZWdlbmQtY2hlY2tib3h7d2lkdGg6MThweDtoZWlnaHQ6MThweDttYXJnaW4tcmlnaHQ6OHB4O2N1cnNvcjpwb2ludGVyfQoubGVnZW5kLWNvbG9ye3dpZHRoOjIwcHg7aGVpZ2h0OjIwcHg7bWFyZ2luLXJpZ2h0OjEwcHg7Ym9yZGVyLXJhZGl1czozcHh9Ci5sZWdlbmQtbGFiZWx7ZmxleDoxfQoubm9kZS1oaWRkZW57b3BhY2l0eTowLjF9Ci5saW5rLWhpZGRlbntvcGFjaXR5OjAuMDV9Cmgxe2ZvbnQtc2l6ZTozNnB4O2ZvbnQtd2VpZ2h0OjQwMDtjb2xvcjpyZ2JhKDAsMCwwLDAuOTUpO21hcmdpbjowIDAgMTBweCAwfQouY29udHJvbHN7bWFyZ2luLXRvcDoxNXB4O3BhZGRpbmctdG9wOjE1cHg7Ym9yZGVyLXRvcDoxcHggc29saWQgI0Q4RDhEOH0KLmNvbnRyb2wtYnRue2JhY2tncm91bmQ6IzAwNjZBMTtjb2xvcjojZmZmO2JvcmRlcjpub25lO3BhZGRpbmc6OHB4IDEycHg7Ym9yZGVyLXJhZGl1czo0cHg7Y3Vyc29yOnBvaW50ZXI7Zm9udC1zaXplOjEycHg7Zm9udC1mYW1pbHk6J09wZW4gU2Fucycsc2Fucy1zZXJpZjt3aWR0aDoxMDAlO21hcmdpbi10b3A6NXB4fQouY29udHJvbC1idG46aG92ZXJ7YmFja2dyb3VuZDojMDA0ZDdhfQo8L3N0eWxlPgo8L2hlYWQ&amp;#43;Cjxib2R5Pgo8aDE&amp;#43;RXVyb3BlLVdlc3QgU2l0ZSBSZWxhdGlvbmFsIEdyYXBoPC9oMT4KPGRpdiBjbGFzcz0ibGVnZW5kIj4KPGRpdiBjbGFzcz0ibGVnZW5kLXRpdGxlIj5GaWx0ZXIgYnkgVHlwZTwvZGl2Pgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9InNpdGUiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzAwNjZBMSI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPlNpdGU8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0iZmFjaWxpdHkiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6I0UxNTUzMiI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkZhY2lsaXR5PC9zcGFuPgo8L2xhYmVsPgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9ImFyZWEiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzUwQjQ1MCI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkFyZWE8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0icmVzb3VyY2UiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzNDQUFGMCI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPlJlc291cmNlPC9zcGFuPgo8L2xhYmVsPgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9ImZsb3ciIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6I0M4OUYyNyI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkZsb3c8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0ic3RlcCIgY2hlY2tlZD4KPGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojNDE0MTQxIj48L2Rpdj4KPHNwYW4gY2xhc3M9ImxlZ2VuZC1sYWJlbCI&amp;#43;U3RlcDwvc3Bhbj4KPC9sYWJlbD4KPGRpdiBjbGFzcz0iY29udHJvbHMiPgo8YnV0dG9uIGNsYXNzPSJjb250cm9sLWJ0biIgaWQ9InNlbGVjdEFsbCI&amp;#43;U2VsZWN0IEFsbDwvYnV0dG9uPgo8YnV0dG9uIGNsYXNzPSJjb250cm9sLWJ0biIgaWQ9ImRlc2VsZWN0QWxsIj5EZXNlbGVjdCBBbGw8L2J1dHRvbj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8c3ZnIGlkPSJncmFwaCI&amp;#43;PC9zdmc&amp;#43;CjxzY3JpcHQ&amp;#43;CihmdW5jdGlvbigpewpjb25zdCByYXdEYXRhPVt7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJCRU5EIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJCRU5EIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiQ09BVElORyIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX01ldGFsIFBsYXRlIiwiU3RlcF9OYW1lIjoiQ09BVElORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPSUwgQkVORCMwMSIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJDT0lMIEJFTkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJDT0lMIFRSRUFUTUVOVCIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJDT0lMIFRSRUFUTUVOVCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPTE9SSU5HIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQTEFURSBDT0xPUklORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPTVBSRVNTT1IgVFVCIEJSQVpFIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fQ29pbCAtIENvcHBlciIsIlN0ZXBfTmFtZSI6IkNPTVBSRVNTT1IgVFVCIEJSQVpFIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiQ09QUEVSIEJFTkQjMDEiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiQ09QUEVSIEJFTkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJET09SIFBSRVAiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6IkRPT1IgUFJFUCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkVTRCBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24iLCJTdGVwX05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGSUxURVIgUFJFUCIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0VsZWN0cmljIiwiU3RlcF9OYW1lIjoiRklMVEVSIFBSRVAifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGT0FNIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRm9hbSIsIlN0ZXBfTmFtZSI6IkZPQU0ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGT0FNIFBSRVAiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Gb2FtIiwiU3RlcF9OYW1lIjoiRk9BTSBQUkVQIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiRm9hbSBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24iLCJTdGVwX05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJHYWx2IFNoZWV0IEZlZWRlciMwMSIsIkZsb3dfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IklORE9PUiBCUkFaRSIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJJTkRPT1IgQlJBWkUifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJJbmsgRmVlZGVyIiwiRmxvd19OYW1lIjoiS2FuYmFuIEZhYnJpY2F0aW9uIiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZhYnJpY2F0aW9uIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiSW5rIEZlZWRlciBjb2xvciIsIkZsb3dfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6Ik1BSU4gQk9BUkQiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6Ik1BSU4gQk9BUkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJNZXRhbCBCdXJycyBGZWVkZXIjMDEiLCJGbG93X05hbWUiOiJGbG93IFdhc3RlZCBNYXRlcmlhbCIsIlN0ZXBfTmFtZSI6IlN0b3JlIFdhc3RlZCBNYXRlcmlhbCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6Ik9VVERPT1IgQlJBWkUiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiT1VURE9PUiBCUkFaRSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlBBSU5USU5HIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQTEFURSBQQUlOVElORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlBVTkNIIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQVU5DSCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlJld29yayBTdGF0aW9uIDEiLCJGbG93X05hbWUiOiJSV0tfTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJSd1BvbGlzaCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlJld29yayBTdGF0aW9uIDIiLCJGbG93X05hbWUiOiJSV0tfTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJSd0luc3BlY3Rpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJTVEFHSU5HIE1CIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRWxlY3RyaWMiLCJTdGVwX05hbWUiOiJTVEFHSU5HIE1CIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJEdXJhYmxlcyBGbG93IiwiU3RlcF9OYW1lIjoiS2FuYmFuIER1cmFibGVzIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiU1RBUlQgQ09JTCAtIENPUFBFUiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6IkJBWTAiLCJGbG93X05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlN0ZXBfTmFtZSI6IkJBU0UgQVNNIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMSIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiSEVBVCBNT0QgQ09NUCBUVUIifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkxIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJJTlNUQUxMIE9EIENPSUwgVFVCRVMifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkyIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJBUFBMWSBGT0FNIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiRklOQUwgV0lSSU5HIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiTklUUk9HRU4gRVZBQyBDSEFSR0UifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkzIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJJTlNUQUxMIENPVkVSUyBET09SUyBGQU5TIEFORCBST09GIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQ0xFQU5VUCBBTkQgSU5TUEVDVElPTiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiQ0xFQU5VUCBBTkQgSU5TUEVDVElPTiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6Ik9pbCBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJSVFUgUEFDS0lORyIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiUlRVIFBBQ0tJTkcifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJSVFUgUEFDS0lORyBmZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJTY3Jld3MgRmVlZGVyIiwiRmxvd19OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5In0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5In0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGaW5hbCBBc3NlbWJseSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRm9hbSIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGaW5hbCBBc3NlbWJseSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOm51bGwsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiRmluYWwgQXNzZW1ibHkgV2FpdCBmb3IgUGFja2luZyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJTSElQUElORyBXSCBJTkRVVEVDSCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiUlRVIFBhY2tpbmciLCJSZXNvdXJjZV9OYW1lIjoiUlRVIEZJTkFMIFBBQ0tBR0lORyIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiUlRVIEZJTkFMIFBBQ0tBR0lORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiUlRVIFBhY2tpbmciLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlN0ZXBfTmFtZSI6IlNISVBQSU5HIEZJTkFMIENVU1RPTUVSIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJXYXJlaG91c2UgSW5kdVRlY2giLCJBcmVhX05hbWUiOiJXQVJFSE9VU0UgSW5kdVRlY2giLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UiLCJTdGVwX05hbWUiOiJJbmNvbWluZyBJbmR1VGVjaCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiV0FSRUhPVVNFIEluZHVUZWNoIiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiU3RvcmUgSW5kdVRlY2ggV2FyZWhvdXNlIiwiU3RlcF9OYW1lIjoiUmVjZXB0aW9uIEluZHVUZWNoIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJXYXJlaG91c2UgSW5kdVRlY2giLCJBcmVhX05hbWUiOiJXQVJFSE9VU0UgSW5kdVRlY2giLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UiLCJTdGVwX05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UifV07Cgpjb25zdCBub2RlTWFwPW5ldyBNYXAoKTsKY29uc3QgZ3JhcGhMaW5rcz1bXTsKCmZ1bmN0aW9uIGdldE9yQ3JlYXRlTm9kZShpZCxsYWJlbCx0eXBlKXsKaWYoIW5vZGVNYXAuaGFzKGlkKSl7Cm5vZGVNYXAuc2V0KGlkLHtpZDppZCxsYWJlbDpsYWJlbCx0eXBlOnR5cGV9KTsKfQpyZXR1cm4gbm9kZU1hcC5nZXQoaWQpOwp9CgpyYXdEYXRhLmZvckVhY2gocm93PT57CmNvbnN0IHNpdGVOb2RlPWdldE9yQ3JlYXRlTm9kZShgc2l0ZV8ke3Jvdy5TaXRlX05hbWV9YCxyb3cuU2l0ZV9OYW1lLCdzaXRlJyk7CmNvbnN0IGZhY2lsaXR5Tm9kZT1nZXRPckNyZWF0ZU5vZGUoYGZhY2lsaXR5XyR7cm93LkZhY2lsaXR5X05hbWV9YCxyb3cuRmFjaWxpdHlfTmFtZSwnZmFjaWxpdHknKTsKY29uc3QgYXJlYU5vZGU9Z2V0T3JDcmVhdGVOb2RlKGBhcmVhXyR7cm93LkFyZWFfTmFtZX1gLHJvdy5BcmVhX05hbWUsJ2FyZWEnKTsKY29uc3QgZmxvd05vZGU9Z2V0T3JDcmVhdGVOb2RlKGBmbG93XyR7cm93LkZsb3dfTmFtZX1gLHJvdy5GbG93X05hbWUsJ2Zsb3cnKTsKY29uc3Qgc3RlcE5vZGU9Z2V0T3JDcmVhdGVOb2RlKGBzdGVwXyR7cm93LlN0ZXBfTmFtZX1gLHJvdy5TdGVwX05hbWUsJ3N0ZXAnKTsKCmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1zaXRlTm9kZS5pZCYmbC50YXJnZXQ9PT1mYWNpbGl0eU5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6c2l0ZU5vZGUuaWQsdGFyZ2V0OmZhY2lsaXR5Tm9kZS5pZH0pOwp9CmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1mYWNpbGl0eU5vZGUuaWQmJmwudGFyZ2V0PT09YXJlYU5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6ZmFjaWxpdHlOb2RlLmlkLHRhcmdldDphcmVhTm9kZS5pZH0pOwp9CmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1hcmVhTm9kZS5pZCYmbC50YXJnZXQ9PT1mbG93Tm9kZS5pZCkpewpncmFwaExpbmtzLnB1c2goe3NvdXJjZTphcmVhTm9kZS5pZCx0YXJnZXQ6Zmxvd05vZGUuaWR9KTsKfQppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09Zmxvd05vZGUuaWQmJmwudGFyZ2V0PT09c3RlcE5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6Zmxvd05vZGUuaWQsdGFyZ2V0OnN0ZXBOb2RlLmlkfSk7Cn0KCmlmKHJvdy5SZXNvdXJjZV9OYW1lKXsKY29uc3QgcmVzb3VyY2VOb2RlPWdldE9yQ3JlYXRlTm9kZShgcmVzb3VyY2VfJHtyb3cuUmVzb3VyY2VfTmFtZX1gLHJvdy5SZXNvdXJjZV9OYW1lLCdyZXNvdXJjZScpOwppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09YXJlYU5vZGUuaWQmJmwudGFyZ2V0PT09cmVzb3VyY2VOb2RlLmlkKSl7CmdyYXBoTGlua3MucHVzaCh7c291cmNlOmFyZWFOb2RlLmlkLHRhcmdldDpyZXNvdXJjZU5vZGUuaWR9KTsKfQppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09cmVzb3VyY2VOb2RlLmlkJiZsLnRhcmdldD09PXN0ZXBOb2RlLmlkKSl7CmdyYXBoTGlua3MucHVzaCh7c291cmNlOnJlc291cmNlTm9kZS5pZCx0YXJnZXQ6c3RlcE5vZGUuaWR9KTsKfQp9Cn0pOwoKY29uc3QgZ3JhcGhOb2Rlcz1BcnJheS5mcm9tKG5vZGVNYXAudmFsdWVzKCkpOwoKY29uc3Qgc3ZnRWxlbWVudD1kMy5zZWxlY3QoJyNncmFwaCcpOwpjb25zdCBjb250YWluZXJXaWR0aD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZ3JhcGgnKS5jbGllbnRXaWR0aDsKY29uc3QgY29udGFpbmVySGVpZ2h0PWRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdncmFwaCcpLmNsaWVudEhlaWdodDsKCnN2Z0VsZW1lbnQuYXR0cignd2lkdGgnLGNvbnRhaW5lcldpZHRoKS5hdHRyKCdoZWlnaHQnLGNvbnRhaW5lckhlaWdodCk7Cgpjb25zdCBtYWluR3JvdXA9c3ZnRWxlbWVudC5hcHBlbmQoJ2cnKTsKCmNvbnN0IHpvb21CZWhhdmlvcj1kMy56b29tKCkuc2NhbGVFeHRlbnQoWzAuMSw0XSkub24oJ3pvb20nLChldmVudCk9PnsKbWFpbkdyb3VwLmF0dHIoJ3RyYW5zZm9ybScsZXZlbnQudHJhbnNmb3JtKTsKfSk7CgpzdmdFbGVtZW50LmNhbGwoem9vbUJlaGF2aW9yKTsKCmNvbnN0IGZvcmNlU2ltdWxhdGlvbj1kMy5mb3JjZVNpbXVsYXRpb24oZ3JhcGhOb2RlcykKLmZvcmNlKCdsaW5rJyxkMy5mb3JjZUxpbmsoZ3JhcGhMaW5rcykuaWQoZD0&amp;#43;ZC5pZCkuZGlzdGFuY2UoMTAwKSkKLmZvcmNlKCdjaGFyZ2UnLGQzLmZvcmNlTWFueUJvZHkoKS5zdHJlbmd0aCgtNDAwKSkKLmZvcmNlKCdjZW50ZXInLGQzLmZvcmNlQ2VudGVyKGNvbnRhaW5lcldpZHRoLzIsY29udGFpbmVySGVpZ2h0LzIpKQouZm9yY2UoJ2NvbGxpc2lvbicsZDMuZm9yY2VDb2xsaWRlKCkucmFkaXVzKDMwKSk7Cgpjb25zdCBsaW5rRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ2xpbmUnKQouZGF0YShncmFwaExpbmtzKQouZW50ZXIoKS5hcHBlbmQoJ2xpbmUnKQouYXR0cignY2xhc3MnLCdsaW5rJyk7Cgpjb25zdCBub2RlRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ2NpcmNsZScpCi5kYXRhKGdyYXBoTm9kZXMpCi5lbnRlcigpLmFwcGVuZCgnY2lyY2xlJykKLmF0dHIoJ2NsYXNzJyxkPT5gbm9kZSBub2RlLSR7ZC50eXBlfWApCi5hdHRyKCdyJyxkPT5kLnR5cGU9PT0nc2l0ZSc/MjA6ZC50eXBlPT09J2ZhY2lsaXR5Jz8xNjpkLnR5cGU9PT0nYXJlYSc/MTQ6ZC50eXBlPT09J2Zsb3cnPzEyOmQudHlwZT09PSdyZXNvdXJjZSc/MTA6OCkKLmNhbGwoZDMuZHJhZygpCi5vbignc3RhcnQnLChldmVudCxkKT0&amp;#43;ewppZighZXZlbnQuYWN0aXZlKWZvcmNlU2ltdWxhdGlvbi5hbHBoYVRhcmdldCgwLjMpLnJlc3RhcnQoKTsKZC5meD1kLng7CmQuZnk9ZC55Owp9KQoub24oJ2RyYWcnLChldmVudCxkKT0&amp;#43;ewpkLmZ4PWV2ZW50Lng7CmQuZnk9ZXZlbnQueTsKfSkKLm9uKCdlbmQnLChldmVudCxkKT0&amp;#43;ewppZighZXZlbnQuYWN0aXZlKWZvcmNlU2ltdWxhdGlvbi5hbHBoYVRhcmdldCgwKTsKZC5meD1udWxsOwpkLmZ5PW51bGw7Cn0pKTsKCmNvbnN0IGxhYmVsRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ3RleHQnKQouZGF0YShncmFwaE5vZGVzKQouZW50ZXIoKS5hcHBlbmQoJ3RleHQnKQouYXR0cignY2xhc3MnLCdub2RlLWxhYmVsJykKLmF0dHIoJ2R5JyxkPT5kLnR5cGU9PT0nc2l0ZSc/Mjg6ZC50eXBlPT09J2ZhY2lsaXR5Jz8yNDpkLnR5cGU9PT0nYXJlYSc/MjI6ZC50eXBlPT09J2Zsb3cnPzIwOmQudHlwZT09PSdyZXNvdXJjZSc/MTg6MTYpCi50ZXh0KGQ9PmQubGFiZWwpOwoKZm9yY2VTaW11bGF0aW9uLm9uKCd0aWNrJywoKT0&amp;#43;ewpsaW5rRWxlbWVudHMKLmF0dHIoJ3gxJyxkPT5kLnNvdXJjZS54KQouYXR0cigneTEnLGQ9PmQuc291cmNlLnkpCi5hdHRyKCd4MicsZD0&amp;#43;ZC50YXJnZXQueCkKLmF0dHIoJ3kyJyxkPT5kLnRhcmdldC55KTsKCm5vZGVFbGVtZW50cwouYXR0cignY3gnLGQ9PmQueCkKLmF0dHIoJ2N5JyxkPT5kLnkpOwoKbGFiZWxFbGVtZW50cwouYXR0cigneCcsZD0&amp;#43;ZC54KQouYXR0cigneScsZD0&amp;#43;ZC55KTsKfSk7Cgpjb25zdCBhY3RpdmVUeXBlcz1uZXcgU2V0KFsnc2l0ZScsJ2ZhY2lsaXR5JywnYXJlYScsJ3Jlc291cmNlJywnZmxvdycsJ3N0ZXAnXSk7CgpmdW5jdGlvbiB1cGRhdGVWaXNpYmlsaXR5KCl7Cm5vZGVFbGVtZW50cy5jbGFzc2VkKCdub2RlLWhpZGRlbicsZD0&amp;#43;IWFjdGl2ZVR5cGVzLmhhcyhkLnR5cGUpKTsKbGFiZWxFbGVtZW50cy5jbGFzc2VkKCdub2RlLWhpZGRlbicsZD0&amp;#43;IWFjdGl2ZVR5cGVzLmhhcyhkLnR5cGUpKTsKbGlua0VsZW1lbnRzLmNsYXNzZWQoJ2xpbmstaGlkZGVuJyxkPT57CmNvbnN0IHNvdXJjZU5vZGU9Z3JhcGhOb2Rlcy5maW5kKG49Pm4uaWQ9PT1kLnNvdXJjZS5pZCk7CmNvbnN0IHRhcmdldE5vZGU9Z3JhcGhOb2Rlcy5maW5kKG49Pm4uaWQ9PT1kLnRhcmdldC5pZCk7CnJldHVybiAhYWN0aXZlVHlwZXMuaGFzKHNvdXJjZU5vZGUudHlwZSl8fCFhY3RpdmVUeXBlcy5oYXModGFyZ2V0Tm9kZS50eXBlKTsKfSk7Cn0KCmRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5sZWdlbmQtY2hlY2tib3gnKS5mb3JFYWNoKGNoZWNrYm94PT57CmNoZWNrYm94LmFkZEV2ZW50TGlzdGVuZXIoJ2NoYW5nZScsZnVuY3Rpb24oKXsKY29uc3Qgbm9kZVR5cGU9dGhpcy5nZXRBdHRyaWJ1dGUoJ2RhdGEtdHlwZScpOwppZih0aGlzLmNoZWNrZWQpewphY3RpdmVUeXBlcy5hZGQobm9kZVR5cGUpOwp9ZWxzZXsKYWN0aXZlVHlwZXMuZGVsZXRlKG5vZGVUeXBlKTsKfQp1cGRhdGVWaXNpYmlsaXR5KCk7Cn0pOwp9KTsKCmRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZWxlY3RBbGwnKS5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsKCk9PnsKZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmxlZ2VuZC1jaGVja2JveCcpLmZvckVhY2goY2I9PnsKY2IuY2hlY2tlZD10cnVlOwphY3RpdmVUeXBlcy5hZGQoY2IuZ2V0QXR0cmlidXRlKCdkYXRhLXR5cGUnKSk7Cn0pOwp1cGRhdGVWaXNpYmlsaXR5KCk7Cn0pOwoKZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2Rlc2VsZWN0QWxsJykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCgpPT57CmRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5sZWdlbmQtY2hlY2tib3gnKS5mb3JFYWNoKGNiPT57CmNiLmNoZWNrZWQ9ZmFsc2U7CmFjdGl2ZVR5cGVzLmRlbGV0ZShjYi5nZXRBdHRyaWJ1dGUoJ2RhdGEtdHlwZScpKTsKfSk7CnVwZGF0ZVZpc2liaWxpdHkoKTsKfSk7Cn0pKCk7Cjwvc2NyaXB0Pgo8L2JvZHk&amp;#43;CjwvaHRtbD4K" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;p&gt;One of the key aspects of this node diagram is that it goes &lt;strong&gt;beyond the ISA95 tree&lt;/strong&gt;. We see concepts that are not a hierarchical place, but a functional association.&lt;/p&gt;
&lt;p&gt;We map not only physical objects, but also our process in the form of flows and steps.&lt;/p&gt;
&lt;p&gt;A flow is a process sequence that defines how materials move through the manufacturing system, so they are vital in mapping how the materials are moving through the shopfloor. A step is an individual operation or stage within a flow. A step requires a service to be performed and a resource may provide multiple services.&lt;/p&gt;
&lt;p&gt;We now see that our shopfloor is actually much more dynamic than just &lt;code&gt;Enterprise, Site, Facility, Area, Resource&lt;/code&gt;. It actually has complex and deeply relational interactions between objects. The &lt;strong&gt;anchor&lt;/strong&gt; system of all those actions is the MES.&lt;/p&gt;
&lt;p&gt;If your system has no notion of these relations your &lt;strong&gt;AI strategy will fail&lt;/strong&gt;, because AI will have a &lt;strong&gt;monolithical and siloed vision&lt;/strong&gt; of what is happening in the shopfloor.&lt;/p&gt;
&lt;p&gt;It will miss all the relations in your shopfloor, from what recipe was used, raw materials, samplings, maintenance activities, non conformance, all that rich feature set that makes an MES and is the day to day life of your factory.&lt;/p&gt;
&lt;p&gt;It will also not know if what is seeing is a deviation or if it&amp;rsquo;s an expected action. The &lt;strong&gt;pure ISA95 based systems are reporting and monitoring&lt;/strong&gt;, but fundamentally they are &lt;strong&gt;not control systems&lt;/strong&gt;. They offer no explanation of why a particular recipe or raw material was used and if it should have been used.&lt;/p&gt;
&lt;p&gt;This means, your AI will always be working with polluted datasets.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;AI follows the age old adage in software engineering, &lt;strong&gt;garbage in; garbage out&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If its datasets have to be cleaned or parsed, in order for them to have meaning, you already lost.&lt;/p&gt;
&lt;p&gt;No one will scrape petabytes of data to make sure they are conformant.&lt;/p&gt;
&lt;p&gt;You will end up in the current industry 4.0 roadblock.&lt;/p&gt;
&lt;p&gt;Massive unstructured datasets, from machine logs, test reports, divergent systems and no knowledge.&lt;/p&gt;
&lt;p&gt;You will have a massive pile of worthless data. And the pile will keep adding up.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;You can go bottom up, your machines dictate your data structure. But all machines are different and behave in a different way with a different structure and every machine process and topology are completely different.&lt;/p&gt;
&lt;p&gt;You can go top down, your ERP gives all your semantic structure. But what do ERP&amp;rsquo;s know about shopfloor reality and constraints?&lt;/p&gt;
&lt;p&gt;You only have one viable choice, put your house in order with an MES and chose an MES that already gives you all the rest out of the box.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why the &lt;strong&gt;MES is your shopfloor anchor system&lt;/strong&gt;, because it&amp;rsquo;s what captures your shopfloor structure and logic.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260225-relationalmes/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260225-relationalmes/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>From Insight to Action: AI That Actually Does Something</title>
<link>https://j-roque.com/posts/20260218-frominsighttoaction/</link>
<pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260218-frominsighttoaction/</guid>
<description>From Insight to Action: AI That Actually Does Something</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260218-frominsighttoaction/featured.png" alt="From Insight to Action: AI That Actually Does Something" /&gt;&lt;/p&gt;&lt;p&gt;Dashboards are easy. Action is hard. This post explores how AI in manufacturing must go beyond monitoring and surface insights that lead to real, executable decisions.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We&amp;rsquo;ve all seen the promise: &lt;code&gt;collect data&lt;/code&gt;, &lt;code&gt;train models&lt;/code&gt;, &lt;code&gt;visualize results&lt;/code&gt;. The modern factory is drowning in dashboards. Every vendor promises &lt;em&gt;insights&lt;/em&gt; and &lt;em&gt;visibility&lt;/em&gt;. And yet, when something goes wrong, engineers still spend days or weeks reconstructing events, comparing materials, and searching for root causes.&lt;/p&gt;
&lt;p&gt;The problem isn&amp;rsquo;t a lack of data. The problem is the gap between knowing and doing.&lt;/p&gt;
&lt;p&gt;Manufacturing is fundamentally about action. Pressing buttons, moving materials, adjusting parameters, correcting defects.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Insight without action is entertainment.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;A factory, even of the future is not a passive experience, it&amp;rsquo;s a full on contact sport. Where hard decisions have to made, where we train, plan and execute. But where executing is king.&lt;/p&gt;
&lt;p&gt;The true value of any intelligent system in manufacturing is not what it sees, but what it enables you to do about it.&lt;/p&gt;
&lt;p&gt;This is the missing piece in most AI deployments: &lt;strong&gt;The Closed Loop&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Incomplete Loop
&lt;div id="the-incomplete-loop" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-incomplete-loop" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Most AI implementations in manufacturing stop at visibility. The typical flow, for event the most advanced applications, goes something like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Collect&lt;/strong&gt; data from sensors, machines, and processes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store&lt;/strong&gt; that data in a historian or data lake&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visualize&lt;/strong&gt; it in dashboards&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Analyze&lt;/strong&gt; patterns using statistical or ML methods&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;(Optional)&lt;/strong&gt; Generate alerts when thresholds are breached&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is valuable. But it&amp;rsquo;s incomplete.&lt;/p&gt;
&lt;p&gt;The operator sees an alert. Now what? They must:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Interpret the alert&lt;/li&gt;
&lt;li&gt;Gather additional context&lt;/li&gt;
&lt;li&gt;Investigate root cause&lt;/li&gt;
&lt;li&gt;Decide on corrective action&lt;/li&gt;
&lt;li&gt;Execute that action somewhere else (often manually)&lt;/li&gt;
&lt;li&gt;Document what was done&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these steps introduces delay, inconsistency, and the potential for human error. The system told them &lt;em&gt;what&lt;/em&gt; is wrong, but not &lt;em&gt;why&lt;/em&gt;, and certainly didn&amp;rsquo;t help them &lt;em&gt;act&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I am not even talking about the problems faced when we have multiple applications owners of different subsystems inside the factory and being monitored by totally different people.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Complete Loop
&lt;div id="the-complete-loop" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-complete-loop" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The intelligent factory doesn&amp;rsquo;t stop at alerts. It closes the loop:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Collect&lt;/strong&gt; vast amounts of structured data&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contextualize&lt;/strong&gt; that data with process, material, and equipment relationships&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable exploration&lt;/strong&gt; through natural language and AI-assisted queries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface insights&lt;/strong&gt; that explain not just &lt;em&gt;what&lt;/em&gt; happened, but &lt;em&gt;why&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Execute actions&lt;/strong&gt; directly in the control system&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is the difference between a dashboard and a control system. One shows you a problem. The other helps you solve it.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why Context Is Everything
&lt;div id="why-context-is-everything" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-context-is-everything" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Raw data is meaningless without context. A temperature reading of 73.22 tells you nothing. But &lt;code&gt;73.22 μm paint thickness on Lot-RTU-FRAME W, applied at PLATE PAINTING by Bob Operator using Gauge 0004&lt;/code&gt; tells you a story.&lt;/p&gt;
&lt;p&gt;This is where the MES becomes essential. An MES doesn&amp;rsquo;t just collect data. It already knows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which material is being processed&lt;/li&gt;
&lt;li&gt;At which step and resource&lt;/li&gt;
&lt;li&gt;By which operator&lt;/li&gt;
&lt;li&gt;With which equipment and parameters&lt;/li&gt;
&lt;li&gt;Against which specification&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When a problem occurs, the MES can correlate across all these dimensions instantly. The data isn&amp;rsquo;t a spreadsheet waiting to be analyzed. It&amp;rsquo;s a &lt;code&gt;knowledge graph waiting to be queried&lt;/code&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Conversational Exploration
&lt;div id="conversational-exploration" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#conversational-exploration" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Here is where modern AI changes the game.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;Generate a complete relational graph of Site Europe-West, with draggable nodes&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AI Generate a Relational Graph"
src="https://image.j-roque.com/posts/20260218-frominsighttoaction/Generate%20a%20complete%20relational%20graph%20of%20Site%20Europe-West.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Generated by AI:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CjxtZXRhIGNoYXJzZXQ9IlVURi04Ij4KPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAiPgo8dGl0bGU&amp;#43;RXVyb3BlLVdlc3QgUmVsYXRpb25hbCBHcmFwaDwvdGl0bGU&amp;#43;CjxzY3JpcHQgc3JjPSJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvbnBtL2QzQDciPjwvc2NyaXB0Pgo8c3R5bGU&amp;#43;CmJvZHl7bWFyZ2luOjA7cGFkZGluZzoyMHB4O2ZvbnQtZmFtaWx5OidPcGVuIFNhbnMnLHNhbnMtc2VyaWY7YmFja2dyb3VuZDojZmZmfQojZ3JhcGh7d2lkdGg6MTAwJTtoZWlnaHQ6OTV2aDtib3JkZXI6MXB4IHNvbGlkICNEOEQ4RDg7YmFja2dyb3VuZDojRjVGNUY1fQoubm9kZXtjdXJzb3I6cG9pbnRlcjtzdHJva2U6I2ZmZjtzdHJva2Utd2lkdGg6MnB4fQoubm9kZS1zaXRle2ZpbGw6IzAwNjZBMX0KLm5vZGUtZmFjaWxpdHl7ZmlsbDojRTE1NTMyfQoubm9kZS1hcmVhe2ZpbGw6IzUwQjQ1MH0KLm5vZGUtcmVzb3VyY2V7ZmlsbDojM0NBQUYwfQoubm9kZS1mbG93e2ZpbGw6I0M4OUYyN30KLm5vZGUtc3RlcHtmaWxsOiM0MTQxNDF9Ci5saW5re3N0cm9rZTojQUFBQUFBO3N0cm9rZS1vcGFjaXR5OjAuNDtzdHJva2Utd2lkdGg6MS41cHg7ZmlsbDpub25lfQoubm9kZS1sYWJlbHtmb250LXNpemU6MTJweDtmaWxsOnJnYmEoMCwwLDAsMC45NSk7cG9pbnRlci1ldmVudHM6bm9uZTt0ZXh0LWFuY2hvcjptaWRkbGU7Zm9udC1mYW1pbHk6J09wZW4gU2Fucycsc2Fucy1zZXJpZn0KLmxlZ2VuZHtwb3NpdGlvbjphYnNvbHV0ZTt0b3A6MzBweDtyaWdodDozMHB4O2JhY2tncm91bmQ6cmdiYSgyNTUsMjU1LDI1NSwwLjk1KTtwYWRkaW5nOjE1cHg7Ym9yZGVyOjFweCBzb2xpZCAjRDhEOEQ4O2JvcmRlci1yYWRpdXM6NHB4O2ZvbnQtc2l6ZToxNHB4O21pbi13aWR0aDoyMDBweH0KLmxlZ2VuZC10aXRsZXtmb250LXNpemU6MTZweDtmb250LXdlaWdodDo2MDA7bWFyZ2luLWJvdHRvbToxMnB4O2NvbG9yOnJnYmEoMCwwLDAsMC45NSl9Ci5sZWdlbmQtaXRlbXtkaXNwbGF5OmZsZXg7YWxpZ24taXRlbXM6Y2VudGVyO21hcmdpbjo4cHggMDtjdXJzb3I6cG9pbnRlcjt1c2VyLXNlbGVjdDpub25lfQoubGVnZW5kLWl0ZW06aG92ZXJ7YmFja2dyb3VuZDojRjVGNUY1O3BhZGRpbmc6MnB4IDRweDttYXJnaW46OHB4IC00cHg7Ym9yZGVyLXJhZGl1czozcHh9Ci5sZWdlbmQtY2hlY2tib3h7d2lkdGg6MThweDtoZWlnaHQ6MThweDttYXJnaW4tcmlnaHQ6OHB4O2N1cnNvcjpwb2ludGVyfQoubGVnZW5kLWNvbG9ye3dpZHRoOjIwcHg7aGVpZ2h0OjIwcHg7bWFyZ2luLXJpZ2h0OjEwcHg7Ym9yZGVyLXJhZGl1czozcHh9Ci5sZWdlbmQtbGFiZWx7ZmxleDoxfQoubm9kZS1oaWRkZW57b3BhY2l0eTowLjF9Ci5saW5rLWhpZGRlbntvcGFjaXR5OjAuMDV9Cmgxe2ZvbnQtc2l6ZTozNnB4O2ZvbnQtd2VpZ2h0OjQwMDtjb2xvcjpyZ2JhKDAsMCwwLDAuOTUpO21hcmdpbjowIDAgMTBweCAwfQouY29udHJvbHN7bWFyZ2luLXRvcDoxNXB4O3BhZGRpbmctdG9wOjE1cHg7Ym9yZGVyLXRvcDoxcHggc29saWQgI0Q4RDhEOH0KLmNvbnRyb2wtYnRue2JhY2tncm91bmQ6IzAwNjZBMTtjb2xvcjojZmZmO2JvcmRlcjpub25lO3BhZGRpbmc6OHB4IDEycHg7Ym9yZGVyLXJhZGl1czo0cHg7Y3Vyc29yOnBvaW50ZXI7Zm9udC1zaXplOjEycHg7Zm9udC1mYW1pbHk6J09wZW4gU2Fucycsc2Fucy1zZXJpZjt3aWR0aDoxMDAlO21hcmdpbi10b3A6NXB4fQouY29udHJvbC1idG46aG92ZXJ7YmFja2dyb3VuZDojMDA0ZDdhfQo8L3N0eWxlPgo8L2hlYWQ&amp;#43;Cjxib2R5Pgo8aDE&amp;#43;RXVyb3BlLVdlc3QgU2l0ZSBSZWxhdGlvbmFsIEdyYXBoPC9oMT4KPGRpdiBjbGFzcz0ibGVnZW5kIj4KPGRpdiBjbGFzcz0ibGVnZW5kLXRpdGxlIj5GaWx0ZXIgYnkgVHlwZTwvZGl2Pgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9InNpdGUiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzAwNjZBMSI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPlNpdGU8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0iZmFjaWxpdHkiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6I0UxNTUzMiI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkZhY2lsaXR5PC9zcGFuPgo8L2xhYmVsPgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9ImFyZWEiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzUwQjQ1MCI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkFyZWE8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0icmVzb3VyY2UiIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6IzNDQUFGMCI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPlJlc291cmNlPC9zcGFuPgo8L2xhYmVsPgo8bGFiZWwgY2xhc3M9ImxlZ2VuZC1pdGVtIj4KPGlucHV0IHR5cGU9ImNoZWNrYm94IiBjbGFzcz0ibGVnZW5kLWNoZWNrYm94IiBkYXRhLXR5cGU9ImZsb3ciIGNoZWNrZWQ&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6I0M4OUYyNyI&amp;#43;PC9kaXY&amp;#43;CjxzcGFuIGNsYXNzPSJsZWdlbmQtbGFiZWwiPkZsb3c8L3NwYW4&amp;#43;CjwvbGFiZWw&amp;#43;CjxsYWJlbCBjbGFzcz0ibGVnZW5kLWl0ZW0iPgo8aW5wdXQgdHlwZT0iY2hlY2tib3giIGNsYXNzPSJsZWdlbmQtY2hlY2tib3giIGRhdGEtdHlwZT0ic3RlcCIgY2hlY2tlZD4KPGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojNDE0MTQxIj48L2Rpdj4KPHNwYW4gY2xhc3M9ImxlZ2VuZC1sYWJlbCI&amp;#43;U3RlcDwvc3Bhbj4KPC9sYWJlbD4KPGRpdiBjbGFzcz0iY29udHJvbHMiPgo8YnV0dG9uIGNsYXNzPSJjb250cm9sLWJ0biIgaWQ9InNlbGVjdEFsbCI&amp;#43;U2VsZWN0IEFsbDwvYnV0dG9uPgo8YnV0dG9uIGNsYXNzPSJjb250cm9sLWJ0biIgaWQ9ImRlc2VsZWN0QWxsIj5EZXNlbGVjdCBBbGw8L2J1dHRvbj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8c3ZnIGlkPSJncmFwaCI&amp;#43;PC9zdmc&amp;#43;CjxzY3JpcHQ&amp;#43;CihmdW5jdGlvbigpewpjb25zdCByYXdEYXRhPVt7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJCRU5EIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJCRU5EIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiQ09BVElORyIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX01ldGFsIFBsYXRlIiwiU3RlcF9OYW1lIjoiQ09BVElORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPSUwgQkVORCMwMSIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJDT0lMIEJFTkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJDT0lMIFRSRUFUTUVOVCIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJDT0lMIFRSRUFUTUVOVCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPTE9SSU5HIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQTEFURSBDT0xPUklORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkNPTVBSRVNTT1IgVFVCIEJSQVpFIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fQ29pbCAtIENvcHBlciIsIlN0ZXBfTmFtZSI6IkNPTVBSRVNTT1IgVFVCIEJSQVpFIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiQ09QUEVSIEJFTkQjMDEiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiQ09QUEVSIEJFTkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJET09SIFBSRVAiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6IkRPT1IgUFJFUCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IkVTRCBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24iLCJTdGVwX05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGSUxURVIgUFJFUCIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0VsZWN0cmljIiwiU3RlcF9OYW1lIjoiRklMVEVSIFBSRVAifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGT0FNIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRm9hbSIsIlN0ZXBfTmFtZSI6IkZPQU0ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJGT0FNIFBSRVAiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Gb2FtIiwiU3RlcF9OYW1lIjoiRk9BTSBQUkVQIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiRm9hbSBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24iLCJTdGVwX05hbWUiOiJLYW5iYW4gRmFicmljYXRpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJHYWx2IFNoZWV0IEZlZWRlciMwMSIsIkZsb3dfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IklORE9PUiBCUkFaRSIsIkZsb3dfTmFtZSI6IkZhYnJpY2F0aW9uX0NvaWwgLSBDb3BwZXIiLCJTdGVwX05hbWUiOiJJTkRPT1IgQlJBWkUifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJJbmsgRmVlZGVyIiwiRmxvd19OYW1lIjoiS2FuYmFuIEZhYnJpY2F0aW9uIiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZhYnJpY2F0aW9uIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjoiSW5rIEZlZWRlciBjb2xvciIsIkZsb3dfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGYWJyaWNhdGlvbiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6Ik1BSU4gQk9BUkQiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6Ik1BSU4gQk9BUkQifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJNZXRhbCBCdXJycyBGZWVkZXIjMDEiLCJGbG93X05hbWUiOiJGbG93IFdhc3RlZCBNYXRlcmlhbCIsIlN0ZXBfTmFtZSI6IlN0b3JlIFdhc3RlZCBNYXRlcmlhbCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6Ik9VVERPT1IgQlJBWkUiLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiT1VURE9PUiBCUkFaRSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlBBSU5USU5HIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQTEFURSBQQUlOVElORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlBVTkNIIzAxIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJQVU5DSCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlJld29yayBTdGF0aW9uIDEiLCJGbG93X05hbWUiOiJSV0tfTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJSd1BvbGlzaCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZhYnJpY2F0aW9uIiwiUmVzb3VyY2VfTmFtZSI6IlJld29yayBTdGF0aW9uIDIiLCJGbG93X05hbWUiOiJSV0tfTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJSd0luc3BlY3Rpb24ifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGYWJyaWNhdGlvbiIsIlJlc291cmNlX05hbWUiOiJTVEFHSU5HIE1CIiwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRWxlY3RyaWMiLCJTdGVwX05hbWUiOiJTVEFHSU5HIE1CIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJEdXJhYmxlcyBGbG93IiwiU3RlcF9OYW1lIjoiS2FuYmFuIER1cmFibGVzIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmFicmljYXRpb24iLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiU1RBUlQgQ09JTCAtIENPUFBFUiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6IkJBWTAiLCJGbG93X05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlN0ZXBfTmFtZSI6IkJBU0UgQVNNIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMSIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiSEVBVCBNT0QgQ09NUCBUVUIifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkxIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJJTlNUQUxMIE9EIENPSUwgVFVCRVMifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkyIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJBUFBMWSBGT0FNIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiRklOQUwgV0lSSU5HIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQkFZMiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiTklUUk9HRU4gRVZBQyBDSEFSR0UifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJCQVkzIiwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJJTlNUQUxMIENPVkVSUyBET09SUyBGQU5TIEFORCBST09GIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjoiQ0xFQU5VUCBBTkQgSU5TUEVDVElPTiIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiQ0xFQU5VUCBBTkQgSU5TUEVDVElPTiJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6Ik9pbCBGZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJSVFUgUEFDS0lORyIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiUlRVIFBBQ0tJTkcifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJSVFUgUEFDS0lORyBmZWVkZXIiLCJGbG93X05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOiJTY3Jld3MgRmVlZGVyIiwiRmxvd19OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5In0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9Db2lsIC0gQ29wcGVyIiwiU3RlcF9OYW1lIjoiS2FuYmFuIEZpbmFsIEFzc2VtYmx5In0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJQcm9kdWN0aW9uIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGYWJyaWNhdGlvbl9FbGVjdHJpYyIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGaW5hbCBBc3NlbWJseSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fRm9hbSIsIlN0ZXBfTmFtZSI6IkthbmJhbiBGaW5hbCBBc3NlbWJseSJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmFicmljYXRpb25fTWV0YWwgUGxhdGUiLCJTdGVwX05hbWUiOiJLYW5iYW4gRmluYWwgQXNzZW1ibHkifSx7IlNpdGVfTmFtZSI6IkV1cm9wZS1XZXN0IiwiRmFjaWxpdHlfTmFtZSI6IlByb2R1Y3Rpb24gSW5kdVRlY2giLCJBcmVhX05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlJlc291cmNlX05hbWUiOm51bGwsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiRmluYWwgQXNzZW1ibHkgV2FpdCBmb3IgUGFja2luZyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiUHJvZHVjdGlvbiBJbmR1VGVjaCIsIkFyZWFfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiRmluYWwgQXNzZW1ibHkiLCJTdGVwX05hbWUiOiJTSElQUElORyBXSCBJTkRVVEVDSCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiUlRVIFBhY2tpbmciLCJSZXNvdXJjZV9OYW1lIjoiUlRVIEZJTkFMIFBBQ0tBR0lORyIsIkZsb3dfTmFtZSI6IkZpbmFsIEFzc2VtYmx5IiwiU3RlcF9OYW1lIjoiUlRVIEZJTkFMIFBBQ0tBR0lORyJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiUlRVIFBhY2tpbmciLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJGaW5hbCBBc3NlbWJseSIsIlN0ZXBfTmFtZSI6IlNISVBQSU5HIEZJTkFMIENVU1RPTUVSIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJXYXJlaG91c2UgSW5kdVRlY2giLCJBcmVhX05hbWUiOiJXQVJFSE9VU0UgSW5kdVRlY2giLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UiLCJTdGVwX05hbWUiOiJJbmNvbWluZyBJbmR1VGVjaCJ9LHsiU2l0ZV9OYW1lIjoiRXVyb3BlLVdlc3QiLCJGYWNpbGl0eV9OYW1lIjoiV2FyZWhvdXNlIEluZHVUZWNoIiwiQXJlYV9OYW1lIjoiV0FSRUhPVVNFIEluZHVUZWNoIiwiUmVzb3VyY2VfTmFtZSI6bnVsbCwiRmxvd19OYW1lIjoiU3RvcmUgSW5kdVRlY2ggV2FyZWhvdXNlIiwiU3RlcF9OYW1lIjoiUmVjZXB0aW9uIEluZHVUZWNoIn0seyJTaXRlX05hbWUiOiJFdXJvcGUtV2VzdCIsIkZhY2lsaXR5X05hbWUiOiJXYXJlaG91c2UgSW5kdVRlY2giLCJBcmVhX05hbWUiOiJXQVJFSE9VU0UgSW5kdVRlY2giLCJSZXNvdXJjZV9OYW1lIjpudWxsLCJGbG93X05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UiLCJTdGVwX05hbWUiOiJTdG9yZSBJbmR1VGVjaCBXYXJlaG91c2UifV07Cgpjb25zdCBub2RlTWFwPW5ldyBNYXAoKTsKY29uc3QgZ3JhcGhMaW5rcz1bXTsKCmZ1bmN0aW9uIGdldE9yQ3JlYXRlTm9kZShpZCxsYWJlbCx0eXBlKXsKaWYoIW5vZGVNYXAuaGFzKGlkKSl7Cm5vZGVNYXAuc2V0KGlkLHtpZDppZCxsYWJlbDpsYWJlbCx0eXBlOnR5cGV9KTsKfQpyZXR1cm4gbm9kZU1hcC5nZXQoaWQpOwp9CgpyYXdEYXRhLmZvckVhY2gocm93PT57CmNvbnN0IHNpdGVOb2RlPWdldE9yQ3JlYXRlTm9kZShgc2l0ZV8ke3Jvdy5TaXRlX05hbWV9YCxyb3cuU2l0ZV9OYW1lLCdzaXRlJyk7CmNvbnN0IGZhY2lsaXR5Tm9kZT1nZXRPckNyZWF0ZU5vZGUoYGZhY2lsaXR5XyR7cm93LkZhY2lsaXR5X05hbWV9YCxyb3cuRmFjaWxpdHlfTmFtZSwnZmFjaWxpdHknKTsKY29uc3QgYXJlYU5vZGU9Z2V0T3JDcmVhdGVOb2RlKGBhcmVhXyR7cm93LkFyZWFfTmFtZX1gLHJvdy5BcmVhX05hbWUsJ2FyZWEnKTsKY29uc3QgZmxvd05vZGU9Z2V0T3JDcmVhdGVOb2RlKGBmbG93XyR7cm93LkZsb3dfTmFtZX1gLHJvdy5GbG93X05hbWUsJ2Zsb3cnKTsKY29uc3Qgc3RlcE5vZGU9Z2V0T3JDcmVhdGVOb2RlKGBzdGVwXyR7cm93LlN0ZXBfTmFtZX1gLHJvdy5TdGVwX05hbWUsJ3N0ZXAnKTsKCmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1zaXRlTm9kZS5pZCYmbC50YXJnZXQ9PT1mYWNpbGl0eU5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6c2l0ZU5vZGUuaWQsdGFyZ2V0OmZhY2lsaXR5Tm9kZS5pZH0pOwp9CmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1mYWNpbGl0eU5vZGUuaWQmJmwudGFyZ2V0PT09YXJlYU5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6ZmFjaWxpdHlOb2RlLmlkLHRhcmdldDphcmVhTm9kZS5pZH0pOwp9CmlmKCFncmFwaExpbmtzLmZpbmQobD0&amp;#43;bC5zb3VyY2U9PT1hcmVhTm9kZS5pZCYmbC50YXJnZXQ9PT1mbG93Tm9kZS5pZCkpewpncmFwaExpbmtzLnB1c2goe3NvdXJjZTphcmVhTm9kZS5pZCx0YXJnZXQ6Zmxvd05vZGUuaWR9KTsKfQppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09Zmxvd05vZGUuaWQmJmwudGFyZ2V0PT09c3RlcE5vZGUuaWQpKXsKZ3JhcGhMaW5rcy5wdXNoKHtzb3VyY2U6Zmxvd05vZGUuaWQsdGFyZ2V0OnN0ZXBOb2RlLmlkfSk7Cn0KCmlmKHJvdy5SZXNvdXJjZV9OYW1lKXsKY29uc3QgcmVzb3VyY2VOb2RlPWdldE9yQ3JlYXRlTm9kZShgcmVzb3VyY2VfJHtyb3cuUmVzb3VyY2VfTmFtZX1gLHJvdy5SZXNvdXJjZV9OYW1lLCdyZXNvdXJjZScpOwppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09YXJlYU5vZGUuaWQmJmwudGFyZ2V0PT09cmVzb3VyY2VOb2RlLmlkKSl7CmdyYXBoTGlua3MucHVzaCh7c291cmNlOmFyZWFOb2RlLmlkLHRhcmdldDpyZXNvdXJjZU5vZGUuaWR9KTsKfQppZighZ3JhcGhMaW5rcy5maW5kKGw9Pmwuc291cmNlPT09cmVzb3VyY2VOb2RlLmlkJiZsLnRhcmdldD09PXN0ZXBOb2RlLmlkKSl7CmdyYXBoTGlua3MucHVzaCh7c291cmNlOnJlc291cmNlTm9kZS5pZCx0YXJnZXQ6c3RlcE5vZGUuaWR9KTsKfQp9Cn0pOwoKY29uc3QgZ3JhcGhOb2Rlcz1BcnJheS5mcm9tKG5vZGVNYXAudmFsdWVzKCkpOwoKY29uc3Qgc3ZnRWxlbWVudD1kMy5zZWxlY3QoJyNncmFwaCcpOwpjb25zdCBjb250YWluZXJXaWR0aD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZ3JhcGgnKS5jbGllbnRXaWR0aDsKY29uc3QgY29udGFpbmVySGVpZ2h0PWRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdncmFwaCcpLmNsaWVudEhlaWdodDsKCnN2Z0VsZW1lbnQuYXR0cignd2lkdGgnLGNvbnRhaW5lcldpZHRoKS5hdHRyKCdoZWlnaHQnLGNvbnRhaW5lckhlaWdodCk7Cgpjb25zdCBtYWluR3JvdXA9c3ZnRWxlbWVudC5hcHBlbmQoJ2cnKTsKCmNvbnN0IHpvb21CZWhhdmlvcj1kMy56b29tKCkuc2NhbGVFeHRlbnQoWzAuMSw0XSkub24oJ3pvb20nLChldmVudCk9PnsKbWFpbkdyb3VwLmF0dHIoJ3RyYW5zZm9ybScsZXZlbnQudHJhbnNmb3JtKTsKfSk7CgpzdmdFbGVtZW50LmNhbGwoem9vbUJlaGF2aW9yKTsKCmNvbnN0IGZvcmNlU2ltdWxhdGlvbj1kMy5mb3JjZVNpbXVsYXRpb24oZ3JhcGhOb2RlcykKLmZvcmNlKCdsaW5rJyxkMy5mb3JjZUxpbmsoZ3JhcGhMaW5rcykuaWQoZD0&amp;#43;ZC5pZCkuZGlzdGFuY2UoMTAwKSkKLmZvcmNlKCdjaGFyZ2UnLGQzLmZvcmNlTWFueUJvZHkoKS5zdHJlbmd0aCgtNDAwKSkKLmZvcmNlKCdjZW50ZXInLGQzLmZvcmNlQ2VudGVyKGNvbnRhaW5lcldpZHRoLzIsY29udGFpbmVySGVpZ2h0LzIpKQouZm9yY2UoJ2NvbGxpc2lvbicsZDMuZm9yY2VDb2xsaWRlKCkucmFkaXVzKDMwKSk7Cgpjb25zdCBsaW5rRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ2xpbmUnKQouZGF0YShncmFwaExpbmtzKQouZW50ZXIoKS5hcHBlbmQoJ2xpbmUnKQouYXR0cignY2xhc3MnLCdsaW5rJyk7Cgpjb25zdCBub2RlRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ2NpcmNsZScpCi5kYXRhKGdyYXBoTm9kZXMpCi5lbnRlcigpLmFwcGVuZCgnY2lyY2xlJykKLmF0dHIoJ2NsYXNzJyxkPT5gbm9kZSBub2RlLSR7ZC50eXBlfWApCi5hdHRyKCdyJyxkPT5kLnR5cGU9PT0nc2l0ZSc/MjA6ZC50eXBlPT09J2ZhY2lsaXR5Jz8xNjpkLnR5cGU9PT0nYXJlYSc/MTQ6ZC50eXBlPT09J2Zsb3cnPzEyOmQudHlwZT09PSdyZXNvdXJjZSc/MTA6OCkKLmNhbGwoZDMuZHJhZygpCi5vbignc3RhcnQnLChldmVudCxkKT0&amp;#43;ewppZighZXZlbnQuYWN0aXZlKWZvcmNlU2ltdWxhdGlvbi5hbHBoYVRhcmdldCgwLjMpLnJlc3RhcnQoKTsKZC5meD1kLng7CmQuZnk9ZC55Owp9KQoub24oJ2RyYWcnLChldmVudCxkKT0&amp;#43;ewpkLmZ4PWV2ZW50Lng7CmQuZnk9ZXZlbnQueTsKfSkKLm9uKCdlbmQnLChldmVudCxkKT0&amp;#43;ewppZighZXZlbnQuYWN0aXZlKWZvcmNlU2ltdWxhdGlvbi5hbHBoYVRhcmdldCgwKTsKZC5meD1udWxsOwpkLmZ5PW51bGw7Cn0pKTsKCmNvbnN0IGxhYmVsRWxlbWVudHM9bWFpbkdyb3VwLmFwcGVuZCgnZycpCi5zZWxlY3RBbGwoJ3RleHQnKQouZGF0YShncmFwaE5vZGVzKQouZW50ZXIoKS5hcHBlbmQoJ3RleHQnKQouYXR0cignY2xhc3MnLCdub2RlLWxhYmVsJykKLmF0dHIoJ2R5JyxkPT5kLnR5cGU9PT0nc2l0ZSc/Mjg6ZC50eXBlPT09J2ZhY2lsaXR5Jz8yNDpkLnR5cGU9PT0nYXJlYSc/MjI6ZC50eXBlPT09J2Zsb3cnPzIwOmQudHlwZT09PSdyZXNvdXJjZSc/MTg6MTYpCi50ZXh0KGQ9PmQubGFiZWwpOwoKZm9yY2VTaW11bGF0aW9uLm9uKCd0aWNrJywoKT0&amp;#43;ewpsaW5rRWxlbWVudHMKLmF0dHIoJ3gxJyxkPT5kLnNvdXJjZS54KQouYXR0cigneTEnLGQ9PmQuc291cmNlLnkpCi5hdHRyKCd4MicsZD0&amp;#43;ZC50YXJnZXQueCkKLmF0dHIoJ3kyJyxkPT5kLnRhcmdldC55KTsKCm5vZGVFbGVtZW50cwouYXR0cignY3gnLGQ9PmQueCkKLmF0dHIoJ2N5JyxkPT5kLnkpOwoKbGFiZWxFbGVtZW50cwouYXR0cigneCcsZD0&amp;#43;ZC54KQouYXR0cigneScsZD0&amp;#43;ZC55KTsKfSk7Cgpjb25zdCBhY3RpdmVUeXBlcz1uZXcgU2V0KFsnc2l0ZScsJ2ZhY2lsaXR5JywnYXJlYScsJ3Jlc291cmNlJywnZmxvdycsJ3N0ZXAnXSk7CgpmdW5jdGlvbiB1cGRhdGVWaXNpYmlsaXR5KCl7Cm5vZGVFbGVtZW50cy5jbGFzc2VkKCdub2RlLWhpZGRlbicsZD0&amp;#43;IWFjdGl2ZVR5cGVzLmhhcyhkLnR5cGUpKTsKbGFiZWxFbGVtZW50cy5jbGFzc2VkKCdub2RlLWhpZGRlbicsZD0&amp;#43;IWFjdGl2ZVR5cGVzLmhhcyhkLnR5cGUpKTsKbGlua0VsZW1lbnRzLmNsYXNzZWQoJ2xpbmstaGlkZGVuJyxkPT57CmNvbnN0IHNvdXJjZU5vZGU9Z3JhcGhOb2Rlcy5maW5kKG49Pm4uaWQ9PT1kLnNvdXJjZS5pZCk7CmNvbnN0IHRhcmdldE5vZGU9Z3JhcGhOb2Rlcy5maW5kKG49Pm4uaWQ9PT1kLnRhcmdldC5pZCk7CnJldHVybiAhYWN0aXZlVHlwZXMuaGFzKHNvdXJjZU5vZGUudHlwZSl8fCFhY3RpdmVUeXBlcy5oYXModGFyZ2V0Tm9kZS50eXBlKTsKfSk7Cn0KCmRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5sZWdlbmQtY2hlY2tib3gnKS5mb3JFYWNoKGNoZWNrYm94PT57CmNoZWNrYm94LmFkZEV2ZW50TGlzdGVuZXIoJ2NoYW5nZScsZnVuY3Rpb24oKXsKY29uc3Qgbm9kZVR5cGU9dGhpcy5nZXRBdHRyaWJ1dGUoJ2RhdGEtdHlwZScpOwppZih0aGlzLmNoZWNrZWQpewphY3RpdmVUeXBlcy5hZGQobm9kZVR5cGUpOwp9ZWxzZXsKYWN0aXZlVHlwZXMuZGVsZXRlKG5vZGVUeXBlKTsKfQp1cGRhdGVWaXNpYmlsaXR5KCk7Cn0pOwp9KTsKCmRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZWxlY3RBbGwnKS5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsKCk9PnsKZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmxlZ2VuZC1jaGVja2JveCcpLmZvckVhY2goY2I9PnsKY2IuY2hlY2tlZD10cnVlOwphY3RpdmVUeXBlcy5hZGQoY2IuZ2V0QXR0cmlidXRlKCdkYXRhLXR5cGUnKSk7Cn0pOwp1cGRhdGVWaXNpYmlsaXR5KCk7Cn0pOwoKZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2Rlc2VsZWN0QWxsJykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCgpPT57CmRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5sZWdlbmQtY2hlY2tib3gnKS5mb3JFYWNoKGNiPT57CmNiLmNoZWNrZWQ9ZmFsc2U7CmFjdGl2ZVR5cGVzLmRlbGV0ZShjYi5nZXRBdHRyaWJ1dGUoJ2RhdGEtdHlwZScpKTsKfSk7CnVwZGF0ZVZpc2liaWxpdHkoKTsKfSk7Cn0pKCk7Cjwvc2NyaXB0Pgo8L2JvZHk&amp;#43;CjwvaHRtbD4K" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;hr&gt;
&lt;p&gt;Without any knowledge of the system, you have an instant graph visualization of your system. You are able to explore critical chokepoints, critical connections in a complete bird&amp;rsquo;s eye view of your shopfloor.&lt;/p&gt;
&lt;p&gt;Instead of building predefined reports for every possible question, we can now ask questions in natural language and let the system explore the data on our behalf.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Happening Site Europe"
src="https://image.j-roque.com/posts/20260218-frominsighttoaction/I%20am%20currently%20at%20proveIt%20in%20the%20US%20what%27s%20happening%20with%20my%20Site%20in%20Europe.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;With just a simple question we can understand what is happening in remote geographies.&lt;/p&gt;
&lt;p&gt;Consider a real scenario. Quality protocols (or non-conformances) have been opened for several materials. A quality engineer wants to understand what&amp;rsquo;s happening. Instead of trying to correlate information between different systems and reports. They &lt;em&gt;just&lt;/em&gt; ask:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;Give me all the materials with quality protocols opened for them.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Materials With Protocols"
src="https://image.j-roque.com/posts/20260218-frominsighttoaction/Give%20me%20all%20the%20materials%20with%20quality%20protocols%20opened%20for%20them..png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The system responds with a structured answer: 12 materials, primarily at the PLATE PAINTING step, associated with specific employees and dates.&lt;/p&gt;
&lt;p&gt;Now they dig deeper:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;For the newest material with a protocol opened for it, do we have data collections? Show me a comparison to 3 materials without protocols for the same product. Include the instruments used and the employees.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Newest Material with a protocol"
src="https://image.j-roque.com/posts/20260218-frominsighttoaction/For%20the%20newest%20material%20with%20a%20protocol%20opened%20for%20it.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Generated by AI:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CjxtZXRhIGNoYXJzZXQ9IlVURi04Ij4KPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAiPgo8dGl0bGU&amp;#43;UGFpbnQgVGhpY2tuZXNzIENvbXBhcmlzb248L3RpdGxlPgo8c2NyaXB0IHNyYz0iaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9jaGFydC5qcyI&amp;#43;PC9zY3JpcHQ&amp;#43;CjxzdHlsZT4KYm9keXtmb250LWZhbWlseTpBcmlhbCxzYW5zLXNlcmlmO2JhY2tncm91bmQ6I2ZmZjtwYWRkaW5nOjIwcHg7bWFyZ2luOjB9Ci5jb250YWluZXJ7bWF4LXdpZHRoOjE0MDBweDttYXJnaW46MCBhdXRvfQpoMXtjb2xvcjojNDE0MTQxO2ZvbnQtc2l6ZToyNHB4O21hcmdpbi1ib3R0b206MTBweH0KLnN1bW1hcnl7YmFja2dyb3VuZDojRjVGNUY1O3BhZGRpbmc6MjBweDtib3JkZXItcmFkaXVzOjhweDttYXJnaW4tYm90dG9tOjMwcHh9Ci5zdW1tYXJ5IGgye2NvbG9yOiMwMDY2QTE7Zm9udC1zaXplOjE4cHg7bWFyZ2luOjAgMCAxNXB4IDB9Ci5pbmZvLWdyaWR7ZGlzcGxheTpncmlkO2dyaWQtdGVtcGxhdGUtY29sdW1uczpyZXBlYXQoYXV0by1maXQsbWlubWF4KDMwMHB4LDFmcikpO2dhcDoxNXB4O21hcmdpbi1ib3R0b206MjBweH0KLmluZm8tY2FyZHtiYWNrZ3JvdW5kOiNmZmY7cGFkZGluZzoxNXB4O2JvcmRlci1yYWRpdXM6NnB4O2JvcmRlci1sZWZ0OjRweCBzb2xpZCAjMDA2NkExfQouaW5mby1jYXJkIGgze21hcmdpbjowIDAgOHB4IDA7Zm9udC1zaXplOjE0cHg7Y29sb3I6IzhDOEM4Qztmb250LXdlaWdodDpub3JtYWx9Ci5pbmZvLWNhcmQgcHttYXJnaW46MDtmb250LXNpemU6MTZweDtjb2xvcjojNDE0MTQxO2ZvbnQtd2VpZ2h0OmJvbGR9Ci5jaGFydC1jb250YWluZXJ7YmFja2dyb3VuZDojZmZmO3BhZGRpbmc6MjBweDtib3JkZXItcmFkaXVzOjhweDtib3gtc2hhZG93OjAgMnB4IDRweCByZ2JhKDAsMCwwLDAuMSk7bWFyZ2luLWJvdHRvbTozMHB4fQouY2hhcnQtY29udGFpbmVyIGgze2NvbG9yOiM0MTQxNDE7Zm9udC1zaXplOjE2cHg7bWFyZ2luOjAgMCAyMHB4IDB9CmNhbnZhc3ttYXgtaGVpZ2h0OjQwMHB4fQoubGVnZW5ke2Rpc3BsYXk6ZmxleDtmbGV4LXdyYXA6d3JhcDtnYXA6MjBweDttYXJnaW4tdG9wOjE1cHg7Zm9udC1zaXplOjEzcHh9Ci5sZWdlbmQtaXRlbXtkaXNwbGF5OmZsZXg7YWxpZ24taXRlbXM6Y2VudGVyO2dhcDo4cHh9Ci5sZWdlbmQtY29sb3J7d2lkdGg6MTZweDtoZWlnaHQ6MTZweDtib3JkZXItcmFkaXVzOjNweH0KLm1hdGVyaWFsLWxhYmVse2ZvbnQtc2l6ZToxMXB4O2NvbG9yOiM4QzhDOEN9Cjwvc3R5bGU&amp;#43;CjwvaGVhZD4KPGJvZHk&amp;#43;CjxkaXYgY2xhc3M9ImNvbnRhaW5lciI&amp;#43;CjxoMT5QYWludCBUaGlja25lc3MgRGF0YSBDb2xsZWN0aW9uIEFuYWx5c2lzPC9oMT4KPGRpdiBjbGFzcz0ic3VtbWFyeSI&amp;#43;CjxoMj5NYXRlcmlhbCB3aXRoIFByb3RvY29sIChOZXdlc3QpPC9oMj4KPGRpdiBjbGFzcz0iaW5mby1ncmlkIj4KPGRpdiBjbGFzcz0iaW5mby1jYXJkIj4KPGgzPk1hdGVyaWFsIE5hbWU8L2gzPgo8cD5Mb3QtUlRVLUZSQU1FIFctMzEzZGI4ZDU8L3A&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJpbmZvLWNhcmQiPgo8aDM&amp;#43;UHJvZHVjdDwvaDM&amp;#43;CjxwPlJUVS1GUkFNRSBXPC9wPgo8L2Rpdj4KPGRpdiBjbGFzcz0iaW5mby1jYXJkIj4KPGgzPlByb3RvY29sIE9wZW5lZDwvaDM&amp;#43;CjxwPjIwMjYtMDItMTMgMTM6NTU6MjggVVRDPC9wPgo8L2Rpdj4KPGRpdiBjbGFzcz0iaW5mby1jYXJkIj4KPGgzPkluc3RydW1lbnQgVXNlZDwvaDM&amp;#43;CjxwPlBhaW50IFRoaWNrbmVzcyBHYXVnZSAwMDA0PC9wPgo8L2Rpdj4KPGRpdiBjbGFzcz0iaW5mby1jYXJkIj4KPGgzPkVtcGxveWVlPC9oMz4KPHA&amp;#43;Qm9iIE9wZXJhdG9yPC9wPgo8L2Rpdj4KPGRpdiBjbGFzcz0iaW5mby1jYXJkIj4KPGgzPlN0ZXA8L2gzPgo8cD5QTEFURSBQQUlOVElORzwvcD4KPC9kaXY&amp;#43;CjwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0iY2hhcnQtY29udGFpbmVyIj4KPGgzPkF2ZXJhZ2UgUGFpbnQgVGhpY2tuZXNzIENvbXBhcmlzb248L2gzPgo8Y2FudmFzIGlkPSJhdmdDaGFydCI&amp;#43;PC9jYW52YXM&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZCI&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1pdGVtIj48ZGl2IGNsYXNzPSJsZWdlbmQtY29sb3IiIHN0eWxlPSJiYWNrZ3JvdW5kOiNCODMxMjgiPjwvZGl2PjxzcGFuPldpdGggUHJvdG9jb2wgKExvdC0zMTNkYjhkNSk8L3NwYW4&amp;#43;PC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImxlZ2VuZC1pdGVtIj48ZGl2IGNsYXNzPSJsZWdlbmQtY29sb3IiIHN0eWxlPSJiYWNrZ3JvdW5kOiMwMDY2QTEiPjwvZGl2PjxzcGFuPk5vIFByb3RvY29sIChMb3QtMGI2YTlmMTMpPC9zcGFuPjwvZGl2Pgo8ZGl2IGNsYXNzPSJsZWdlbmQtaXRlbSI&amp;#43;PGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojM0NBQUYwIj48L2Rpdj48c3Bhbj5ObyBQcm90b2NvbCAoTG90LTgxYzEyNjdhKTwvc3Bhbj48L2Rpdj4KPGRpdiBjbGFzcz0ibGVnZW5kLWl0ZW0iPjxkaXYgY2xhc3M9ImxlZ2VuZC1jb2xvciIgc3R5bGU9ImJhY2tncm91bmQ6I0FBQUFBQSI&amp;#43;PC9kaXY&amp;#43;PHNwYW4&amp;#43;Tm8gUHJvdG9jb2wgKExvdC04MjliYWE5Zik8L3NwYW4&amp;#43;PC9kaXY&amp;#43;CjwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0iY2hhcnQtY29udGFpbmVyIj4KPGgzPkRldGFpbGVkIFBhaW50IFRoaWNrbmVzcyBieSBMb2NhdGlvbjwvaDM&amp;#43;CjxjYW52YXMgaWQ9ImRldGFpbENoYXJ0Ij48L2NhbnZhcz4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9InN1bW1hcnkiPgo8aDI&amp;#43;Q29tcGFyaXNvbiBNYXRlcmlhbHMgKE5vIFByb3RvY29sKTwvaDI&amp;#43;CjxkaXYgY2xhc3M9ImluZm8tZ3JpZCI&amp;#43;CjxkaXYgY2xhc3M9ImluZm8tY2FyZCI&amp;#43;CjxoMz5NYXRlcmlhbCAxPC9oMz4KPHA&amp;#43;TG90LVJUVS1GUkFNRSBXLTBiNmE5ZjEzPGJyPjxzcGFuIGNsYXNzPSJtYXRlcmlhbC1sYWJlbCI&amp;#43;SW5zdHJ1bWVudDogUGFpbnQgVGhpY2tuZXNzIEdhdWdlIDAwMDEgfCBFbXBsb3llZTogSm9obiBPcGVyYXRvcjwvc3Bhbj48L3A&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJpbmZvLWNhcmQiPgo8aDM&amp;#43;TWF0ZXJpYWwgMjwvaDM&amp;#43;CjxwPkxvdC1SVFUtRlJBTUUgVy04MWMxMjY3YTxicj48c3BhbiBjbGFzcz0ibWF0ZXJpYWwtbGFiZWwiPkluc3RydW1lbnQ6IFBhaW50IFRoaWNrbmVzcyBHYXVnZSAwMDAxIHwgRW1wbG95ZWU6IFRpbSBPcGVyYXRvcjwvc3Bhbj48L3A&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJpbmZvLWNhcmQiPgo8aDM&amp;#43;TWF0ZXJpYWwgMzwvaDM&amp;#43;CjxwPkxvdC1SVFUtRlJBTUUgVy04MjliYWE5Zjxicj48c3BhbiBjbGFzcz0ibWF0ZXJpYWwtbGFiZWwiPkluc3RydW1lbnQ6IFBhaW50IFRoaWNrbmVzcyBHYXVnZSAwMDAxIHwgRW1wbG95ZWU6IEpvaG4gT3BlcmF0b3I8L3NwYW4&amp;#43;PC9wPgo8L2Rpdj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8L2Rpdj4KPHNjcmlwdD4KKGZ1bmN0aW9uKCl7CmNvbnN0IGF2Z0RhdGE9e2xhYmVsczpbJ1Byb3RvY29sIE1hdGVyaWFsJywnTWF0ZXJpYWwgMSAoTm8gUHJvdG9jb2wpJywnTWF0ZXJpYWwgMiAoTm8gUHJvdG9jb2wpJywnTWF0ZXJpYWwgMyAoTm8gUHJvdG9jb2wpJ10sZGF0YXNldHM6W3tsYWJlbDonQXZlcmFnZSBQYWludCBUaGlja25lc3MnLGRhdGE6WzczLjIyLDExOS41OCwxMjEuODY4LDExOS40M10sYmFja2dyb3VuZENvbG9yOlsncmdiYSgxODQsNDksNDAsMC43KScsJ3JnYmEoMCwxMDIsMTYxLDAuNyknLCdyZ2JhKDYwLDE3MCwyNDAsMC43KScsJ3JnYmEoMTcwLDE3MCwxNzAsMC43KSddLGJvcmRlckNvbG9yOlsnI0I4MzEyOCcsJyMwMDY2QTEnLCcjM0NBQUYwJywnI0FBQUFBQSddLGJvcmRlcldpZHRoOjJ9XX07CmNvbnN0IGF2Z0N0eD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYXZnQ2hhcnQnKS5nZXRDb250ZXh0KCcyZCcpOwpuZXcgQ2hhcnQoYXZnQ3R4LHt0eXBlOidiYXInLGRhdGE6YXZnRGF0YSxvcHRpb25zOntyZXNwb25zaXZlOnRydWUsbWFpbnRhaW5Bc3BlY3RSYXRpbzp0cnVlLHBsdWdpbnM6e2xlZ2VuZDp7ZGlzcGxheTpmYWxzZX0sdG9vbHRpcDp7Y2FsbGJhY2tzOntsYWJlbDpmdW5jdGlvbihjb250ZXh0KXtyZXR1cm4gY29udGV4dC5wYXJzZWQueS50b0ZpeGVkKDIpKycgzrxtJ319fX0sc2NhbGVzOnt5OntiZWdpbkF0WmVybzp0cnVlLHRpdGxlOntkaXNwbGF5OnRydWUsdGV4dDonUGFpbnQgVGhpY2tuZXNzICjOvG0pJyxjb2xvcjonIzQxNDE0MSd9LGdyaWQ6e2NvbG9yOicjRDhEOEQ4Jyxib3JkZXJEYXNoOls1LDVdfX0seDp7Z3JpZDp7ZGlzcGxheTpmYWxzZX19fX19KTsKY29uc3QgZGV0YWlsRGF0YT17bGFiZWxzOlsnVG9wIEVkZ2UnLCdCb3R0b20gRWRnZScsJ0xlZnQgU2lkZScsJ1JpZ2h0IFNpZGUnLCdDZW50ZXInXSxkYXRhc2V0czpbe2xhYmVsOidXaXRoIFByb3RvY29sIChMb3QtMzEzZGI4ZDUpJyxkYXRhOls3NC44OSw3OS4yNSw3OC4zNSw2MC45MSw3Mi43XSxib3JkZXJDb2xvcjonI0I4MzEyOCcsYmFja2dyb3VuZENvbG9yOidyZ2JhKDE4NCw0OSw0MCwwLjEpJyxib3JkZXJXaWR0aDoyLHRlbnNpb246MC4zfSx7bGFiZWw6J05vIFByb3RvY29sIChMb3QtMGI2YTlmMTMpJyxkYXRhOlsxMjMuNDQsMTIxLjYsMTE1Ljc2LDExOS40MywxMTcuNjddLGJvcmRlckNvbG9yOicjMDA2NkExJyxiYWNrZ3JvdW5kQ29sb3I6J3JnYmEoMCwxMDIsMTYxLDAuMSknLGJvcmRlcldpZHRoOjIsdGVuc2lvbjowLjN9LHtsYWJlbDonTm8gUHJvdG9jb2wgKExvdC04MWMxMjY3YSknLGRhdGE6WzExNy45NSwxMjEuOTgsMTI0LjI0LDEyMy45MSwxMjEuMjZdLGJvcmRlckNvbG9yOicjM0NBQUYwJyxiYWNrZ3JvdW5kQ29sb3I6J3JnYmEoNjAsMTcwLDI0MCwwLjEpJyxib3JkZXJXaWR0aDoyLHRlbnNpb246MC4zfSx7bGFiZWw6J05vIFByb3RvY29sIChMb3QtODI5YmFhOWYpJyxkYXRhOlsxMTUuNSwxMTcuODgsMTIwLjgzLDExOS4yOSwxMjMuNjVdLGJvcmRlckNvbG9yOicjQUFBQUFBJyxiYWNrZ3JvdW5kQ29sb3I6J3JnYmEoMTcwLDE3MCwxNzAsMC4xKScsYm9yZGVyV2lkdGg6Mix0ZW5zaW9uOjAuM31dfTsKY29uc3QgZGV0YWlsQ3R4PWRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdkZXRhaWxDaGFydCcpLmdldENvbnRleHQoJzJkJyk7Cm5ldyBDaGFydChkZXRhaWxDdHgse3R5cGU6J2xpbmUnLGRhdGE6ZGV0YWlsRGF0YSxvcHRpb25zOntyZXNwb25zaXZlOnRydWUsbWFpbnRhaW5Bc3BlY3RSYXRpbzp0cnVlLHBsdWdpbnM6e2xlZ2VuZDp7ZGlzcGxheTp0cnVlLHBvc2l0aW9uOidib3R0b20nfSx0b29sdGlwOntjYWxsYmFja3M6e2xhYmVsOmZ1bmN0aW9uKGNvbnRleHQpe3JldHVybiBjb250ZXh0LmRhdGFzZXQubGFiZWwrJzogJytjb250ZXh0LnBhcnNlZC55LnRvRml4ZWQoMikrJyDOvG0nfX19fSxzY2FsZXM6e3k6e2JlZ2luQXRaZXJvOnRydWUsdGl0bGU6e2Rpc3BsYXk6dHJ1ZSx0ZXh0OidQYWludCBUaGlja25lc3MgKM68bSknLGNvbG9yOicjNDE0MTQxJ30sZ3JpZDp7Y29sb3I6JyNEOEQ4RDgnLGJvcmRlckRhc2g6WzUsNV19fSx4OntncmlkOntjb2xvcjonI0Q4RDhEOCcsYm9yZGVyRGFzaDpbNSw1XX19fX19KTsKfSkoKTsKPC9zY3JpcHQ&amp;#43;CjwvYm9keT4KPC9odG1sPgo=" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;hr&gt;
&lt;p&gt;The system correlates telemetry, quality results, and execution context. It returns:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Material&lt;/th&gt;
&lt;th&gt;Paint Thickness&lt;/th&gt;
&lt;th&gt;Instrument&lt;/th&gt;
&lt;th&gt;Employee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protocol Material&lt;/td&gt;
&lt;td&gt;73.22 μm&lt;/td&gt;
&lt;td&gt;Gauge 0004&lt;/td&gt;
&lt;td&gt;Bob Operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good Material 1&lt;/td&gt;
&lt;td&gt;119.58 μm&lt;/td&gt;
&lt;td&gt;Gauge 0001&lt;/td&gt;
&lt;td&gt;John Operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good Material 2&lt;/td&gt;
&lt;td&gt;121.87 μm&lt;/td&gt;
&lt;td&gt;Gauge 0001&lt;/td&gt;
&lt;td&gt;Tim Operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good Material 3&lt;/td&gt;
&lt;td&gt;119.43 μm&lt;/td&gt;
&lt;td&gt;Gauge 0001&lt;/td&gt;
&lt;td&gt;John Operator&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;He formulates a hypothesis. Well, Bob is the new guy and Gauge 0004 is one of the old gauges. Can this be why we&amp;rsquo;re having issues?&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;For the other recent quality protocols, who was the operator and what gauge did they use?&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Comparison protocol"
src="https://image.j-roque.com/posts/20260218-frominsighttoaction/Material%20Comparison.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Generated by AI:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CiAgICA8bWV0YSBjaGFyc2V0PSJVVEYtOCI&amp;#43;CiAgICA8bWV0YSBuYW1lPSJ2aWV3cG9ydCIgY29udGVudD0id2lkdGg9ZGV2aWNlLXdpZHRoLCBpbml0aWFsLXNjYWxlPTEuMCI&amp;#43;CiAgICA8dGl0bGU&amp;#43;UGFpbnQgVGhpY2tuZXNzIENvbXBhcmlzb24gLSBQcm90b2NvbCBNYXRlcmlhbHM8L3RpdGxlPgogICAgPHNjcmlwdCBzcmM9Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9ucG0vY2hhcnQuanMiPjwvc2NyaXB0PgogICAgPHN0eWxlPgogICAgICAgIGJvZHkgewogICAgICAgICAgICBmb250LWZhbWlseTogJ1NlZ29lIFVJJywgVGFob21hLCBHZW5ldmEsIFZlcmRhbmEsIHNhbnMtc2VyaWY7CiAgICAgICAgICAgIGJhY2tncm91bmQtY29sb3I6IHdoaXRlOwogICAgICAgICAgICBwYWRkaW5nOiAzMHB4OwogICAgICAgICAgICBtYXJnaW46IDA7CiAgICAgICAgfQogICAgICAgIC5jb250YWluZXIgewogICAgICAgICAgICBtYXgtd2lkdGg6IDE2MDBweDsKICAgICAgICAgICAgbWFyZ2luOiAwIGF1dG87CiAgICAgICAgfQogICAgICAgIGgxIHsKICAgICAgICAgICAgY29sb3I6ICM0MTQxNDE7CiAgICAgICAgICAgIHRleHQtYWxpZ246IGNlbnRlcjsKICAgICAgICAgICAgbWFyZ2luLWJvdHRvbTogMTBweDsKICAgICAgICB9CiAgICAgICAgLnN1YnRpdGxlIHsKICAgICAgICAgICAgdGV4dC1hbGlnbjogY2VudGVyOwogICAgICAgICAgICBjb2xvcjogIzhDOEM4QzsKICAgICAgICAgICAgbWFyZ2luLWJvdHRvbTogMzBweDsKICAgICAgICAgICAgZm9udC1zaXplOiAxNnB4OwogICAgICAgIH0KICAgICAgICAuc3VtbWFyeS1jYXJkcyB7CiAgICAgICAgICAgIGRpc3BsYXk6IGdyaWQ7CiAgICAgICAgICAgIGdyaWQtdGVtcGxhdGUtY29sdW1uczogcmVwZWF0KGF1dG8tZml0LCBtaW5tYXgoMjgwcHgsIDFmcikpOwogICAgICAgICAgICBnYXA6IDIwcHg7CiAgICAgICAgICAgIG1hcmdpbi1ib3R0b206IDMwcHg7CiAgICAgICAgfQogICAgICAgIC5jYXJkIHsKICAgICAgICAgICAgYmFja2dyb3VuZDogbGluZWFyLWdyYWRpZW50KDEzNWRlZywgI0Y1RjVGNSAwJSwgI0ZGRkZGRiAxMDAlKTsKICAgICAgICAgICAgYm9yZGVyLWxlZnQ6IDRweCBzb2xpZCAjMDA2NkExOwogICAgICAgICAgICBwYWRkaW5nOiAyMHB4OwogICAgICAgICAgICBib3JkZXItcmFkaXVzOiA4cHg7CiAgICAgICAgICAgIGJveC1zaGFkb3c6IDAgMnB4IDhweCByZ2JhKDAsMCwwLDAuMDgpOwogICAgICAgIH0KICAgICAgICAuY2FyZC5uZXdlc3QgewogICAgICAgICAgICBib3JkZXItbGVmdC1jb2xvcjogIzUwQjQ1MDsKICAgICAgICAgICAgYmFja2dyb3VuZDogbGluZWFyLWdyYWRpZW50KDEzNWRlZywgI2YwZjlmMCAwJSwgI0ZGRkZGRiAxMDAlKTsKICAgICAgICB9CiAgICAgICAgLmNhcmQgaDMgewogICAgICAgICAgICBtYXJnaW46IDAgMCAxNXB4IDA7CiAgICAgICAgICAgIGNvbG9yOiAjNDE0MTQxOwogICAgICAgICAgICBmb250LXNpemU6IDE0cHg7CiAgICAgICAgICAgIHRleHQtdHJhbnNmb3JtOiB1cHBlcmNhc2U7CiAgICAgICAgICAgIGxldHRlci1zcGFjaW5nOiAwLjVweDsKICAgICAgICB9CiAgICAgICAgLmNhcmQgLnN0YXQtcm93IHsKICAgICAgICAgICAgZGlzcGxheTogZmxleDsKICAgICAgICAgICAganVzdGlmeS1jb250ZW50OiBzcGFjZS1iZXR3ZWVuOwogICAgICAgICAgICBtYXJnaW46IDhweCAwOwogICAgICAgICAgICBwYWRkaW5nOiA1cHggMDsKICAgICAgICAgICAgYm9yZGVyLWJvdHRvbTogMXB4IHNvbGlkICNFMEUwRTA7CiAgICAgICAgfQogICAgICAgIC5jYXJkIC5zdGF0LXJvdzpsYXN0LWNoaWxkIHsKICAgICAgICAgICAgYm9yZGVyLWJvdHRvbTogbm9uZTsKICAgICAgICB9CiAgICAgICAgLmNhcmQgLmxhYmVsIHsKICAgICAgICAgICAgZm9udC1zaXplOiAxMnB4OwogICAgICAgICAgICBjb2xvcjogIzhDOEM4QzsKICAgICAgICB9CiAgICAgICAgLmNhcmQgLnZhbHVlIHsKICAgICAgICAgICAgZm9udC1zaXplOiAxNHB4OwogICAgICAgICAgICBmb250LXdlaWdodDogYm9sZDsKICAgICAgICAgICAgY29sb3I6ICM0MTQxNDE7CiAgICAgICAgfQogICAgICAgIC5jYXJkIC5oaWdobGlnaHQgewogICAgICAgICAgICBmb250LXNpemU6IDI0cHg7CiAgICAgICAgICAgIGZvbnQtd2VpZ2h0OiBib2xkOwogICAgICAgICAgICBjb2xvcjogIzAwNjZBMTsKICAgICAgICAgICAgdGV4dC1hbGlnbjogY2VudGVyOwogICAgICAgICAgICBtYXJnaW46IDEwcHggMDsKICAgICAgICB9CiAgICAgICAgLmNoYXJ0LWNvbnRhaW5lciB7CiAgICAgICAgICAgIGJhY2tncm91bmQ6IHdoaXRlOwogICAgICAgICAgICBwYWRkaW5nOiAzMHB4OwogICAgICAgICAgICBib3JkZXItcmFkaXVzOiA4cHg7CiAgICAgICAgICAgIGJveC1zaGFkb3c6IDAgMnB4IDhweCByZ2JhKDAsMCwwLDAuMSk7CiAgICAgICAgICAgIG1hcmdpbi1ib3R0b206IDMwcHg7CiAgICAgICAgfQogICAgICAgIC5jaGFydC1ncmlkIHsKICAgICAgICAgICAgZGlzcGxheTogZ3JpZDsKICAgICAgICAgICAgZ3JpZC10ZW1wbGF0ZS1jb2x1bW5zOiAxZnIgMWZyOwogICAgICAgICAgICBnYXA6IDMwcHg7CiAgICAgICAgICAgIG1hcmdpbi1ib3R0b206IDMwcHg7CiAgICAgICAgfQogICAgICAgIEBtZWRpYSAobWF4LXdpZHRoOiAxMjAwcHgpIHsKICAgICAgICAgICAgLmNoYXJ0LWdyaWQgewogICAgICAgICAgICAgICAgZ3JpZC10ZW1wbGF0ZS1jb2x1bW5zOiAxZnI7CiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICAgICAgY2FudmFzIHsKICAgICAgICAgICAgbWF4LWhlaWdodDogNDUwcHg7CiAgICAgICAgfQogICAgICAgIC5pbnNpZ2h0LWJveCB7CiAgICAgICAgICAgIGJhY2tncm91bmQ6ICNFOEY0Rjg7CiAgICAgICAgICAgIGJvcmRlci1sZWZ0OiA0cHggc29saWQgIzAwNjZBMTsKICAgICAgICAgICAgcGFkZGluZzogMjBweDsKICAgICAgICAgICAgYm9yZGVyLXJhZGl1czogNHB4OwogICAgICAgICAgICBtYXJnaW4tYm90dG9tOiAzMHB4OwogICAgICAgIH0KICAgICAgICAuaW5zaWdodC1ib3ggaDMgewogICAgICAgICAgICBtYXJnaW46IDAgMCAxMHB4IDA7CiAgICAgICAgICAgIGNvbG9yOiAjMDA2NkExOwogICAgICAgICAgICBmb250LXNpemU6IDE2cHg7CiAgICAgICAgfQogICAgICAgIC5pbnNpZ2h0LWJveCB1bCB7CiAgICAgICAgICAgIG1hcmdpbjogMTBweCAwOwogICAgICAgICAgICBwYWRkaW5nLWxlZnQ6IDIwcHg7CiAgICAgICAgICAgIGNvbG9yOiAjNDE0MTQxOwogICAgICAgIH0KICAgICAgICAuaW5zaWdodC1ib3ggbGkgewogICAgICAgICAgICBtYXJnaW46IDhweCAwOwogICAgICAgICAgICBsaW5lLWhlaWdodDogMS42OwogICAgICAgIH0KICAgIDwvc3R5bGU&amp;#43;CjwvaGVhZD4KPGJvZHk&amp;#43;CiAgICA8ZGl2IGNsYXNzPSJjb250YWluZXIiPgogICAgICAgIDxoMT5QYWludCBUaGlja25lc3MgRGF0YSBDb2xsZWN0aW9uIC0gTWF0ZXJpYWxzIHdpdGggQWN0aXZlIFByb3RvY29sczwvaDE&amp;#43;CiAgICAgICAgPGRpdiBjbGFzcz0ic3VidGl0bGUiPlJUVS1MQVRFUkFMIFcgUHJvZHVjdCB8IEFsbCBtZWFzdXJlbWVudHMgYnkgQm9iIE9wZXJhdG9yIHVzaW5nIFBhaW50IFRoaWNrbmVzcyBHYXVnZSAwMDA0PC9kaXY&amp;#43;CiAgICAgICAgCiAgICAgICAgPGRpdiBjbGFzcz0iaW5zaWdodC1ib3giPgogICAgICAgICAgICA8aDM&amp;#43;8J&amp;#43;TiiBLZXkgRmluZGluZ3M8L2gzPgogICAgICAgICAgICA8dWw&amp;#43;CiAgICAgICAgICAgICAgICA8bGk&amp;#43;PHN0cm9uZz5Db25zaXN0ZW5jeSBBY3Jvc3MgUHJvdG9jb2wgTWF0ZXJpYWxzOjwvc3Ryb25nPiBBbGwgNSBtYXRlcmlhbHMgd2l0aCBhY3RpdmUgcHJvdG9jb2xzIHNob3cgc2ltaWxhciBhdmVyYWdlIHBhaW50IHRoaWNrbmVzcyB2YWx1ZXMgKDY4LjQgLSA3MS4yIM68bSksIGRlbW9uc3RyYXRpbmcgZXhjZWxsZW50IHByb2Nlc3MgY29udHJvbC48L2xpPgogICAgICAgICAgICAgICAgPGxpPjxzdHJvbmc&amp;#43;U3RhbmRhcmQgRGV2aWF0aW9uOjwvc3Ryb25nPiBBbGwgbWF0ZXJpYWxzIG1haW50YWluIGxvdyB2YXJpYWJpbGl0eSAoNC42IC0gNS44IM68bSksIGluZGljYXRpbmcgY29uc2lzdGVudCBxdWFsaXR5LjwvbGk&amp;#43;CiAgICAgICAgICAgICAgICA8bGk&amp;#43;PHN0cm9uZz5TYW1lIE9wZXJhdG9yICYgSW5zdHJ1bWVudDo8L3N0cm9uZz4gQWxsIG1lYXN1cmVtZW50cyBwZXJmb3JtZWQgYnkgQm9iIE9wZXJhdG9yIHVzaW5nIFBhaW50IFRoaWNrbmVzcyBHYXVnZSAwMDA0LCBlbGltaW5hdGluZyBvcGVyYXRvciBhbmQgZXF1aXBtZW50IHZhcmlhYmlsaXR5LjwvbGk&amp;#43;CiAgICAgICAgICAgICAgICA8bGk&amp;#43;PHN0cm9uZz5Qcm90b2NvbCBFZmZlY3RpdmVuZXNzOjwvc3Ryb25nPiBDb21wYXJlZCB0byBub24tcHJvdG9jb2wgbWF0ZXJpYWxzIChhdmcgODUuMSDOvG0pLCBwcm90b2NvbC1jb250cm9sbGVkIG1hdGVyaWFscyBhcmUgYXBwcm94aW1hdGVseSAxOCUgdGhpbm5lciBhbmQgbW9yZSBjb25zaXN0ZW50LjwvbGk&amp;#43;CiAgICAgICAgICAgIDwvdWw&amp;#43;CiAgICAgICAgPC9kaXY&amp;#43;CgogICAgICAgIDxkaXYgY2xhc3M9InN1bW1hcnktY2FyZHMiPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkIG5ld2VzdCI&amp;#43;CiAgICAgICAgICAgICAgICA8aDM&amp;#43;8J&amp;#43;GlSBNYXRlcmlhbCAxIChOZXdlc3QpPC9oMz4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImhpZ2hsaWdodCI&amp;#43;NzAuMDkgzrxtPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5SYW5nZTwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjYwLjM2IC0gNzkuMTcgzrxtPC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5TdGQgRGV2PC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSI&amp;#43;NS42NiDOvG08L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPlJlYWRpbmdzPC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSI&amp;#43;MTU8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPk1hdGVyaWFsIElEPC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSIgc3R5bGU9ImZvbnQtc2l6ZTogMTBweDsiPi4uLmEyOWMxZTE0PC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgIDwvZGl2PgoKICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZCI&amp;#43;CiAgICAgICAgICAgICAgICA8aDM&amp;#43;TWF0ZXJpYWwgMjwvaDM&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJoaWdobGlnaHQiPjcwLjA1IM68bTwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;UmFuZ2U8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9InZhbHVlIj42MC4yNCAtIDc5LjAwIM68bTwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;U3RkIERldjwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjUuNjggzrxtPC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5SZWFkaW5nczwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjE1PC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5NYXRlcmlhbCBJRDwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiIHN0eWxlPSJmb250LXNpemU6IDEwcHg7Ij4uLi40ZDZjZWE4Mzwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNhcmQiPgogICAgICAgICAgICAgICAgPGgzPk1hdGVyaWFsIDM8L2gzPgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0iaGlnaGxpZ2h0Ij42OS4wMCDOvG08L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPlJhbmdlPC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSI&amp;#43;NjEuMTIgLSA3OC42NiDOvG08L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPlN0ZCBEZXY8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9InZhbHVlIj40LjY1IM68bTwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;UmVhZGluZ3M8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9InZhbHVlIj4xNTwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;TWF0ZXJpYWwgSUQ8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9InZhbHVlIiBzdHlsZT0iZm9udC1zaXplOiAxMHB4OyI&amp;#43;Li4uNGE4ZmMzMWU8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPC9kaXY&amp;#43;CgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjYXJkIj4KICAgICAgICAgICAgICAgIDxoMz5NYXRlcmlhbCA0PC9oMz4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9ImhpZ2hsaWdodCI&amp;#43;NjguNDMgzrxtPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5SYW5nZTwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjYwLjcxIC0gNzYuNjggzrxtPC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5TdGQgRGV2PC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSI&amp;#43;NC45MyDOvG08L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPlJlYWRpbmdzPC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSI&amp;#43;MTU8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9InN0YXQtcm93Ij4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0ibGFiZWwiPk1hdGVyaWFsIElEPC9zcGFuPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJ2YWx1ZSIgc3R5bGU9ImZvbnQtc2l6ZTogMTBweDsiPi4uLjQ0MjM1YzFjPC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgIDwvZGl2PgoKICAgICAgICAgICAgPGRpdiBjbGFzcz0iY2FyZCI&amp;#43;CiAgICAgICAgICAgICAgICA8aDM&amp;#43;TWF0ZXJpYWwgNTwvaDM&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJoaWdobGlnaHQiPjcxLjIwIM68bTwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;UmFuZ2U8L3NwYW4&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9InZhbHVlIj42MS42NSAtIDc4LjE4IM68bTwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgPGRpdiBjbGFzcz0ic3RhdC1yb3ciPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJsYWJlbCI&amp;#43;U3RkIERldjwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjUuODMgzrxtPC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5SZWFkaW5nczwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiPjE1PC9zcGFuPgogICAgICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJzdGF0LXJvdyI&amp;#43;CiAgICAgICAgICAgICAgICAgICAgPHNwYW4gY2xhc3M9ImxhYmVsIj5NYXRlcmlhbCBJRDwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8c3BhbiBjbGFzcz0idmFsdWUiIHN0eWxlPSJmb250LXNpemU6IDEwcHg7Ij4uLi44OWY3YmMwNzwvc3Bhbj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L2Rpdj4KCiAgICAgICAgPGRpdiBjbGFzcz0iY2hhcnQtZ3JpZCI&amp;#43;CiAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNoYXJ0LWNvbnRhaW5lciI&amp;#43;CiAgICAgICAgICAgICAgICA8Y2FudmFzIGlkPSJyYWRhckNoYXJ0Ij48L2NhbnZhcz4KICAgICAgICAgICAgPC9kaXY&amp;#43;CiAgICAgICAgICAgIDxkaXYgY2xhc3M9ImNoYXJ0LWNvbnRhaW5lciI&amp;#43;CiAgICAgICAgICAgICAgICA8Y2FudmFzIGlkPSJiYXJDaGFydCI&amp;#43;PC9jYW52YXM&amp;#43;CiAgICAgICAgICAgIDwvZGl2PgogICAgICAgIDwvZGl2PgoKICAgICAgICA8ZGl2IGNsYXNzPSJjaGFydC1jb250YWluZXIiPgogICAgICAgICAgICA8Y2FudmFzIGlkPSJib3hQbG90Q2hhcnQiPjwvY2FudmFzPgogICAgICAgIDwvZGl2PgogICAgPC9kaXY&amp;#43;CgogICAgPHNjcmlwdD4KICAgIChmdW5jdGlvbigpIHsKICAgICAgICAvLyBDb2xvciBwYWxldHRlCiAgICAgICAgY29uc3QgY29sb3JzID0gWycjNTBCNDUwJywgJyMwMDY2QTEnLCAnIzNDQUFGMCcsICcjRTE1NTMyJywgJyNBQUFBQUEnXTsKICAgICAgICAKICAgICAgICAvLyBEYXRhIGZvciBhbGwgbWF0ZXJpYWxzCiAgICAgICAgY29uc3QgbWF0ZXJpYWxzRGF0YSA9IHsKICAgICAgICAgICAgJ01hdGVyaWFsIDEgKE5ld2VzdCknOiB7CiAgICAgICAgICAgICAgICBib3R0b21FZGdlOiA3MC44NywKICAgICAgICAgICAgICAgIGNlbnRlcjogNjQuOTIsCiAgICAgICAgICAgICAgICBsZWZ0U2lkZTogNjkuNjUsCiAgICAgICAgICAgICAgICByaWdodFNpZGU6IDcyLjI3LAogICAgICAgICAgICAgICAgdG9wRWRnZTogNzIuNzMsCiAgICAgICAgICAgICAgICBvdmVyYWxsOiA3MC4wOSwKICAgICAgICAgICAgICAgIHN0ZERldjogNS42NiwKICAgICAgICAgICAgICAgIG1pbjogNjAuMzYsCiAgICAgICAgICAgICAgICBtYXg6IDc5LjE3CiAgICAgICAgICAgIH0sCiAgICAgICAgICAgICdNYXRlcmlhbCAyJzogewogICAgICAgICAgICAgICAgYm90dG9tRWRnZTogNzEuODgsCiAgICAgICAgICAgICAgICBjZW50ZXI6IDY5Ljg4LAogICAgICAgICAgICAgICAgbGVmdFNpZGU6IDYyLjk0LAogICAgICAgICAgICAgICAgcmlnaHRTaWRlOiA3NS45MSwKICAgICAgICAgICAgICAgIHRvcEVkZ2U6IDY5LjY0LAogICAgICAgICAgICAgICAgb3ZlcmFsbDogNzAuMDUsCiAgICAgICAgICAgICAgICBzdGREZXY6IDUuNjgsCiAgICAgICAgICAgICAgICBtaW46IDYwLjI0LAogICAgICAgICAgICAgICAgbWF4OiA3OS4wMAogICAgICAgICAgICB9LAogICAgICAgICAgICAnTWF0ZXJpYWwgMyc6IHsKICAgICAgICAgICAgICAgIGJvdHRvbUVkZ2U6IDY5LjEzLAogICAgICAgICAgICAgICAgY2VudGVyOiA3Mi4wNywKICAgICAgICAgICAgICAgIGxlZnRTaWRlOiA2Ny42MiwKICAgICAgICAgICAgICAgIHJpZ2h0U2lkZTogNjkuNjYsCiAgICAgICAgICAgICAgICB0b3BFZGdlOiA2Ni41NCwKICAgICAgICAgICAgICAgIG92ZXJhbGw6IDY5LjAwLAogICAgICAgICAgICAgICAgc3RkRGV2OiA0LjY1LAogICAgICAgICAgICAgICAgbWluOiA2MS4xMiwKICAgICAgICAgICAgICAgIG1heDogNzguNjYKICAgICAgICAgICAgfSwKICAgICAgICAgICAgJ01hdGVyaWFsIDQnOiB7CiAgICAgICAgICAgICAgICBib3R0b21FZGdlOiA2OC41NiwKICAgICAgICAgICAgICAgIGNlbnRlcjogNjkuMTQsCiAgICAgICAgICAgICAgICBsZWZ0U2lkZTogNjguNDIsCiAgICAgICAgICAgICAgICByaWdodFNpZGU6IDY4LjU1LAogICAgICAgICAgICAgICAgdG9wRWRnZTogNjcuNTAsCiAgICAgICAgICAgICAgICBvdmVyYWxsOiA2OC40MywKICAgICAgICAgICAgICAgIHN0ZERldjogNC45MywKICAgICAgICAgICAgICAgIG1pbjogNjAuNzEsCiAgICAgICAgICAgICAgICBtYXg6IDc2LjY4CiAgICAgICAgICAgIH0sCiAgICAgICAgICAgICdNYXRlcmlhbCA1JzogewogICAgICAgICAgICAgICAgYm90dG9tRWRnZTogNzQuNjIsCiAgICAgICAgICAgICAgICBjZW50ZXI6IDc1LjkwLAogICAgICAgICAgICAgICAgbGVmdFNpZGU6IDY0Ljg5LAogICAgICAgICAgICAgICAgcmlnaHRTaWRlOiA3NC4xNSwKICAgICAgICAgICAgICAgIHRvcEVkZ2U6IDY2LjQ3LAogICAgICAgICAgICAgICAgb3ZlcmFsbDogNzEuMjAsCiAgICAgICAgICAgICAgICBzdGREZXY6IDUuODMsCiAgICAgICAgICAgICAgICBtaW46IDYxLjY1LAogICAgICAgICAgICAgICAgbWF4OiA3OC4xOAogICAgICAgICAgICB9CiAgICAgICAgfTsKCiAgICAgICAgLy8gUmFkYXIgQ2hhcnQKICAgICAgICBjb25zdCByYWRhckNvbnRleHQgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncmFkYXJDaGFydCcpLmdldENvbnRleHQoJzJkJyk7CiAgICAgICAgY29uc3QgcmFkYXJEYXRhc2V0cyA9IE9iamVjdC5rZXlzKG1hdGVyaWFsc0RhdGEpLm1hcCgobWF0ZXJpYWwsIGluZGV4KSA9PiAoewogICAgICAgICAgICBsYWJlbDogbWF0ZXJpYWwsCiAgICAgICAgICAgIGRhdGE6IFsKICAgICAgICAgICAgICAgIG1hdGVyaWFsc0RhdGFbbWF0ZXJpYWxdLmJvdHRvbUVkZ2UsCiAgICAgICAgICAgICAgICBtYXRlcmlhbHNEYXRhW21hdGVyaWFsXS5jZW50ZXIsCiAgICAgICAgICAgICAgICBtYXRlcmlhbHNEYXRhW21hdGVyaWFsXS5sZWZ0U2lkZSwKICAgICAgICAgICAgICAgIG1hdGVyaWFsc0RhdGFbbWF0ZXJpYWxdLnJpZ2h0U2lkZSwKICAgICAgICAgICAgICAgIG1hdGVyaWFsc0RhdGFbbWF0ZXJpYWxdLnRvcEVkZ2UKICAgICAgICAgICAgXSwKICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yOiBjb2xvcnNbaW5kZXhdICsgJzIwJywKICAgICAgICAgICAgYm9yZGVyQ29sb3I6IGNvbG9yc1tpbmRleF0sCiAgICAgICAgICAgIGJvcmRlcldpZHRoOiAyLAogICAgICAgICAgICBwb2ludEJhY2tncm91bmRDb2xvcjogY29sb3JzW2luZGV4XSwKICAgICAgICAgICAgcG9pbnRCb3JkZXJDb2xvcjogJyNmZmYnLAogICAgICAgICAgICBwb2ludEJvcmRlcldpZHRoOiAyLAogICAgICAgICAgICBwb2ludFJhZGl1czogNCwKICAgICAgICAgICAgcG9pbnRIb3ZlclJhZGl1czogNgogICAgICAgIH0pKTsKCiAgICAgICAgbmV3IENoYXJ0KHJhZGFyQ29udGV4dCwgewogICAgICAgICAgICB0eXBlOiAncmFkYXInLAogICAgICAgICAgICBkYXRhOiB7CiAgICAgICAgICAgICAgICBsYWJlbHM6IFsnQm90dG9tIEVkZ2UnLCAnQ2VudGVyJywgJ0xlZnQgU2lkZScsICdSaWdodCBTaWRlJywgJ1RvcCBFZGdlJ10sCiAgICAgICAgICAgICAgICBkYXRhc2V0czogcmFkYXJEYXRhc2V0cwogICAgICAgICAgICB9LAogICAgICAgICAgICBvcHRpb25zOiB7CiAgICAgICAgICAgICAgICByZXNwb25zaXZlOiB0cnVlLAogICAgICAgICAgICAgICAgbWFpbnRhaW5Bc3BlY3RSYXRpbzogdHJ1ZSwKICAgICAgICAgICAgICAgIHBsdWdpbnM6IHsKICAgICAgICAgICAgICAgICAgICB0aXRsZTogewogICAgICAgICAgICAgICAgICAgICAgICBkaXNwbGF5OiB0cnVlLAogICAgICAgICAgICAgICAgICAgICAgICB0ZXh0OiAnUGFpbnQgVGhpY2tuZXNzIGJ5IE1lYXN1cmVtZW50IExvY2F0aW9uJywKICAgICAgICAgICAgICAgICAgICAgICAgZm9udDogeyBzaXplOiAxNiwgd2VpZ2h0OiAnYm9sZCcgfSwKICAgICAgICAgICAgICAgICAgICAgICAgY29sb3I6ICcjNDE0MTQxJywKICAgICAgICAgICAgICAgICAgICAgICAgcGFkZGluZzogMjAKICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgIGxlZ2VuZDogewogICAgICAgICAgICAgICAgICAgICAgICBkaXNwbGF5OiB0cnVlLAogICAgICAgICAgICAgICAgICAgICAgICBwb3NpdGlvbjogJ2JvdHRvbScsCiAgICAgICAgICAgICAgICAgICAgICAgIGxhYmVsczogewogICAgICAgICAgICAgICAgICAgICAgICAgICAgZm9udDogeyBzaXplOiAxMSB9LAogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29sb3I6ICcjNDE0MTQxJywKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHBhZGRpbmc6IDEwLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgdXNlUG9pbnRTdHlsZTogdHJ1ZQogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICB0b29sdGlwOiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGJhY2tncm91bmRDb2xvcjogJ3JnYmEoNjUsIDY1LCA2NSwgMC45NSknLAogICAgICAgICAgICAgICAgICAgICAgICBwYWRkaW5nOiAxMiwKICAgICAgICAgICAgICAgICAgICAgICAgY2FsbGJhY2tzOiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBsYWJlbDogZnVuY3Rpb24oY29udGV4dCkgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHJldHVybiBjb250ZXh0LmRhdGFzZXQubGFiZWwgKyAnOiAnICsgY29udGV4dC5wYXJzZWQuci50b0ZpeGVkKDIpICsgJyDOvG0nOwogICAgICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgIHNjYWxlczogewogICAgICAgICAgICAgICAgICAgIHI6IHsKICAgICAgICAgICAgICAgICAgICAgICAgYmVnaW5BdFplcm86IHRydWUsCiAgICAgICAgICAgICAgICAgICAgICAgIG1pbjogMCwKICAgICAgICAgICAgICAgICAgICAgICAgbWF4OiA4MCwKICAgICAgICAgICAgICAgICAgICAgICAgdGlja3M6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHN0ZXBTaXplOiAyMCwKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGZvbnQ6IHsgc2l6ZTogMTEgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnIzhDOEM4QycsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjYWxsYmFjazogZnVuY3Rpb24odmFsdWUpIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICByZXR1cm4gdmFsdWUgKyAnIM68bSc7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgIGdyaWQ6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnI0Q4RDhEOCcKICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgcG9pbnRMYWJlbHM6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGZvbnQ6IHsgc2l6ZTogMTIsIHdlaWdodDogJzYwMCcgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnIzQxNDE0MScKICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgIH0pOwoKICAgICAgICAvLyBCYXIgQ2hhcnQgLSBPdmVyYWxsIEF2ZXJhZ2UgQ29tcGFyaXNvbgogICAgICAgIGNvbnN0IGJhckNvbnRleHQgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYmFyQ2hhcnQnKS5nZXRDb250ZXh0KCcyZCcpOwogICAgICAgIG5ldyBDaGFydChiYXJDb250ZXh0LCB7CiAgICAgICAgICAgIHR5cGU6ICdiYXInLAogICAgICAgICAgICBkYXRhOiB7CiAgICAgICAgICAgICAgICBsYWJlbHM6IE9iamVjdC5rZXlzKG1hdGVyaWFsc0RhdGEpLAogICAgICAgICAgICAgICAgZGF0YXNldHM6IFt7CiAgICAgICAgICAgICAgICAgICAgbGFiZWw6ICdPdmVyYWxsIEF2ZXJhZ2UgUGFpbnQgVGhpY2tuZXNzJywKICAgICAgICAgICAgICAgICAgICBkYXRhOiBPYmplY3QudmFsdWVzKG1hdGVyaWFsc0RhdGEpLm1hcChtID0&amp;#43;IG0ub3ZlcmFsbCksCiAgICAgICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yOiBjb2xvcnMubWFwKGMgPT4gYyArICc4MCcpLAogICAgICAgICAgICAgICAgICAgIGJvcmRlckNvbG9yOiBjb2xvcnMsCiAgICAgICAgICAgICAgICAgICAgYm9yZGVyV2lkdGg6IDIKICAgICAgICAgICAgICAgIH1dCiAgICAgICAgICAgIH0sCiAgICAgICAgICAgIG9wdGlvbnM6IHsKICAgICAgICAgICAgICAgIHJlc3BvbnNpdmU6IHRydWUsCiAgICAgICAgICAgICAgICBtYWludGFpbkFzcGVjdFJhdGlvOiB0cnVlLAogICAgICAgICAgICAgICAgcGx1Z2luczogewogICAgICAgICAgICAgICAgICAgIHRpdGxlOiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRpc3BsYXk6IHRydWUsCiAgICAgICAgICAgICAgICAgICAgICAgIHRleHQ6ICdPdmVyYWxsIEF2ZXJhZ2UgUGFpbnQgVGhpY2tuZXNzIENvbXBhcmlzb24nLAogICAgICAgICAgICAgICAgICAgICAgICBmb250OiB7IHNpemU6IDE2LCB3ZWlnaHQ6ICdib2xkJyB9LAogICAgICAgICAgICAgICAgICAgICAgICBjb2xvcjogJyM0MTQxNDEnLAogICAgICAgICAgICAgICAgICAgICAgICBwYWRkaW5nOiAyMAogICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgbGVnZW5kOiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRpc3BsYXk6IGZhbHNlCiAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICB0b29sdGlwOiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGJhY2tncm91bmRDb2xvcjogJ3JnYmEoNjUsIDY1LCA2NSwgMC45NSknLAogICAgICAgICAgICAgICAgICAgICAgICBwYWRkaW5nOiAxMiwKICAgICAgICAgICAgICAgICAgICAgICAgY2FsbGJhY2tzOiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBsYWJlbDogZnVuY3Rpb24oY29udGV4dCkgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHJldHVybiAnQXZlcmFnZTogJyArIGNvbnRleHQucGFyc2VkLnkudG9GaXhlZCgyKSArICcgzrxtJzsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICBzY2FsZXM6IHsKICAgICAgICAgICAgICAgICAgICB5OiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGJlZ2luQXRaZXJvOiB0cnVlLAogICAgICAgICAgICAgICAgICAgICAgICBtYXg6IDgwLAogICAgICAgICAgICAgICAgICAgICAgICB0aWNrczogewogICAgICAgICAgICAgICAgICAgICAgICAgICAgZm9udDogeyBzaXplOiAxMSB9LAogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29sb3I6ICcjOEM4QzhDJywKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNhbGxiYWNrOiBmdW5jdGlvbih2YWx1ZSkgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHJldHVybiB2YWx1ZSArICcgzrxtJzsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgZ3JpZDogewogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29sb3I6ICcjRDhEOEQ4JywKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGRyYXdCb3JkZXI6IGZhbHNlCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgIHg6IHsKICAgICAgICAgICAgICAgICAgICAgICAgdGlja3M6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGZvbnQ6IHsgc2l6ZTogMTEgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnIzQxNDE0MScKICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgZ3JpZDogewogICAgICAgICAgICAgICAgICAgICAgICAgICAgZGlzcGxheTogZmFsc2UKICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgIH0pOwoKICAgICAgICAvLyBCb3ggUGxvdCBTdHlsZSBDaGFydCAodXNpbmcgYmFyIGNoYXJ0IHdpdGggZXJyb3IgYmFycyBzaW11bGF0aW9uKQogICAgICAgIGNvbnN0IGJveENvbnRleHQgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnYm94UGxvdENoYXJ0JykuZ2V0Q29udGV4dCgnMmQnKTsKICAgICAgICBuZXcgQ2hhcnQoYm94Q29udGV4dCwgewogICAgICAgICAgICB0eXBlOiAnYmFyJywKICAgICAgICAgICAgZGF0YTogewogICAgICAgICAgICAgICAgbGFiZWxzOiBPYmplY3Qua2V5cyhtYXRlcmlhbHNEYXRhKSwKICAgICAgICAgICAgICAgIGRhdGFzZXRzOiBbCiAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICBsYWJlbDogJ0F2ZXJhZ2UnLAogICAgICAgICAgICAgICAgICAgICAgICBkYXRhOiBPYmplY3QudmFsdWVzKG1hdGVyaWFsc0RhdGEpLm1hcChtID0&amp;#43;IG0ub3ZlcmFsbCksCiAgICAgICAgICAgICAgICAgICAgICAgIGJhY2tncm91bmRDb2xvcjogY29sb3JzLm1hcChjID0&amp;#43;IGMgKyAnNjAnKSwKICAgICAgICAgICAgICAgICAgICAgICAgYm9yZGVyQ29sb3I6IGNvbG9ycywKICAgICAgICAgICAgICAgICAgICAgICAgYm9yZGVyV2lkdGg6IDIKICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgbGFiZWw6ICdNaW4gVmFsdWUnLAogICAgICAgICAgICAgICAgICAgICAgICBkYXRhOiBPYmplY3QudmFsdWVzKG1hdGVyaWFsc0RhdGEpLm1hcChtID0&amp;#43;IG0ubWluKSwKICAgICAgICAgICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yOiAndHJhbnNwYXJlbnQnLAogICAgICAgICAgICAgICAgICAgICAgICBib3JkZXJDb2xvcjogJyNCODMxMjgnLAogICAgICAgICAgICAgICAgICAgICAgICBib3JkZXJXaWR0aDogMiwKICAgICAgICAgICAgICAgICAgICAgICAgYm9yZGVyRGFzaDogWzUsIDVdLAogICAgICAgICAgICAgICAgICAgICAgICB0eXBlOiAnbGluZScsCiAgICAgICAgICAgICAgICAgICAgICAgIHBvaW50U3R5bGU6ICd0cmlhbmdsZScsCiAgICAgICAgICAgICAgICAgICAgICAgIHBvaW50UmFkaXVzOiA1CiAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgIGxhYmVsOiAnTWF4IFZhbHVlJywKICAgICAgICAgICAgICAgICAgICAgICAgZGF0YTogT2JqZWN0LnZhbHVlcyhtYXRlcmlhbHNEYXRhKS5tYXAobSA9PiBtLm1heCksCiAgICAgICAgICAgICAgICAgICAgICAgIGJhY2tncm91bmRDb2xvcjogJ3RyYW5zcGFyZW50JywKICAgICAgICAgICAgICAgICAgICAgICAgYm9yZGVyQ29sb3I6ICcjNTBCNDUwJywKICAgICAgICAgICAgICAgICAgICAgICAgYm9yZGVyV2lkdGg6IDIsCiAgICAgICAgICAgICAgICAgICAgICAgIGJvcmRlckRhc2g6IFs1LCA1XSwKICAgICAgICAgICAgICAgICAgICAgICAgdHlwZTogJ2xpbmUnLAogICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0eWxlOiAndHJpYW5nbGUnLAogICAgICAgICAgICAgICAgICAgICAgICBwb2ludFJvdGF0aW9uOiAxODAsCiAgICAgICAgICAgICAgICAgICAgICAgIHBvaW50UmFkaXVzOiA1CiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgXQogICAgICAgICAgICB9LAogICAgICAgICAgICBvcHRpb25zOiB7CiAgICAgICAgICAgICAgICByZXNwb25zaXZlOiB0cnVlLAogICAgICAgICAgICAgICAgbWFpbnRhaW5Bc3BlY3RSYXRpbzogdHJ1ZSwKICAgICAgICAgICAgICAgIHBsdWdpbnM6IHsKICAgICAgICAgICAgICAgICAgICB0aXRsZTogewogICAgICAgICAgICAgICAgICAgICAgICBkaXNwbGF5OiB0cnVlLAogICAgICAgICAgICAgICAgICAgICAgICB0ZXh0OiAnUGFpbnQgVGhpY2tuZXNzIFJhbmdlIEFuYWx5c2lzIChNaW4sIEF2ZXJhZ2UsIE1heCknLAogICAgICAgICAgICAgICAgICAgICAgICBmb250OiB7IHNpemU6IDE2LCB3ZWlnaHQ6ICdib2xkJyB9LAogICAgICAgICAgICAgICAgICAgICAgICBjb2xvcjogJyM0MTQxNDEnLAogICAgICAgICAgICAgICAgICAgICAgICBwYWRkaW5nOiAyMAogICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgbGVnZW5kOiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRpc3BsYXk6IHRydWUsCiAgICAgICAgICAgICAgICAgICAgICAgIHBvc2l0aW9uOiAnYm90dG9tJywKICAgICAgICAgICAgICAgICAgICAgICAgbGFiZWxzOiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBmb250OiB7IHNpemU6IDEyIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb2xvcjogJyM0MTQxNDEnLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgcGFkZGluZzogMTUsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICB1c2VQb2ludFN0eWxlOiB0cnVlCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgIHRvb2x0aXA6IHsKICAgICAgICAgICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yOiAncmdiYSg2NSwgNjUsIDY1LCAwLjk1KScsCiAgICAgICAgICAgICAgICAgICAgICAgIHBhZGRpbmc6IDEyLAogICAgICAgICAgICAgICAgICAgICAgICBjYWxsYmFja3M6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGxhYmVsOiBmdW5jdGlvbihjb250ZXh0KSB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGNvbnRleHQuZGF0YXNldC5sYWJlbCArICc6ICcgKyBjb250ZXh0LnBhcnNlZC55LnRvRml4ZWQoMikgKyAnIM68bSc7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgc2NhbGVzOiB7CiAgICAgICAgICAgICAgICAgICAgeTogewogICAgICAgICAgICAgICAgICAgICAgICBiZWdpbkF0WmVybzogdHJ1ZSwKICAgICAgICAgICAgICAgICAgICAgICAgbWF4OiA4NSwKICAgICAgICAgICAgICAgICAgICAgICAgdGlja3M6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGZvbnQ6IHsgc2l6ZTogMTEgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnIzhDOEM4QycsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjYWxsYmFjazogZnVuY3Rpb24odmFsdWUpIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICByZXR1cm4gdmFsdWUgKyAnIM68bSc7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgIGdyaWQ6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbG9yOiAnI0Q4RDhEOCcsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBkcmF3Qm9yZGVyOiBmYWxzZQogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICB4OiB7CiAgICAgICAgICAgICAgICAgICAgICAgIHRpY2tzOiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBmb250OiB7IHNpemU6IDExIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb2xvcjogJyM0MTQxNDEnCiAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgIGdyaWQ6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGRpc3BsYXk6IGZhbHNlCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KICAgICAgICB9KTsKICAgIH0pKCk7CiAgICA8L3NjcmlwdD4KPC9ib2R5Pgo8L2h0bWw&amp;#43;Cg==" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;hr&gt;
&lt;p&gt;The pattern is obvious: the material with the protocol has &lt;strong&gt;40% thinner paint&lt;/strong&gt;, a different gauge was used, and a specific employee is associated with the anomaly.&lt;/p&gt;
&lt;p&gt;This is not speculation. This is evidence grounded in context. The AI didn&amp;rsquo;t replace the engineer. It made them dramatically faster at discovering root cause.&lt;/p&gt;
&lt;p&gt;He&amp;rsquo;s able to leverage his experience and his intuition and knowledge of the shopfloor and do an instant exploratory analysis, in a couple of minutes.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The Final Mile: Action
&lt;div id="the-final-mile-action" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-final-mile-action" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;But here&amp;rsquo;s where most systems fail. The engineer has the insight. Now they need to act.&lt;/p&gt;
&lt;p&gt;In a traditional setup, they would:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Open a separate system (maybe a paper form, maybe an email)&lt;/li&gt;
&lt;li&gt;Write up the finding&lt;/li&gt;
&lt;li&gt;Create a corrective action request&lt;/li&gt;
&lt;li&gt;Assign it to someone&lt;/li&gt;
&lt;li&gt;Hope it gets followed up&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In a closed-loop system, the engineer can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Navigate directly to the quality protocol in the MES&lt;/li&gt;
&lt;li&gt;Create a corrective action task in the same interface&lt;/li&gt;
&lt;li&gt;Assign responsibility&lt;/li&gt;
&lt;li&gt;Track resolution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The action is recorded, traceable, and connected to the original evidence. The loop is closed.&lt;/p&gt;
&lt;p&gt;This is not a minor improvement. This is the difference between &lt;code&gt;analysis paralysis&lt;/code&gt; and &lt;code&gt;disciplined action&lt;/code&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why MES Is the Right Place
&lt;div id="why-mes-is-the-right-place" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-mes-is-the-right-place" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;An MES sits at the &lt;strong&gt;intersection&lt;/strong&gt; of data, process, and action. It is uniquely positioned to close this loop because:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It owns the execution layer.&lt;/strong&gt; The MES controls what happens on the shopfloor. It can trigger workflows, quarantine materials, create tasks, and notify stakeholders.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It provides structured context.&lt;/strong&gt; Unlike a generic data lake, the MES already knows the relationships between materials, processes, equipment, and specifications.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It enforces discipline.&lt;/strong&gt; Actions in the MES are not suggestions. They are tracked, auditable, and part of the compliance record.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It integrates with AI natively.&lt;/strong&gt; Modern MES platforms can ingest canonical events, run ML models, and expose natural language interfaces without leaving the system.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When AI lives inside the MES, insights become actions. The factory doesn&amp;rsquo;t just know. It does.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Beyond the Dashboard
&lt;div id="beyond-the-dashboard" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#beyond-the-dashboard" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The manufacturing industry has been oversold on visibility. We have more charts, more graphs, and more alerts than ever before. And yet, problems still take too long to solve.&lt;/p&gt;
&lt;p&gt;The next frontier is not more data. It&amp;rsquo;s better action.&lt;/p&gt;
&lt;p&gt;AI in manufacturing must:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Collect&lt;/strong&gt; data automatically and continuously&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contextualize&lt;/strong&gt; it with process and product knowledge&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable exploration&lt;/strong&gt; through natural language&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface insights&lt;/strong&gt; that explain causality&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Close the loop&lt;/strong&gt; with executable actions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the intelligent factory. Not one that watches. One that acts.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The true value of an MES is not just insight. It is disciplined action.&lt;/p&gt;
&lt;p&gt;Seeing problems is easy. Being able to act correctly and consistently is the hard part. That is where the MES proves its value. It doesn&amp;rsquo;t just tell you what happened. It helps you respond, consistently, traceably, and immediately.&lt;/p&gt;
&lt;p&gt;The future of AI in manufacturing isn&amp;rsquo;t better dashboards. It&amp;rsquo;s better decisions, faster execution, and closed loops.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The question isn&amp;rsquo;t whether your factory has enough data. It&amp;rsquo;s whether your data leads to action.&lt;/strong&gt;&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260218-frominsighttoaction/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260218-frominsighttoaction/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>From Data to Knowledge: A Case Study in Schema-Driven Manufacturing Intelligence</title>
<link>https://j-roque.com/posts/20260215-unstocdm/</link>
<pubDate>Sun, 15 Feb 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260215-unstocdm/</guid>
<description>UNS to CDM: from data to contextualized insights</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260215-unstocdm/featured.png" alt="From Data to Knowledge: A Case Study in Schema-Driven Manufacturing Intelligence" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The proliferation of &lt;code&gt;Unified Namespace&lt;/code&gt; (UNS) architectures in manufacturing has solved fundamental challenges in real-time data distribution.&lt;/p&gt;
&lt;p&gt;However, the transition from raw telemetry to actionable intelligence remains problematic. We will take a look at a case study of ProveItBeverage to try to illustrate it. ProveItBeverage is a multi-site beverage manufacturer.&lt;/p&gt;
&lt;p&gt;We aim to demonstrate how schema-full ingestion into a &lt;code&gt;Canonical Data Model&lt;/code&gt; (CDM) enables the transformation of stateless event streams into contextual information suitable for &lt;code&gt;AI-driven knowledge extraction&lt;/code&gt;. We examine the theoretical foundations of this transformation through the lens of the DIKW hierarchy and present an architectural framework that bridges operational technology with analytical intelligence.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Jeff Winter&amp;rsquo;s DIKW"
src="https://images.squarespace-cdn.com/content/v1/6500be7b90b0f770653f355f/3a4856bc-f832-4321-b80d-61d752cf5794/DIKW&amp;#43;Pyramid.jpg?format=2500w"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;DIKW Pyramid&lt;/em&gt; — Image source: &lt;em&gt;&lt;a
href="https://www.jeffwinterinsights.com/insights/dikw-pyramid"
target="_blank"
&gt;https://www.jeffwinterinsights.com/insights/dikw-pyramid&lt;/a&gt;&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Introduction
&lt;div id="introduction" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#introduction" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Fourth Industrial Revolution and the diminishing costs of sensors and micro-controllers in the last decades, has brought unprecedented connectivity to manufacturing environments. Sensors, programmable logic controllers, and edge devices now generate continuous streams of operational data. The Unified Namespace paradigm, built upon publish-subscribe messaging protocols such as &lt;code&gt;MQTT&lt;/code&gt;, has emerged as the dominant architectural pattern for distributing this data across enterprise systems.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Yet connectivity alone does not yield intelligence.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Manufacturing organizations increasingly report a paradox: more data has not produced proportionally better decisions. Production managers possess access to real-time dashboards yet struggle to answer fundamental questions about operational performance. The barrier is not access but &lt;em&gt;comprehension&lt;/em&gt; — raw data lacks the semantic structure necessary for meaningful analysis.&lt;/p&gt;
&lt;p&gt;The missing element is &lt;strong&gt;schema-driven contextualization&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Through examination of a demo production implementation at ProveItBeverage, we demonstrate how ingesting &lt;code&gt;UNS events into a Canonical Data Model&lt;/code&gt; creates the informational foundation upon which artificial intelligence can perform genuine knowledge extraction.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Fundamental Concepts
&lt;div id="fundamental-concepts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#fundamental-concepts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;DIKW Hierarchy
&lt;div id="dikw--hierarchy" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dikw--hierarchy" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;Data-Information-Knowledge-Wisdom&lt;/code&gt; (DIKW) hierarchy, provides a useful framework for understanding the transformation of raw observations into actionable understanding. In manufacturing contexts, this hierarchy manifests as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌───────────────────────────────────────────────────────────────────┐
│                           WISDOM                                  │
│         Strategic decisions informed by accumulated insight       │
├───────────────────────────────────────────────────────────────────┤
│                          KNOWLEDGE                                │
│       Patterns, correlations, and causal relationships            │
│       discovered through analytical reasoning                     │
├───────────────────────────────────────────────────────────────────┤
│                         INFORMATION                               │
│       Contextualized events with temporal, spatial,               │
│       and relational attributes                                   │
├───────────────────────────────────────────────────────────────────┤
│                            DATA                                   │
│       Raw sensor values, timestamps, discrete measurements        │
└───────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Figure 1.&lt;/strong&gt; The DIKW hierarchy applied to manufacturing intelligence&lt;/p&gt;
&lt;p&gt;The critical observation is that transitions between levels require &lt;em&gt;transformation&lt;/em&gt;, not merely &lt;em&gt;accumulation&lt;/em&gt;. Data becomes information through contextualization. Information becomes knowledge through pattern recognition and inference. Each transition requires different computational and architectural capabilities.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Semantic Gap in UNS Architectures
&lt;div id="the-semantic-gap-in-uns-architectures" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-semantic-gap-in-uns-architectures" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Unified Namespace architectures excel at the distribution of data but are intentionally &lt;em&gt;agnostic&lt;/em&gt; regarding semantic structure. A topic hierarchy such as &lt;code&gt;enterprise/site/area/resource/metric&lt;/code&gt; provides locational context but does not embed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Temporal state&lt;/strong&gt;: Previous values and duration of current condition&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational context&lt;/strong&gt;: Active production orders, shift boundaries, maintenance windows&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Causal relationships&lt;/strong&gt;: Events that precipitated the current observation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comparative baselines&lt;/strong&gt;: Historical norms against which to evaluate current performance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This &lt;code&gt;semantic gap&lt;/code&gt; represents the primary barrier to AI-driven analytics. Machine learning models and large language models require structured, contextual data to perform meaningful inference. Without such structure, these systems are limited to surface-level retrieval rather than genuine knowledge extraction.&lt;/p&gt;
&lt;h3 class="relative group"&gt;ISA-95 as Semantic Foundation
&lt;div id="isa-95-as-semantic-foundation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#isa-95-as-semantic-foundation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The ISA-95 standard (IEC 62264) provides an internationally recognized framework for manufacturing system integration. Its hierarchical model — &lt;em&gt;Enterprise, Site, Area, Work Center, Work Unit&lt;/em&gt; — offers a semantic vocabulary that both humans and machines can interpret consistently.&lt;/p&gt;
&lt;p&gt;By anchoring data models in ISA-95 semantics, manufacturing organizations establish a shared ontology that enables:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Cross-system interoperability without custom mapping&lt;/li&gt;
&lt;li&gt;Human-comprehensible data organization&lt;/li&gt;
&lt;li&gt;Machine-readable hierarchical relationships&lt;/li&gt;
&lt;li&gt;Vendor-agnostic data exchange&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;ProveItBeverage
&lt;div id="proveitbeverage" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#proveitbeverage" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Organizational Context
&lt;div id="organizational-context" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#organizational-context" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;ProveItBeverage is a beverage manufacturing enterprise operating three production facilities. The organization sought to implement an analytics platform capable of answering operational questions through natural language interaction — a capability requiring sophisticated data infrastructure.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Facility&lt;/th&gt;
&lt;th&gt;Production Areas&lt;/th&gt;
&lt;th&gt;Resources&lt;/th&gt;
&lt;th&gt;Daily Event Volume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plant1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;~1.7M events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plant2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;~1.0M events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plant3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;~0.6M events&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Table 1.&lt;/strong&gt; ProveItBeverage facility characteristics&lt;/p&gt;
&lt;p&gt;Each facility follows an identical four-stage production flow: Liquid Processing, Filler Production, Packaging, and Palletizing. This standardization enabled cross-plant performance comparison — a key analytical requirement.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Initial State: Raw UNS Implementation
&lt;div id="initial-state-raw-uns-implementation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#initial-state-raw-uns-implementation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;ProveItBeverage&amp;rsquo;s initial implementation utilized &lt;code&gt;MQTT-based UNS for real-time data distribution&lt;/code&gt;. Equipment across all facilities published metrics to a centralized broker using a standardized topic hierarchy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;proveitbeverage/{site}/{area}/{resource}/metrics/{metric_type}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This architecture successfully addressed &lt;code&gt;real-time visibility&lt;/code&gt; requirements. Operators could observe current equipment states through dashboard applications subscribing to relevant topics.&lt;/p&gt;
&lt;p&gt;However, analytical queries proved problematic. When production managers asked questions such as &lt;em&gt;&amp;ldquo;What caused the performance drop on Pallet01 yesterday afternoon?&amp;rdquo;&lt;/em&gt;, the system could not respond. The UNS contained only point-in-time values; reconstructing the narrative of events required manual correlation across thousands of discrete messages.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Transformation Challenge
&lt;div id="the-transformation-challenge" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-transformation-challenge" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The fundamental challenge was transforming stateless message streams into stateful event narratives. Consider the following UNS message:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;topic&amp;#34;: &amp;#34;proveitbeverage/Plant1/Palletizing/Pallet01/metrics/oee&amp;#34;,
  &amp;#34;payload&amp;#34;: { &amp;#34;value&amp;#34;: 0.64, &amp;#34;timestamp&amp;#34;: &amp;#34;2026-02-12T14:30:00Z&amp;#34; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This message communicates a fact but not a story. To understand its significance, an analyst must determine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What was the previous OEE value?&lt;/li&gt;
&lt;li&gt;How long has performance been at this level?&lt;/li&gt;
&lt;li&gt;Is this within normal operational variance?&lt;/li&gt;
&lt;li&gt;What other events correlate temporally?&lt;/li&gt;
&lt;li&gt;Which production order and product were active?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Answering these questions from raw UNS data requires reconstructing state from message history, a computationally expensive operation that becomes intractable at scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Architectural Solution
&lt;div id="architectural-solution" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#architectural-solution" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;The Canonical Data Model
&lt;div id="the-canonical-data-model" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-canonical-data-model" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;ProveItBeverage implemented a Canonical Data Model (CDM) as an intermediary layer between UNS ingestion and analytical systems. The CDM restructures manufacturing data around &lt;strong&gt;self-describing events&lt;/strong&gt; rather than point-in-time measurements.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                        CDM EVENT STRUCTURE                              │
├─────────────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐     │
│  │  Event      │  │  Hierarchy  │  │  Metrics    │  │  Previous   │     │
│  │  Metadata   │  │  Context    │  │  Payload    │  │  State      │     │
│  ├─────────────┤  ├─────────────┤  ├─────────────┤  ├─────────────┤     │
│  │ eventType   │  │ enterprise  │  │ oee         │  │ priorOee    │     │
│  │ eventTime   │  │ site        │  │ availability│  │ priorTime   │     │
│  │ eventId     │  │ area        │  │ performance │  │ duration    │     │
│  │ source      │  │ resource    │  │ quality     │  │             │     │
│  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘     │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │                    Operational Context                          │    │
│  ├─────────────────────────────────────────────────────────────────┤    │
│  │ shift: &amp;#34;Afternoon&amp;#34; | workOrderId: &amp;#34;WO-2026-0847&amp;#34; | product: ... │    │
│  └─────────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Figure 2.&lt;/strong&gt; Canonical Data Model event structure&lt;/p&gt;
&lt;p&gt;Each CDM event encapsulates:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Explicit hierarchy&lt;/strong&gt; conforming to ISA-95 semantics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embedded previous state&lt;/strong&gt; eliminating downstream state reconstruction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational context&lt;/strong&gt; linking measurements to business entities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-describing metadata&lt;/strong&gt; enabling schema-aware processing&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 class="relative group"&gt;Ingestion Architecture
&lt;div id="ingestion-architecture" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ingestion-architecture" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;em&gt;transformation&lt;/em&gt; from UNS messages to CDM events occurs through a &lt;code&gt;low-code automation workflow&lt;/code&gt; deployed as a containerized service. The architecture maintains clear separation between &lt;code&gt;data distribution&lt;/code&gt; (UNS), &lt;code&gt;transformation&lt;/code&gt; (automation layer), and &lt;code&gt;storage&lt;/code&gt; (Enterprise Data Platform).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────┐      ┌──────────────────────────────┐      ┌──────────────┐
│              │      │      AUTOMATION LAYER        │      │              │
│   UNS/MQTT   │      │  ┌────────────────────────┐  │      │  ENTERPRISE  │
│   BROKER     │───&amp;gt;  │  │  1. Topic Subscription │  │────&amp;gt; │    DATA      │
│              │      │  │  2. Hierarchy Parsing  │  │      │  PLATFORM    │
│  Stateless   │      │  │  3. MES Context Query  │  │      │              │
│  Messages    │      │  │  4. State Enrichment   │  │      │  CDM Event   │
│              │      │  │  5. Schema Validation  │  │      │  Store       │
│              │      │  │  6. CDM Transformation │  │      │              │
│              │      │  └────────────────────────┘  │      │              │
└──────────────┘      └──────────────────────────────┘      └──────────────┘
                                    │
                                    ▼
                      ┌──────────────────────────────┐
                      │     OBSERVABILITY LAYER      │
                      │  Ingestion metrics, latency, │
                      │  error rates, throughput     │
                      └──────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Figure 3.&lt;/strong&gt; UNS-to-CDM ingestion architecture&lt;/p&gt;
&lt;p&gt;The automation workflow anf MES performs the following transformations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Topic Parsing&lt;/strong&gt;: Extracts ISA-95 hierarchy from MQTT topic structure&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Enrichment&lt;/strong&gt;: Queries MES for active shift, work order, and resource state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State Embedding&lt;/strong&gt;: Retrieves previous metric values and calculates state duration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema Validation&lt;/strong&gt;: Ensures conformance to CDM event specifications&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publication&lt;/strong&gt;: Transmits validated events to the Enterprise Data Platform&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This transformation executes within milliseconds, preserving the real-time characteristics of the source data while adding the semantic richness required for analytics.&lt;/p&gt;
&lt;h3 class="relative group"&gt;The Enterprise Data Platform
&lt;div id="the-enterprise-data-platform" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-enterprise-data-platform" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;CDM events flow into an &lt;code&gt;Enterprise Data Platform&lt;/code&gt; (EDP) designed for both operational querying and analytical aggregation. The platform maintains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Event Store&lt;/strong&gt;: Immutable log of all CDM events, indexed by hierarchy and time&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aggregation Layer&lt;/strong&gt;: Pre-computed KPIs (OEE, availability, performance, quality) at configurable time granularities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query Interface&lt;/strong&gt;: SQL and GraphQL endpoints for programmatic access&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This dual-layer architecture supports both real-time operational queries (&amp;ldquo;What is the current state of Pallet01?&amp;rdquo;) and historical analytical queries (&amp;ldquo;What was the average OEE trend over the past week?&amp;rdquo;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;AI-Driven Knowledge Extraction
&lt;div id="ai-driven-knowledge-extraction" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ai-driven-knowledge-extraction" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;The Analytics Copilot
&lt;div id="the-analytics-copilot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-analytics-copilot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;With CDM events providing structured, contextual information, ProveItBeverage implemented CM MES and its EDP with Analytics Copilot, a natural language interface powered by large language models. The system utilizes &lt;code&gt;Model Context Protocol&lt;/code&gt; (MCP) servers to provide the LLM with, safe and guarded, &lt;code&gt;query access to both real-time events and aggregated metrics&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                      ANALYTICS COPILOT ARCHITECTURE                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│    ┌───────────────┐         ┌───────────────┐          ┌─────────────┐ │
│    │    USER       │         │    LARGE      │          │    MCP      │ │
│    │   QUERY       │────────&amp;gt;│   LANGUAGE    │────────&amp;gt; │   SERVERS   │ │
│    │               │         │    MODEL      │          │             │ │
│    │ &amp;#34;What caused  │         │               │          │ ┌─────────┐ │ │
│    │  the drop in  │&amp;lt;────────│  Reasoning &amp;amp;  │&amp;lt;──────── │ │SQL MCP  │ │ │
│    │  performance?&amp;#34;│         │  Synthesis    │          │ │(Events) │ │ │
│    └───────────────┘         └───────────────┘          │ ├─────────┤ │ │
│                                                         │ │GraphQL  │ │ │
│                                                         │ │MCP(KPIs)│ │ │
│                                                         │ └─────────┘ │ │
│                                                         └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Figure 4.&lt;/strong&gt; Analytics Copilot query architecture&lt;/p&gt;
&lt;h3 class="relative group"&gt;From Information to Knowledge
&lt;div id="from-information-to-knowledge" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#from-information-to-knowledge" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The transition from information retrieval to knowledge extraction is observable in the system&amp;rsquo;s response characteristics.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Analytics CoPilot"
src="https://image.j-roque.com/posts/20260215-unstocdm/AnalyticsCoPilot.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Consider the following query:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;Create a report for Site Plant1 Area Palletizing Resource Pallet01 of the last two days.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Report for Site Plant 1"
src="https://image.j-roque.com/posts/20260215-unstocdm/Create%20a%20report%20for%20Site%20Plant1%20Area%20Palletizing%20Resource%20Pallet01%20of%20the%20last%20two%20days.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Generated by AI:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CjxtZXRhIGNoYXJzZXQ9IlVURi04Ij4KPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCxpbml0aWFsLXNjYWxlPTEuMCI&amp;#43;Cjx0aXRsZT5QYWxsZXQwMSBQZXJmb3JtYW5jZSBSZXBvcnQ8L3RpdGxlPgo8c2NyaXB0IHNyYz0iaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9jaGFydC5qcyI&amp;#43;PC9zY3JpcHQ&amp;#43;CjxzdHlsZT5ib2R5e2JhY2tncm91bmQ6I2ZmZjtmb250LWZhbWlseTpBcmlhbCxzYW5zLXNlcmlmO21hcmdpbjowO3BhZGRpbmc6MjBweDtjb2xvcjojNDE0MTQxfS5jb250YWluZXJ7bWF4LXdpZHRoOjE0MDBweDttYXJnaW46MCBhdXRvfS5oZWFkZXJ7YmFja2dyb3VuZDpsaW5lYXItZ3JhZGllbnQoMTM1ZGVnLCMwMDY2QTEgMCUsIzNDQUFGMCAxMDAlKTtjb2xvcjojZmZmO3BhZGRpbmc6MzBweDtib3JkZXItcmFkaXVzOjhweDttYXJnaW4tYm90dG9tOjMwcHh9LmhlYWRlciBoMXttYXJnaW46MCAwIDEwcHggMDtmb250LXNpemU6MzJweH0uaGVhZGVyIC5zdWJ0aXRsZXtmb250LXNpemU6MTZweDtvcGFjaXR5OjAuOTU7bWFyZ2luOjVweCAwfS5oZWFkZXIgLnBlcmlvZHtmb250LXNpemU6MTRweDtvcGFjaXR5OjAuODU7bWFyZ2luLXRvcDoxNXB4O3BhZGRpbmctdG9wOjE1cHg7Ym9yZGVyLXRvcDoxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjMpfS5rcGktZ3JpZHtkaXNwbGF5OmdyaWQ7Z3JpZC10ZW1wbGF0ZS1jb2x1bW5zOnJlcGVhdChhdXRvLWZpdCxtaW5tYXgoMjUwcHgsMWZyKSk7Z2FwOjIwcHg7bWFyZ2luLWJvdHRvbTozMHB4fS5rcGktY2FyZHtiYWNrZ3JvdW5kOiNmZmY7Ym9yZGVyLXJhZGl1czo4cHg7cGFkZGluZzoyMHB4O2JveC1zaGFkb3c6MCAycHggOHB4IHJnYmEoMCwwLDAsMC4xKTtib3JkZXItbGVmdDo0cHggc29saWQgIzAwNjZBMX0ua3BpLWNhcmQuZ29vZHtib3JkZXItbGVmdC1jb2xvcjojNTBCNDUwfS5rcGktY2FyZC53YXJuaW5ne2JvcmRlci1sZWZ0LWNvbG9yOiNDODlGMjd9LmtwaS1jYXJkLmVycm9ye2JvcmRlci1sZWZ0LWNvbG9yOiNCODMxMjh9LmtwaS1sYWJlbHtmb250LXNpemU6MTJweDtjb2xvcjojOEM4QzhDO3RleHQtdHJhbnNmb3JtOnVwcGVyY2FzZTtsZXR0ZXItc3BhY2luZzowLjVweDttYXJnaW4tYm90dG9tOjhweH0ua3BpLXZhbHVle2ZvbnQtc2l6ZTozNnB4O2ZvbnQtd2VpZ2h0OmJvbGQ7Y29sb3I6IzQxNDE0MTttYXJnaW4tYm90dG9tOjVweH0ua3BpLXVuaXR7Zm9udC1zaXplOjE0cHg7Y29sb3I6IzhDOEM4QzttYXJnaW4tbGVmdDo1cHh9LmtwaS1kZXRhaWx7Zm9udC1zaXplOjEzcHg7Y29sb3I6IzhDOEM4QzttYXJnaW4tdG9wOjhweH0uc2VjdGlvbntiYWNrZ3JvdW5kOiNmZmY7Ym9yZGVyLXJhZGl1czo4cHg7cGFkZGluZzoyNXB4O21hcmdpbi1ib3R0b206MjVweDtib3gtc2hhZG93OjAgMnB4IDhweCByZ2JhKDAsMCwwLDAuMSl9LnNlY3Rpb24tdGl0bGV7Zm9udC1zaXplOjIwcHg7Zm9udC13ZWlnaHQ6Ym9sZDtjb2xvcjojNDE0MTQxO21hcmdpbjowIDAgMjBweCAwO3BhZGRpbmctYm90dG9tOjEwcHg7Ym9yZGVyLWJvdHRvbToycHggc29saWQgI0Q4RDhEOH0uY2hhcnQtY29udGFpbmVye3Bvc2l0aW9uOnJlbGF0aXZlO2hlaWdodDozNTBweDttYXJnaW4tdG9wOjIwcHh9Lm1ldHJpY3MtdGFibGV7d2lkdGg6MTAwJTtib3JkZXItY29sbGFwc2U6Y29sbGFwc2U7bWFyZ2luLXRvcDoxNXB4fS5tZXRyaWNzLXRhYmxlIHRoe2JhY2tncm91bmQ6I0Y1RjVGNTtwYWRkaW5nOjEycHg7dGV4dC1hbGlnbjpsZWZ0O2ZvbnQtc2l6ZToxM3B4O2NvbG9yOiM0MTQxNDE7Zm9udC13ZWlnaHQ6Ym9sZDtib3JkZXItYm90dG9tOjJweCBzb2xpZCAjRDhEOEQ4fS5tZXRyaWNzLXRhYmxlIHRke3BhZGRpbmc6MTJweDtib3JkZXItYm90dG9tOjFweCBzb2xpZCAjRDhEOEQ4O2ZvbnQtc2l6ZToxNHB4fS5tZXRyaWNzLXRhYmxlIHRyOmhvdmVye2JhY2tncm91bmQ6I0Y1RjVGNX0uc3RhdHVzLWJhZGdle2Rpc3BsYXk6aW5saW5lLWJsb2NrO3BhZGRpbmc6NHB4IDEycHg7Ym9yZGVyLXJhZGl1czoxMnB4O2ZvbnQtc2l6ZToxMnB4O2ZvbnQtd2VpZ2h0OmJvbGR9LnN0YXR1cy1nb29ke2JhY2tncm91bmQ6IzUwQjQ1MDtjb2xvcjojZmZmfS5zdGF0dXMtd2FybmluZ3tiYWNrZ3JvdW5kOiNDODlGMjc7Y29sb3I6I2ZmZn0uc3RhdHVzLWV4Y2VsbGVudHtiYWNrZ3JvdW5kOiMwMDY2QTE7Y29sb3I6I2ZmZn0uc3VtbWFyeS1ncmlke2Rpc3BsYXk6Z3JpZDtncmlkLXRlbXBsYXRlLWNvbHVtbnM6MWZyIDFmcjtnYXA6MjBweDttYXJnaW4tdG9wOjIwcHh9LnN1bW1hcnktaXRlbXtwYWRkaW5nOjE1cHg7YmFja2dyb3VuZDojRjVGNUY1O2JvcmRlci1yYWRpdXM6NnB4fS5zdW1tYXJ5LWl0ZW0gc3Ryb25ne2Rpc3BsYXk6YmxvY2s7Y29sb3I6IzQxNDE0MTttYXJnaW4tYm90dG9tOjVweDtmb250LXNpemU6MTRweH0uc3VtbWFyeS1pdGVtIHNwYW57Y29sb3I6IzhDOEM4Qztmb250LXNpemU6MTNweH08L3N0eWxlPgo8L2hlYWQ&amp;#43;Cjxib2R5Pgo8ZGl2IGNsYXNzPSJjb250YWluZXIiPgo8ZGl2IGNsYXNzPSJoZWFkZXIiPgo8aDE&amp;#43;8J&amp;#43;PrSBQYWxsZXQwMSBQZXJmb3JtYW5jZSBSZXBvcnQ8L2gxPgo8ZGl2IGNsYXNzPSJzdWJ0aXRsZSI&amp;#43;UGxhbnQxIHwgUGFsbGV0aXppbmcgQXJlYSB8IFByb3ZlSXRCZXZlcmFnZSBFbnRlcnByaXNlPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9InBlcmlvZCI&amp;#43;8J&amp;#43;ThSBSZXBvcnRpbmcgUGVyaW9kOiBGZWJydWFyeSAxMSwgMjAyNiAxNToyMyBVVEMgLSBGZWJydWFyeSAxMywgMjAyNiAxNToyMiBVVEMgKDQ4IGhvdXJzKTwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWdyaWQiPgo8ZGl2IGNsYXNzPSJrcGktY2FyZCBnb29kIj4KPGRpdiBjbGFzcz0ia3BpLWxhYmVsIj5PdmVyYWxsIEVxdWlwbWVudCBFZmZlY3RpdmVuZXNzPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS12YWx1ZSI&amp;#43;OTIuODxzcGFuIGNsYXNzPSJrcGktdW5pdCI&amp;#43;JTwvc3Bhbj48L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWRldGFpbCI&amp;#43;VGFyZ2V0OiA4NSUgfCA8c3BhbiBjbGFzcz0ic3RhdHVzLWJhZGdlIHN0YXR1cy1nb29kIj5HT09EPC9zcGFuPjwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWNhcmQgZ29vZCI&amp;#43;CjxkaXYgY2xhc3M9ImtwaS1sYWJlbCI&amp;#43;QXZhaWxhYmlsaXR5PC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS12YWx1ZSI&amp;#43;OTQuODxzcGFuIGNsYXNzPSJrcGktdW5pdCI&amp;#43;JTwvc3Bhbj48L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWRldGFpbCI&amp;#43;UmFuZ2U6IDQ4Ljk3JSAtIDEwMCU8L2Rpdj4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS1jYXJkIGdvb2QiPgo8ZGl2IGNsYXNzPSJrcGktbGFiZWwiPlBlcmZvcm1hbmNlPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS12YWx1ZSI&amp;#43;OTcuNTxzcGFuIGNsYXNzPSJrcGktdW5pdCI&amp;#43;JTwvc3Bhbj48L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWRldGFpbCI&amp;#43;QWN0dWFsIHZzIFN0YW5kYXJkIFJhdGU8L2Rpdj4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS1jYXJkIGV4Y2VsbGVudCI&amp;#43;CjxkaXYgY2xhc3M9ImtwaS1sYWJlbCI&amp;#43;UXVhbGl0eTwvZGl2Pgo8ZGl2IGNsYXNzPSJrcGktdmFsdWUiPjEwMDxzcGFuIGNsYXNzPSJrcGktdW5pdCI&amp;#43;JTwvc3Bhbj48L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWRldGFpbCI&amp;#43;PHNwYW4gY2xhc3M9InN0YXR1cy1iYWRnZSBzdGF0dXMtZXhjZWxsZW50Ij5QRVJGRUNUPC9zcGFuPjwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWNhcmQiPgo8ZGl2IGNsYXNzPSJrcGktbGFiZWwiPlRvdGFsIFVuaXRzIFByb2Nlc3NlZDwvZGl2Pgo8ZGl2IGNsYXNzPSJrcGktdmFsdWUiPjIwOCwzNzg8L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWRldGFpbCI&amp;#43;SW5mZWVkOiAyMDgsMzc4IHwgT3V0ZmVlZDogMjA4LDM3ODwvZGl2Pgo8L2Rpdj4KPGRpdiBjbGFzcz0ia3BpLWNhcmQgZXhjZWxsZW50Ij4KPGRpdiBjbGFzcz0ia3BpLWxhYmVsIj5EZWZlY3RzPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImtwaS12YWx1ZSI&amp;#43;MDwvZGl2Pgo8ZGl2IGNsYXNzPSJrcGktZGV0YWlsIj5aZXJvIGRlZmVjdHMgcmVjb3JkZWQ8L2Rpdj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJzZWN0aW9uIj4KPGgyIGNsYXNzPSJzZWN0aW9uLXRpdGxlIj7wn5OKIE9FRSBUcmVuZCBBbmFseXNpcyAoSG91cmx5KTwvaDI&amp;#43;CjxkaXYgY2xhc3M9ImNoYXJ0LWNvbnRhaW5lciI&amp;#43;CjxjYW52YXMgaWQ9Im9lZUNoYXJ0Ij48L2NhbnZhcz4KPC9kaXY&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJzZWN0aW9uIj4KPGgyIGNsYXNzPSJzZWN0aW9uLXRpdGxlIj7impnvuI8gUGVyZm9ybWFuY2UgTWV0cmljcyBCcmVha2Rvd248L2gyPgo8ZGl2IGNsYXNzPSJjaGFydC1jb250YWluZXIiPgo8Y2FudmFzIGlkPSJtZXRyaWNzQ2hhcnQiPjwvY2FudmFzPgo8L2Rpdj4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9InNlY3Rpb24iPgo8aDIgY2xhc3M9InNlY3Rpb24tdGl0bGUiPuKPse&amp;#43;4jyBUaW1lIFV0aWxpemF0aW9uPC9oMj4KPGRpdiBjbGFzcz0ic3VtbWFyeS1ncmlkIj4KPGRpdiBjbGFzcz0ic3VtbWFyeS1pdGVtIj4KPHN0cm9uZz5SdW5uaW5nIFRpbWU8L3N0cm9uZz4KPHNwYW4&amp;#43;NTIzLDk5MCBzZWNvbmRzICgxNDUuNiBob3Vycyk8L3NwYW4&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJzdW1tYXJ5LWl0ZW0iPgo8c3Ryb25nPlVucGxhbm5lZCBEb3dudGltZTwvc3Ryb25nPgo8c3Bhbj4yNCw3MzAgc2Vjb25kcyAoNi45IGhvdXJzKTwvc3Bhbj4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9InN1bW1hcnktaXRlbSI&amp;#43;CjxzdHJvbmc&amp;#43;UGxhbm5lZCBEb3dudGltZTwvc3Ryb25nPgo8c3Bhbj4wIHNlY29uZHMgKDAgaG91cnMpPC9zcGFuPgo8L2Rpdj4KPGRpdiBjbGFzcz0ic3VtbWFyeS1pdGVtIj4KPHN0cm9uZz5JZGxlIFRpbWU8L3N0cm9uZz4KPHNwYW4&amp;#43;MCBzZWNvbmRzICgwIGhvdXJzKTwvc3Bhbj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJjaGFydC1jb250YWluZXIiIHN0eWxlPSJoZWlnaHQ6MzAwcHgiPgo8Y2FudmFzIGlkPSJ0aW1lQ2hhcnQiPjwvY2FudmFzPgo8L2Rpdj4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9InNlY3Rpb24iPgo8aDIgY2xhc3M9InNlY3Rpb24tdGl0bGUiPvCfk4ggRGV0YWlsZWQgTWV0cmljcyBTdW1tYXJ5PC9oMj4KPHRhYmxlIGNsYXNzPSJtZXRyaWNzLXRhYmxlIj4KPHRoZWFkPgo8dHI&amp;#43;Cjx0aD5NZXRyaWM8L3RoPgo8dGg&amp;#43;QXZlcmFnZTwvdGg&amp;#43;Cjx0aD5NaW5pbXVtPC90aD4KPHRoPk1heGltdW08L3RoPgo8dGg&amp;#43;RGF0YSBQb2ludHM8L3RoPgo8L3RyPgo8L3RoZWFkPgo8dGJvZHk&amp;#43;Cjx0cj4KPHRkPjxzdHJvbmc&amp;#43;T0VFPC9zdHJvbmc&amp;#43;PC90ZD4KPHRkPjkyLjgwJTwvdGQ&amp;#43;Cjx0ZD4wLjAwJTwvdGQ&amp;#43;Cjx0ZD4xMjEuMjUlPC90ZD4KPHRkPjE2LDM4MjwvdGQ&amp;#43;CjwvdHI&amp;#43;Cjx0cj4KPHRkPjxzdHJvbmc&amp;#43;QXZhaWxhYmlsaXR5PC9zdHJvbmc&amp;#43;PC90ZD4KPHRkPjk0Ljc4JTwvdGQ&amp;#43;Cjx0ZD40OC45NyU8L3RkPgo8dGQ&amp;#43;MTAwLjAwJTwvdGQ&amp;#43;Cjx0ZD4yMSwzMTA8L3RkPgo8L3RyPgo8dHI&amp;#43;Cjx0ZD48c3Ryb25nPlBlcmZvcm1hbmNlPC9zdHJvbmc&amp;#43;PC90ZD4KPHRkPjk3LjUxJTwvdGQ&amp;#43;Cjx0ZD4wLjAwJTwvdGQ&amp;#43;Cjx0ZD4xMjQuOTElPC90ZD4KPHRkPjE2LDQwMjwvdGQ&amp;#43;CjwvdHI&amp;#43;Cjx0cj4KPHRkPjxzdHJvbmc&amp;#43;UXVhbGl0eTwvc3Ryb25nPjwvdGQ&amp;#43;Cjx0ZD4xMDAuMDAlPC90ZD4KPHRkPjEwMC4wMCU8L3RkPgo8dGQ&amp;#43;MTAwLjAwJTwvdGQ&amp;#43;Cjx0ZD43NjI8L3RkPgo8L3RyPgo8dHI&amp;#43;Cjx0ZD48c3Ryb25nPkFjdHVhbCBSYXRlPC9zdHJvbmc&amp;#43;PC90ZD4KPHRkPjM4LjI3IHVuaXRzL21pbjwvdGQ&amp;#43;Cjx0ZD4wLjAwPC90ZD4KPHRkPjk1LjAwPC90ZD4KPHRkPjEzLDY0OTwvdGQ&amp;#43;CjwvdHI&amp;#43;Cjx0cj4KPHRkPjxzdHJvbmc&amp;#43;U3RhbmRhcmQgUmF0ZTwvc3Ryb25nPjwvdGQ&amp;#43;Cjx0ZD4zNS45NyB1bml0cy9taW48L3RkPgo8dGQ&amp;#43;MC4wMDwvdGQ&amp;#43;Cjx0ZD44MC4wMDwvdGQ&amp;#43;Cjx0ZD43Nzc8L3RkPgo8L3RyPgo8L3Rib2R5Pgo8L3RhYmxlPgo8L2Rpdj4KPGRpdiBjbGFzcz0ic2VjdGlvbiI&amp;#43;CjxoMiBjbGFzcz0ic2VjdGlvbi10aXRsZSI&amp;#43;8J&amp;#43;OryBLZXkgRmluZGluZ3MgJiBSZWNvbW1lbmRhdGlvbnM8L2gyPgo8ZGl2IHN0eWxlPSJsaW5lLWhlaWdodDoxLjgiPgo8cD48c3Ryb25nIHN0eWxlPSJjb2xvcjojNTBCNDUwIj7inJMgU3RyZW5ndGhzOjwvc3Ryb25nPjwvcD4KPHVsIHN0eWxlPSJtYXJnaW46MTBweCAwIDIwcHggMjBweCI&amp;#43;CjxsaT5FeGNlbGxlbnQgT0VFIG9mIDkyLjglLCB3ZWxsIGFib3ZlIHRoZSA4NSUgaW5kdXN0cnkgdGFyZ2V0PC9saT4KPGxpPlBlcmZlY3QgcXVhbGl0eSByYXRlIHdpdGggemVybyBkZWZlY3RzIGFjcm9zcyAyMDgsMzc4IHVuaXRzPC9saT4KPGxpPlN0cm9uZyBwZXJmb3JtYW5jZSBhdCA5Ny41JSwgaW5kaWNhdGluZyBlZmZpY2llbnQgb3BlcmF0aW9uPC9saT4KPGxpPkFjdHVhbCByYXRlICgzOC4yNyB1bml0cy9taW4pIGV4Y2VlZHMgc3RhbmRhcmQgcmF0ZSAoMzUuOTcgdW5pdHMvbWluKSBieSA2LjQlPC9saT4KPC91bD4KPHA&amp;#43;PHN0cm9uZyBzdHlsZT0iY29sb3I6I0M4OUYyNyI&amp;#43;4pqgIEFyZWFzIGZvciBJbXByb3ZlbWVudDo8L3N0cm9uZz48L3A&amp;#43;Cjx1bCBzdHlsZT0ibWFyZ2luOjEwcHggMCAyMHB4IDIwcHgiPgo8bGk&amp;#43;QXZhaWxhYmlsaXR5IGRpcCBvbiBGZWIgMTIgKDEzOjAwLTE4OjAwIFVUQykgZHJvcHBlZCB0byA2NC05MyUsIGludmVzdGlnYXRlIHJvb3QgY2F1c2U8L2xpPgo8bGk&amp;#43;Ni45IGhvdXJzIG9mIHVucGxhbm5lZCBkb3dudGltZSByZWNvcmRlZCAtIGltcGxlbWVudCBwcmVkaWN0aXZlIG1haW50ZW5hbmNlPC9saT4KPGxpPlBlcmZvcm1hbmNlIGRpcCB0byA2My4xNyUgYXQgMDQ6MDAgVVRDIG9uIEZlYiAxMiByZXF1aXJlcyBpbnZlc3RpZ2F0aW9uPC9saT4KPC91bD4KPHA&amp;#43;PHN0cm9uZyBzdHlsZT0iY29sb3I6IzAwNjZBMSI&amp;#43;8J&amp;#43;TiyBSZWNvbW1lbmRhdGlvbnM6PC9zdHJvbmc&amp;#43;PC9wPgo8dWwgc3R5bGU9Im1hcmdpbjoxMHB4IDAgMjBweCAyMHB4Ij4KPGxpPkludmVzdGlnYXRlIHRoZSBhdmFpbGFiaWxpdHkgZHJvcCBvbiBGZWIgMTIgYWZ0ZXJub29uIHNoaWZ0PC9saT4KPGxpPkFuYWx5emUgdGhlIDI0LDczMCBzZWNvbmRzIG9mIHVucGxhbm5lZCBkb3dudGltZSBmb3IgcGF0dGVybnM8L2xpPgo8bGk&amp;#43;Q29udGludWUgY3VycmVudCBtYWludGVuYW5jZSBwcmFjdGljZXMgdG8gc3VzdGFpbiB6ZXJvLWRlZmVjdCBwZXJmb3JtYW5jZTwvbGk&amp;#43;CjxsaT5Eb2N1bWVudCBiZXN0IHByYWN0aWNlcyBmcm9tIGhpZ2gtcGVyZm9ybWluZyBwZXJpb2RzICg5NSUrIE9FRSBob3Vycyk8L2xpPgo8L3VsPgo8L2Rpdj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8c2NyaXB0PgooZnVuY3Rpb24oKXsKY29uc3Qgb2VlRGF0YT17bGFiZWxzOlsnRmViIDExIDE1OjAwJywnMTY6MDAnLCcxNzowMCcsJzE4OjAwJywnMTk6MDAnLCcyMDowMCcsJzIxOjAwJywnMjI6MDAnLCcyMzowMCcsJ0ZlYiAxMiAwMDowMCcsJzAxOjAwJywnMDI6MDAnLCcwMzowMCcsJzA0OjAwJywnMDU6MDAnLCcwNjowMCcsJzA3OjAwJywnMDg6MDAnLCcwOTowMCcsJzEwOjAwJywnMTE6MDAnLCcxMjowMCcsJzEzOjAwJywnMTQ6MDAnLCcxNTowMCcsJzE2OjAwJywnMTc6MDAnLCcxODowMCcsJzIxOjAwJywnMjI6MDAnLCdGZWIgMTMgMDM6MDAnLCcwNDowMCcsJzA1OjAwJywnMDY6MDAnLCcxMTowMCcsJzEyOjAwJywnMTM6MDAnLCcxNDowMCcsJzE1OjAwJ10sZGF0YXNldHM6W3tsYWJlbDonT0VFICUnLGRhdGE6WzkwLjkwLDk1LjUyLDk1LjAzLDkzLjExLDk0LjAzLDk0Ljk3LDk0LjA0LDk0LjUxLDkxLjYxLDk0LjkzLDk1LjI0LDkxLjY3LDkxLjgxLDU5Ljc4LDkxLjc4LDk0LjAyLDkyLjY0LDkyLjYxLDg4Ljg3LDkzLjU2LDk0LjM1LDk1Ljk0LDgzLjA2LDg4LjI4LDkyLjI0LDg5LjM1LDk0LjE0LDk0LjAyLDkyLjY1LDkyLjg5LDk0Ljk0LDkyLjUxLDkzLjA0LDk1LjQwLDg3LjEyLDkxLjgxLDk0Ljk4LDk0LjMxLDkzLjYzXSxib3JkZXJDb2xvcjonIzAwNjZBMScsYmFja2dyb3VuZENvbG9yOidyZ2JhKDAsMTAyLDE2MSwwLjEpJyx0ZW5zaW9uOjAuNCxmaWxsOnRydWUscG9pbnRSYWRpdXM6Myxwb2ludEhvdmVyUmFkaXVzOjV9LHtsYWJlbDonQXZhaWxhYmlsaXR5ICUnLGRhdGE6Wzk2LjU4LDk2LjYwLDk2LjYyLDk2LjY1LDk2LjY0LDk2LjQzLDk2LjQ0LDk2LjQ1LDk2LjI0LDk2LjA3LDk2LjA5LDk2LjEyLDk2LjE0LDk1Ljc1LDk1LjQ1LDk1LjQ4LDk1LjUxLDk1LjU0LDk1LjU0LDk1LjQ0LDk1LjQ3LDk1LjU0LDY0LjQ3LDc5LjA0LDg2LjM3LDg5Ljg0LDkxLjkxLDkyLjk2LDg5LjI2LDkwLjAwLDk1LjgyLDk2LjMwLDk2LjUzLDk2LjcwLDk2Ljc1LDk2LjgzLDk2Ljk0LDk3LjA4LDk2Ljc4XSxib3JkZXJDb2xvcjonIzNDQUFGMCcsYmFja2dyb3VuZENvbG9yOidyZ2JhKDYwLDE3MCwyNDAsMC4xKScsdGVuc2lvbjowLjQsZmlsbDp0cnVlLHBvaW50UmFkaXVzOjMscG9pbnRIb3ZlclJhZGl1czo1fSx7bGFiZWw6J1BlcmZvcm1hbmNlICUnLGRhdGE6Wzk0LjM3LDk4LjkyLDk4LjMyLDk2LjM3LDk3LjQyLDk4LjE0LDk3LjczLDk3Ljk4LDk1LjY5LDk4LjczLDk4Ljk3LDk1LjYzLDk1LjU0LDYzLjE3LDk2LjE4LDk4LjQ4LDk3LjAyLDk3LjAwLDkzLjM5LDk4LjI0LDk4Ljc4LDk3LjY2LDk3Ljc4LDk5LjM2LDk5LjIzLDk2LjM4LDk4LjQyLDk3LjUxLDk4LjQxLDk4LjgyLDk4Ljc4LDk2Ljk2LDk2LjEyLDk4LjgzLDkyLjI3LDkzLjgxLDk4LjA2LDk3LjM2LDk3LjI0XSxib3JkZXJDb2xvcjonI0UxNTUzMicsYmFja2dyb3VuZENvbG9yOidyZ2JhKDIyNSw4NSw1MCwwLjEpJyx0ZW5zaW9uOjAuNCxmaWxsOnRydWUscG9pbnRSYWRpdXM6Myxwb2ludEhvdmVyUmFkaXVzOjV9XX07CmNvbnN0IG9lZUNvbmZpZz17dHlwZTonbGluZScsZGF0YTpvZWVEYXRhLG9wdGlvbnM6e3Jlc3BvbnNpdmU6dHJ1ZSxtYWludGFpbkFzcGVjdFJhdGlvOmZhbHNlLHBsdWdpbnM6e2xlZ2VuZDp7ZGlzcGxheTp0cnVlLHBvc2l0aW9uOid0b3AnfSx0b29sdGlwOnttb2RlOidpbmRleCcsaW50ZXJzZWN0OmZhbHNlfX0sc2NhbGVzOnt5OntiZWdpbkF0WmVybzp0cnVlLG1heDoxMTAsZ3JpZDp7Y29sb3I6JyNEOEQ4RDgnfSx0aWNrczp7Y2FsbGJhY2s6ZnVuY3Rpb24odmFsdWUpe3JldHVybiB2YWx1ZSsnJSd9fSx0aXRsZTp7ZGlzcGxheTp0cnVlLHRleHQ6J1BlcmNlbnRhZ2UnLGZvbnQ6e3NpemU6MTIsd2VpZ2h0Oidib2xkJ319fSx4OntncmlkOntkaXNwbGF5OmZhbHNlfSx0aWNrczp7bWF4Um90YXRpb246NDUsbWluUm90YXRpb246NDUsZm9udDp7c2l6ZTo5fX19fX19OwpuZXcgQ2hhcnQoZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ29lZUNoYXJ0JykuZ2V0Q29udGV4dCgnMmQnKSxvZWVDb25maWcpOwpjb25zdCBtZXRyaWNzRGF0YT17bGFiZWxzOlsnT0VFJywnQXZhaWxhYmlsaXR5JywnUGVyZm9ybWFuY2UnLCdRdWFsaXR5J10sZGF0YXNldHM6W3tsYWJlbDonQXZlcmFnZSAlJyxkYXRhOls5Mi44MCw5NC43OCw5Ny41MSwxMDAuMDBdLGJhY2tncm91bmRDb2xvcjpbJyMwMDY2QTEnLCcjM0NBQUYwJywnI0UxNTUzMicsJyM1MEI0NTAnXSxib3JkZXJDb2xvcjpbJyMwMDY2QTEnLCcjM0NBQUYwJywnI0UxNTUzMicsJyM1MEI0NTAnXSxib3JkZXJXaWR0aDoyfV19Owpjb25zdCBtZXRyaWNzQ29uZmlnPXt0eXBlOidiYXInLGRhdGE6bWV0cmljc0RhdGEsb3B0aW9uczp7cmVzcG9uc2l2ZTp0cnVlLG1haW50YWluQXNwZWN0UmF0aW86ZmFsc2UscGx1Z2luczp7bGVnZW5kOntkaXNwbGF5OmZhbHNlfX0sc2NhbGVzOnt5OntiZWdpbkF0WmVybzp0cnVlLG1heDoxMDAsZ3JpZDp7Y29sb3I6JyNEOEQ4RDgnfSx0aWNrczp7Y2FsbGJhY2s6ZnVuY3Rpb24odmFsdWUpe3JldHVybiB2YWx1ZSsnJSd9fX0seDp7Z3JpZDp7ZGlzcGxheTpmYWxzZX19fX19OwpuZXcgQ2hhcnQoZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ21ldHJpY3NDaGFydCcpLmdldENvbnRleHQoJzJkJyksbWV0cmljc0NvbmZpZyk7CmNvbnN0IHRpbWVEYXRhPXtsYWJlbHM6WydSdW5uaW5nIFRpbWUnLCdVbnBsYW5uZWQgRG93bnRpbWUnLCdQbGFubmVkIERvd250aW1lJ10sZGF0YXNldHM6W3tsYWJlbDonSG91cnMnLGRhdGE6WzE0NS41NSw2Ljg3LDBdLGJhY2tncm91bmRDb2xvcjpbJyM1MEI0NTAnLCcjQjgzMTI4JywnI0M4OUYyNyddLGJvcmRlckNvbG9yOlsnIzUwQjQ1MCcsJyNCODMxMjgnLCcjQzg5RjI3J10sYm9yZGVyV2lkdGg6Mn1dfTsKY29uc3QgdGltZUNvbmZpZz17dHlwZTonYmFyJyxkYXRhOnRpbWVEYXRhLG9wdGlvbnM6e2luZGV4QXhpczoneScscmVzcG9uc2l2ZTp0cnVlLG1haW50YWluQXNwZWN0UmF0aW86ZmFsc2UscGx1Z2luczp7bGVnZW5kOntkaXNwbGF5OmZhbHNlfX0sc2NhbGVzOnt4OntncmlkOntjb2xvcjonI0Q4RDhEOCd9LHRpY2tzOntjYWxsYmFjazpmdW5jdGlvbih2YWx1ZSl7cmV0dXJuIHZhbHVlKycgaHJzJ319fSx5OntncmlkOntkaXNwbGF5OmZhbHNlfX19fX07Cm5ldyBDaGFydChkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgndGltZUNoYXJ0JykuZ2V0Q29udGV4dCgnMmQnKSx0aW1lQ29uZmlnKTsKfSkoKTsKPC9zY3JpcHQ&amp;#43;CjwvYm9keT4KPC9odG1sPgo=" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;hr&gt;
&lt;p&gt;A traditional BI system would return tabular data or pre-configured visualizations. The Analytics Copilot, operating on CDM-structured data, produces analytical synthesis:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance Summary (Feb 11-13, 2026)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Overall Equipment Effectiveness: 92.8% (exceeds 85% benchmark)&lt;/li&gt;
&lt;li&gt;Quality Rate: 100% across 208,378 units processed&lt;/li&gt;
&lt;li&gt;Performance Efficiency: 97.5% relative to standard rate&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Anomaly Detection&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Availability degradation identified: Feb 12, 14:00-18:00 UTC&lt;/li&gt;
&lt;li&gt;Performance anomaly: 63.17% at 04:00 UTC on Feb 12&lt;/li&gt;
&lt;li&gt;Cumulative unplanned downtime: 6.9 hours&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Causal Hypothesis&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Availability drops correlate temporally with shift transitions&lt;/li&gt;
&lt;li&gt;Pattern consistent across multiple observation periods&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Recommendations&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Investigate shift handoff procedures&lt;/li&gt;
&lt;li&gt;Schedule preventive maintenance inspection&lt;/li&gt;
&lt;li&gt;Implement automated anomaly alerting&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Generated by AI:&lt;/p&gt;
&lt;iframe src="data:text/html;base64,PCFET0NUWVBFIGh0bWw&amp;#43;CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ&amp;#43;CjxtZXRhIGNoYXJzZXQ9IlVURi04Ij4KPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCxpbml0aWFsLXNjYWxlPTEuMCI&amp;#43;Cjx0aXRsZT5QYWxsZXRpemluZyBPRUUgYnkgUmVzb3VyY2U8L3RpdGxlPgo8c2NyaXB0IHNyYz0iaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L25wbS9jaGFydC5qcyI&amp;#43;PC9zY3JpcHQ&amp;#43;CjxzdHlsZT5ib2R5e2JhY2tncm91bmQ6I2ZmZjtmb250LWZhbWlseTpBcmlhbCxzYW5zLXNlcmlmO3BhZGRpbmc6MjBweDttYXJnaW46MH0jY29udGFpbmVye21heC13aWR0aDoxMjAwcHg7bWFyZ2luOjAgYXV0b30uaGVhZGVye3RleHQtYWxpZ246Y2VudGVyO21hcmdpbi1ib3R0b206MzBweH0uaGVhZGVyIGgxe2NvbG9yOiM0MTQxNDE7bWFyZ2luOjAgMCAxMHB4IDA7Zm9udC1zaXplOjI4cHh9LmhlYWRlciBwe2NvbG9yOiM4QzhDOEM7bWFyZ2luOjA7Zm9udC1zaXplOjE0cHh9LmNoYXJ0LXdyYXBwZXJ7cG9zaXRpb246cmVsYXRpdmU7aGVpZ2h0OjUwMHB4O2JhY2tncm91bmQ6I2ZmZjtwYWRkaW5nOjIwcHg7Ym9yZGVyLXJhZGl1czo4cHg7Ym94LXNoYWRvdzowIDJweCA0cHggcmdiYSgwLDAsMCwwLjEpfS5sZWdlbmR7ZGlzcGxheTpmbGV4O2p1c3RpZnktY29udGVudDpjZW50ZXI7Z2FwOjIwcHg7bWFyZ2luLXRvcDoyMHB4O2ZsZXgtd3JhcDp3cmFwfS5sZWdlbmQtaXRlbXtkaXNwbGF5OmZsZXg7YWxpZ24taXRlbXM6Y2VudGVyO2dhcDo4cHg7Zm9udC1zaXplOjE0cHg7Y29sb3I6IzQxNDE0MX0ubGVnZW5kLWNvbG9ye3dpZHRoOjIwcHg7aGVpZ2h0OjIwcHg7Ym9yZGVyLXJhZGl1czozcHh9PC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KPGRpdiBpZD0iY29udGFpbmVyIj4KPGRpdiBjbGFzcz0iaGVhZGVyIj4KPGgxPlBhbGxldGl6aW5nIEFyZWEgLSBPRUUgYnkgUmVzb3VyY2U8L2gxPgo8cD5PdmVyYWxsIEVxdWlwbWVudCBFZmZlY3RpdmVuZXNzIChMYXN0IDcgRGF5cykgfCBQcm92ZUl0QmV2ZXJhZ2UgRW50ZXJwcmlzZTwvcD4KPC9kaXY&amp;#43;CjxkaXYgY2xhc3M9ImNoYXJ0LXdyYXBwZXIiPgo8Y2FudmFzIGlkPSJvZWVDaGFydCI&amp;#43;PC9jYW52YXM&amp;#43;CjwvZGl2Pgo8ZGl2IGNsYXNzPSJsZWdlbmQiPgo8ZGl2IGNsYXNzPSJsZWdlbmQtaXRlbSI&amp;#43;PGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojNTBCNDUwIj48L2Rpdj48c3Bhbj5Hb29kICjiiaU4NSUpPC9zcGFuPjwvZGl2Pgo8ZGl2IGNsYXNzPSJsZWdlbmQtaXRlbSI&amp;#43;PGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojQzg5RjI3Ij48L2Rpdj48c3Bhbj5XYXJuaW5nICg2Ni04NCUpPC9zcGFuPjwvZGl2Pgo8ZGl2IGNsYXNzPSJsZWdlbmQtaXRlbSI&amp;#43;PGRpdiBjbGFzcz0ibGVnZW5kLWNvbG9yIiBzdHlsZT0iYmFja2dyb3VuZDojQjgzMTI4Ij48L2Rpdj48c3Bhbj5Qb29yICg8NjYlKTwvc3Bhbj48L2Rpdj4KPC9kaXY&amp;#43;CjwvZGl2Pgo8c2NyaXB0PgooZnVuY3Rpb24oKXsKY29uc3QgY2hhcnREYXRhPXtsYWJlbHM6WydQbGFudDFcblBhbGxldDAxJywnUGxhbnQxXG5QYWxsZXQwMicsJ1BsYW50MVxuUm9ib3QnLCdQbGFudDFcbldvcmtzdGF0aW9uJywnUGxhbnQxXG5XcmFwcGVyJywnUGxhbnQyXG5QYWxsZXQwMScsJ1BsYW50MlxuUGFsbGV0MDInLCdQbGFudDJcblJvYm90JywnUGxhbnQyXG5Xb3Jrc3RhdGlvbicsJ1BsYW50MlxuV3JhcHBlcicsJ1BsYW50M1xuV29ya3N0YXRpb24nXSxkYXRhc2V0czpbe2xhYmVsOidPRUUgJScsZGF0YTpbOTMuNzksOTQuMjAsMTcuODIsMC44MiwxMDAuMDAsOTEuNDYsOTEuODMsMTEuODMsMC43OCwxMDAuMDAsMTAuNDNdLGJhY2tncm91bmRDb2xvcjpbJyM1MEI0NTAnLCcjNTBCNDUwJywnI0I4MzEyOCcsJyNCODMxMjgnLCcjNTBCNDUwJywnIzUwQjQ1MCcsJyM1MEI0NTAnLCcjQjgzMTI4JywnI0I4MzEyOCcsJyM1MEI0NTAnLCcjQjgzMTI4J10sYm9yZGVyQ29sb3I6WycjNTBCNDUwJywnIzUwQjQ1MCcsJyNCODMxMjgnLCcjQjgzMTI4JywnIzUwQjQ1MCcsJyM1MEI0NTAnLCcjNTBCNDUwJywnI0I4MzEyOCcsJyNCODMxMjgnLCcjNTBCNDUwJywnI0I4MzEyOCddLGJvcmRlcldpZHRoOjJ9XX07CmNvbnN0IGNvbmZpZz17dHlwZTonYmFyJyxkYXRhOmNoYXJ0RGF0YSxvcHRpb25zOntyZXNwb25zaXZlOnRydWUsbWFpbnRhaW5Bc3BlY3RSYXRpbzpmYWxzZSxwbHVnaW5zOntsZWdlbmQ6e2Rpc3BsYXk6ZmFsc2V9LHRpdGxlOntkaXNwbGF5OmZhbHNlfSx0b29sdGlwOntiYWNrZ3JvdW5kQ29sb3I6J3JnYmEoNjUsNjUsNjUsMC45NSknLHBhZGRpbmc6MTIsdGl0bGVDb2xvcjonI2ZmZicsYm9keUNvbG9yOicjZmZmJyxib3JkZXJDb2xvcjonI0Q4RDhEOCcsYm9yZGVyV2lkdGg6MSxjYWxsYmFja3M6e2xhYmVsOmZ1bmN0aW9uKGNvbnRleHQpe3JldHVybidPRUU6ICcrY29udGV4dC5wYXJzZWQueS50b0ZpeGVkKDIpKyclJ319fX0sc2NhbGVzOnt5OntiZWdpbkF0WmVybzp0cnVlLG1heDoxMDAsZ3JpZDp7Y29sb3I6JyNEOEQ4RDgnLGRyYXdCb3JkZXI6ZmFsc2V9LHRpY2tzOntjb2xvcjonIzQxNDE0MScsY2FsbGJhY2s6ZnVuY3Rpb24odmFsdWUpe3JldHVybiB2YWx1ZSsnJSd9fSx0aXRsZTp7ZGlzcGxheTp0cnVlLHRleHQ6J09FRSBQZXJjZW50YWdlJyxjb2xvcjonIzQxNDE0MScsZm9udDp7c2l6ZToxNCx3ZWlnaHQ6J2JvbGQnfX19LHg6e2dyaWQ6e2Rpc3BsYXk6ZmFsc2V9LHRpY2tzOntjb2xvcjonIzQxNDE0MScsZm9udDp7c2l6ZToxMX19LHRpdGxlOntkaXNwbGF5OnRydWUsdGV4dDonUmVzb3VyY2UnLGNvbG9yOicjNDE0MTQxJyxmb250OntzaXplOjE0LHdlaWdodDonYm9sZCd9fX19fX07CmNvbnN0IGN0eD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnb2VlQ2hhcnQnKS5nZXRDb250ZXh0KCcyZCcpOwpuZXcgQ2hhcnQoY3R4LGNvbmZpZyk7Cn0pKCk7Cjwvc2NyaXB0Pgo8L2JvZHk&amp;#43;CjwvaHRtbD4K" style="width:100%;height:900px;border:0;" loading="lazy" referrerpolicy="no-referrer"&gt;&lt;/iframe&gt;
&lt;hr&gt;
&lt;p&gt;This response demonstrates knowledge-level output: the system has &lt;code&gt;identified patterns&lt;/code&gt;, &lt;code&gt;formulated hypotheses about causation&lt;/code&gt;, and &lt;code&gt;generated actionable recommendations&lt;/code&gt;. Such synthesis is only possible because the underlying data carries sufficient context for the model to reason about manufacturing reality.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Comparative Analysis
&lt;div id="comparative-analysis" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#comparative-analysis" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Copilot&amp;rsquo;s capacity for cross-resource analysis further illustrates the value of schema-driven ingestion. When queried about Palletizing area performance, the system produced comparative insights:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;OEE&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrapper (Plant1, Plant2)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;Optimal performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pallet01&lt;/td&gt;
&lt;td&gt;93.79%&lt;/td&gt;
&lt;td&gt;Above benchmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pallet02&lt;/td&gt;
&lt;td&gt;94.20%&lt;/td&gt;
&lt;td&gt;Above benchmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Robot (Plant1)&lt;/td&gt;
&lt;td&gt;17.82%&lt;/td&gt;
&lt;td&gt;Significant underperformance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Robot (Plant2)&lt;/td&gt;
&lt;td&gt;11.83%&lt;/td&gt;
&lt;td&gt;Critical issue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workstation (Plant1)&lt;/td&gt;
&lt;td&gt;0.82%&lt;/td&gt;
&lt;td&gt;Near non-operational&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Table 2.&lt;/strong&gt; Palletizing resource comparative analysis&lt;/p&gt;
&lt;p&gt;The system independently identified that workstation resources across all plants exhibit critically low OEE, suggesting either systematic data collection issues or fundamental operational problems requiring investigation. This insight emerged from pattern recognition across the CDM event corpus, a capability impossible with unstructured UNS data.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Discussion
&lt;div id="discussion" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#discussion" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Schema as Enablement
&lt;div id="schema-as-enablement" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#schema-as-enablement" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The ProveItBeverage implementation challenges a common misconception: that &lt;em&gt;schema enforcement represents bureaucratic overhead&lt;/em&gt;. In practice, the &lt;code&gt;CDM&lt;/code&gt; schema functions as an &lt;em&gt;enablement layer&lt;/em&gt; — constraints that guarantee downstream systems can reason about data without defensive programming or complex validation logic.&lt;/p&gt;
&lt;p&gt;Every CDM event is guaranteed to contain its hierarchical context, its previous state, and its operational associations. This guarantee eliminates an entire class of analytical failures stemming from missing or inconsistent data.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Architectural Implications
&lt;div id="architectural-implications" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#architectural-implications" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The success of this implementation suggests several architectural principles for manufacturing intelligence systems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Separation of distribution and semantics&lt;/strong&gt;: UNS should remain semantically lightweight; contextualization occurs at ingestion boundaries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State embedding over state reconstruction&lt;/strong&gt;: Downstream systems should not bear responsibility for maintaining state machines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema conformance as contract&lt;/strong&gt;: The CDM schema represents a contract between data producers and analytical consumers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ISA-95 as lingua franca&lt;/strong&gt;: Industry-standard hierarchies reduce integration complexity and enable cross-system reasoning&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 class="relative group"&gt;Limitations and Future Work
&lt;div id="limitations-and-future-work" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#limitations-and-future-work" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This case study examined a relatively homogeneous manufacturing environment with standardized processes across facilities. In a more heterogeneous environments with diverse equipment types and non-standard processes, the impact would be even bigger.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Conclusion
&lt;div id="conclusion" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#conclusion" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The journey from raw manufacturing data to AI-driven knowledge extraction is not a matter of algorithmic sophistication alone. It requires disciplined attention to data architecture — specifically, the transformation of stateless event streams into contextual, self-describing information structures.&lt;/p&gt;
&lt;p&gt;The ProveItBeverage case demonstrates that schema-full ingestion into a Canonical Data Model provides the semantic foundation upon which artificial intelligence can perform genuine analytical reasoning. Without such structure, AI systems are limited to retrieval; with it, they can identify patterns, formulate hypotheses, and generate actionable recommendations.&lt;/p&gt;
&lt;p&gt;As manufacturing organizations invest in AI capabilities, they would be well served to examine their data foundations with equal rigor. The sophistication of the analytical layer is ultimately bounded by the quality of information it receives. Intelligence, in this sense, is not a feature to be added but an emergent property of well-architected systems.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Related Resources:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://www.criticalmanufacturing.com/blog/"
target="_blank"
&gt;Democratizing Manufacturing Analytics Series&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://www.isa.org/isa95"
target="_blank"
&gt;ISA-95 Standard Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260215-unstocdm/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260215-unstocdm/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>GEM 300 a Silent Revolution</title>
<link>https://j-roque.com/posts/20260105-gem300/</link>
<pubDate>Mon, 05 Jan 2026 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20260105-gem300/</guid>
<description>GEM 300 a Silent Revolution</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20260105-gem300/featured.png" alt="GEM 300 a Silent Revolution" /&gt;&lt;/p&gt;&lt;p&gt;This is a topic that I&amp;rsquo;ve been following for some time and haven&amp;rsquo;t seen anyone talk about it. The impact of GEM 300 as a platform for a new breakthrough of solutions in semi.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Semiconductor factories thrive on the seamless communication between manufacturing equipment and factory control systems (MES).&lt;/p&gt;
&lt;p&gt;Historically, the &lt;code&gt;SEMI SECS/GEM&lt;/code&gt; (SEMI Equipment Communications Standard / Generic Equipment Model) protocol provided a common framework for host-to-tool communication.&lt;/p&gt;
&lt;p&gt;Early implementations of SECS/GEM in 200 mm fabs were often loosely interpreted by different equipment vendors, each tool’s interface had its own quirks and custom behaviors. Integration engineers frequently had to create &lt;code&gt;one-off adaptations for each vendor’s GEM interface&lt;/code&gt;, since each interface was custom to a particular equipment vendor and equipment type and even software revisions, making support and development time-consuming.&lt;/p&gt;
&lt;p&gt;In other words, by the late 1990s and 2000s the industry still faced a lack of true plug-and-play interoperability, the need for a more rigidly standardized interface was becoming evident.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Semi history"
src="https://www.semi.org/sites/semi.org/files/styles/webp/public/inline-images/g33.png.webp?itok=fBd03e9r"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;Semi History&lt;/em&gt; — Image source: &lt;em&gt;semi&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 class="relative group"&gt;Traditional SECS/GEM Event Reporting Basics
&lt;div id="traditional-secsgem-event-reporting-basics" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#traditional-secsgem-event-reporting-basics" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In a standard SECS/GEM (E30) implementation, equipment notifies the host of important collection messages via events (typically S6F11).&lt;/p&gt;
&lt;p&gt;Each &lt;code&gt;S6F11 Event&lt;/code&gt; Report Send contains a &lt;em&gt;Collection Event ID&lt;/em&gt; (CEID) and one or more associated reports carrying data values. The reports are defined by &lt;em&gt;Report IDs&lt;/em&gt; (RPTIDs) which group specific &lt;em&gt;Status Variable IDs&lt;/em&gt; (SVIDs) or other data variables to provide context for the event.&lt;/p&gt;
&lt;p&gt;The host can configure these via the GEM messaging scheme (e.g. S2F33 Define Report, S2F35 Link Event Report, and S2F37 Enable Event) to decide what data is reported for each event. This mechanism allows flexible linking of events to data.&lt;/p&gt;
&lt;p&gt;For example, a “Lot Complete” event could be linked to a report containing SVIDs for lot ID, recipe, and yield.&lt;/p&gt;
&lt;p&gt;The S6F11 message structure remains consistent (CEID + list of reports) in all GEM implementations.&lt;/p&gt;
&lt;p&gt;By default, GEM provides only a generic framework: aside from a few required events, &lt;code&gt;most CEIDs and their meanings are defined by the equipment supplier&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For instance, the GEM standard requires basic material movement events such as a Material Received event when a carrier or lot is loaded onto the tool and a Material Removed event when it’s unloaded. However, the exact CEID numbers and any additional events (e.g. start/end of processing, operator interventions) were traditionally vendor-specific. &lt;code&gt;Integration engineers had to consult each equipment’s GEM interface manual to learn which CEIDs correspond to which real-world events and what data (SVIDs) would be provided&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In summary, traditional GEM offers the mechanism (S6F11 events and report linking) but not a standardized set of event definitions – each tool often had a custom list of CEIDs and reports for its unique capabilities. This meant the host’s event handling and report setup were tailored per equipment.&lt;/p&gt;
&lt;h2 class="relative group"&gt;The 300 mm Challenge and the Birth of GEM 300
&lt;div id="the-300mm-challenge-and-the-birth-of-gem-300" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-300mm-challenge-and-the-birth-of-gem-300" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The transition from 200 mm to 300 mm wafers around the year 2000 was a turning point for factory automation.&lt;/p&gt;
&lt;p&gt;A 300 mm wafer (12 inch) is larger, heavier, and carried in an enclosed &lt;em&gt;Front-Opening Unified Pod&lt;/em&gt; (FOUP) rather than an open cassette, making automated material handling almost mandatory.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Wafer transition"
src="https://img.trendforce.com/blog/wp-content/uploads/2023/12/05102829/TSMC-Wafer-624x416.jpg"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;Wafer 200 mm and 300 mm&lt;/em&gt; — Image source: &lt;em&gt;TrendForce&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Front-Opening Unified Pod (FOUP)"
src="https://www.sps-international.com/content/net_products/8518-eWB0685-ASSY-1-eFOUP-2.jpg"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;Front-Opening Unified Pod (FOUP)&lt;/em&gt; — Image source: &lt;em&gt;sps-international&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Fabs investing in 300 mm lines aimed for “lights-out” automation, deploying overhead transport vehicles and robots (Automated Material Handling Systems, or AMHS) to move FOUPs between tools.&lt;/p&gt;
&lt;p&gt;This created new requirements: equipment needed standard ways to accept carriers automatically, verify the right lot has arrived, start the correct recipe, and report wafer-by-wafer progress, all with minimal human intervention.&lt;/p&gt;
&lt;p&gt;The basic SECS/GEM standards (SECS-II messaging [E5] over HSMS [E37] and the GEM protocol [E30]) were not sufficient alone to handle these advanced automation scenarios.&lt;/p&gt;
&lt;p&gt;To meet this challenge, SEMI’s North America GEM 300 task force defined a suite of new standards – collectively nicknamed “GEM 300” since they were first implemented for 300 mm fabs. These standards augment the base GEM (E30) model with specific protocols for carrier handling, job management, and wafer tracking. Crucially, unlike the older 200 mm GEM implementations (which had a reputation for vendor-specific optionality), the GEM 300 standards were designed to enforce much tighter uniformity. Each standard in GEM 300 clearly prescribes when and how specific SECS-II messages must be used, defines state models for various operations, and mandates the events/data that equipment must report for nearly every action.&lt;/p&gt;
&lt;p&gt;By the early 2000s, all major IC manufacturers were requiring 300 mm equipment to comply with this GEM 300 standard suite, making it a de facto universal interface for new fabs.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="GEM300"
src="https://www.semi.org/sites/semi.org/files/styles/webp/public/inline-images/GEM300-image-article-3.png.webp?itok=BTZlbs4_"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;GEM300&lt;/em&gt; — Image source: &lt;em&gt;semi&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;GEM 300 builds upon the foundation of GEM. In 300 mm fab automation, the GEM 300 standards sit on top of the GEM (E30) and SECS-II communication stack, adding defined protocols for material handling, job execution, and tracking.&lt;/p&gt;
&lt;h2 class="relative group"&gt;GEM 300 Standards: A Unified Equipment Interface
&lt;div id="gem-300-standards-a-unified-equipment-interface" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gem-300-standards-a-unified-equipment-interface" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;GEM 300 isn’t a single standard but rather a &lt;code&gt;family of SEMI standards&lt;/code&gt; that together ensure end-to-end automation control.&lt;/p&gt;
&lt;p&gt;These include the base GEM standard and several extensions introduced for 300 mm manufacturing. At a high level, GEM 300 covers the following functional domains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Equipment Communication &amp;amp; Configuration (SEMI E5, E37 and E37.1)&lt;/li&gt;
&lt;li&gt;Carrier (material) handling (SEMI E84 and E87)&lt;/li&gt;
&lt;li&gt;Process job control (SEMI E40 and E94)&lt;/li&gt;
&lt;li&gt;Substrate tracking (SEMI E157)&lt;/li&gt;
&lt;li&gt;Performance/state monitoring (SEMI E10 and E116)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By standardizing each of these areas, GEM 300 essentially guarantees that all compliant equipment will exhibit the same interface behavior for equivalent actions, no matter the vendor.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: center"&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="GEM 300 SEMI Standards"
src="https://www.agileo.com/sites/default/files/inline-images/GEM300_SEMI_Standards.png"
&gt;&lt;/figure&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: center"&gt;&lt;em&gt;GEM 300 SEMI Standards&lt;/em&gt; — Image source: &lt;em&gt;agileo&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Summary of GEM 300 functional areas and corresponding SEMI standards. GEM (E30) remains the core for basic messaging (alarms, data collection, remote commands, etc.), while new standards govern automated carrier transfers (E87), process and control jobs (E40 &amp;amp; E94), substrate (wafer) tracking (E90), and more. Together, these create a predictable, vendor-neutral interface for 300 mm tool integration&lt;/p&gt;
&lt;p&gt;To understand how GEM 300 achieved &lt;em&gt;de facto&lt;/em&gt; standardization, it helps to look at the key standards in this suite and what each one mandates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SEMI E30 – &lt;code&gt;Generic Equipment Model&lt;/code&gt; (GEM):&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The foundation of all GEM 300 communications. E30 defines the common set of fundamental behaviors for any equipment interface – e.g. the connect/disconnect procedure, formats of status data, how to report events, how to upload/download recipes, etc. GEM 300 builds on this by requiring a &lt;code&gt;stricter set of standard events, alarms, variables&lt;/code&gt;, and behaviors than were historically used, to ensure consistency across tools.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SEMI E40 – &lt;code&gt;Process Job Management&lt;/code&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This standard formalizes how a host can define and control process jobs on the equipment. A Process Job is essentially a &lt;code&gt;recipe run for a specific set of material (wafers) on the tool&lt;/code&gt;. It also allows the host to manage a job lifecycle with start, stop, pause or abort jobs via standard commands. By standardizing this, E40 ensures that recipe selection and execution are done in a uniform way: no matter the tool, the host can create a process job and be confident the equipment will interpret it (and report its status) consistently.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SEMI E87 – &lt;code&gt;Carrier Management&lt;/code&gt; (CMS):&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;E87 addresses material handling, i.e. the loading and unloading of &lt;code&gt;wafer carriers&lt;/code&gt; (FOUPs) at the equipment’s load ports.&lt;/p&gt;
&lt;p&gt;In practice, this means every GEM 300 tool adheres to the same sequence for the carrier interactions with the machine from arrival, carrier ID verification, latch/unlatch, and carrier removal. E87 defines detailed state models for load ports and carriers (e.g. states for when a port is occupied, ready, busy, out-of-service, etc.), along with events that announce each transition. Thanks to E87, a factory host can interface with any 300 mm tool’s load ports using identical commands and expect the same set of event notifications (carrier present, door open/close, mapping complete, etc.), greatly simplifying material logistics integration. Notably, this yields consistent material handling and eliminates vendor-specific “dances” that were common in earlier generations.&lt;/p&gt;
&lt;p&gt;• SEMI E90 – &lt;code&gt;Substrate Tracking&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;While E87 deals with carriers, E90 standardizes tracking of &lt;code&gt;individual wafers&lt;/code&gt; (substrates) within the equipment. Once wafers are loaded from a FOUP into a tool, the tool’s software must report each wafer’s location (e.g. which process chamber or slot) and processing state to the host in real time. This allows the factory software to know exactly which wafer is at which step, enabling lot-level and even wafer-level traceability.&lt;/p&gt;
&lt;p&gt;Essentially, E90 extends the GEM/GEM300 model down to the &lt;code&gt;single-wafer granularity&lt;/code&gt;, using standard events for wafer movement and completion. Just as E87 did for carrier handling, E90 provides a uniform method for all tools to report wafer progress, so an integration engineer doesn’t have to worry that “Tool A calls it wafer ID X in module Y while Tool B uses a different convention” – they all follow E90’s method of substrate identification and tracking.&lt;/p&gt;
&lt;p&gt;• SEMI E94 – &lt;code&gt;Control Job Management&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;E94 adds a higher level of coordination above the process jobs.&lt;/p&gt;
&lt;p&gt;A Control Job is essentially a container for one or more process jobs, defining a &lt;code&gt;sequence or batch of jobs that should be executed together&lt;/code&gt; (for example, processing multiple FOUPs in a certain order, or splitting a lot across parallel chambers).&lt;/p&gt;
&lt;p&gt;Through standard messages, the host can create control jobs that queue up multiple process jobs, start or stop all jobs in the group, and specify post-process destinations (e.g. after processing, wafers from carrier A should move to carrier B). In effect, E94 standardizes batch and workflow management so that fab scheduling algorithms can orchestrate multi-lot flows in the same way on any GEM 300 tool. The benefit is more than just convenience – it enables advanced capabilities like lot buffering, sorting, and dynamic reordering using a common language.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Interoperability in a GEM 300 World
&lt;div id="interoperability-in-a-gem-300-world" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#interoperability-in-a-gem-300-world" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;By introducing the above standards as requirements for 300 mm tools, the semiconductor industry effectively created a &lt;em&gt;de facto&lt;/em&gt; standardized equipment interface. Today, any piece of equipment shipped to a leading-edge fab must support GEM 300 communications and this has profoundly changed the landscape of integration.&lt;/p&gt;
&lt;p&gt;The recent introduction of mandatory &lt;code&gt;Well-Known Names&lt;/code&gt; allows also for a greater interoperability. In the past vendors could have names for events and variables that were not strictly defined, they could vary in casing, hyphenation or even similar names. This created a lot of entropy when an integrator tried to analyze and compare across specifications. With strict semantic conventions, this eliminates ambiguity, custom namings and narrows the range for interpretation. Each standard will now enforce a set of namings for all its components.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see an example:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;E90:ProcessJobCompleted
E87:CarrierArrived
E30:ControlStateChanged&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example for the &lt;em&gt;ProcessJobCompleted&lt;/em&gt; this means that every specification is mandated to refer to this event as ProcessJobCompleted it can&amp;rsquo;t be Process_Job_Completed, PJobComplete, or any other variation that introduces a need for interpretation.&lt;/p&gt;
&lt;p&gt;Instead of fighting through custom protocols and vendor-specific GEM quirks, factory integration engineers now work with a well-defined, predictable interface that is remarkably similar from tool to tool. In fact, GEM 300 compliance is universal in 300 mm fabs, meaning a GEM 300-enabled machine can be deployed in any 300 mm front-end facility with minimal to littler or no customization by the host software. This plug-and-play quality was simply not achievable in the 200 mm era, where each new tool could mean weeks of custom interface coding.&lt;/p&gt;
&lt;p&gt;Several factors make GEM 300 integration more straightforward and robust:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Uniform Material Handling&lt;/code&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thanks to standards like E87, the sequence of loading a FOUP is the same on every tool, the host always sends the same commands to request a carrier, and always receives the same key events (arrival, dock, mapped, etc.). This consistency guarantees behaviors and compatibility between load ports and carriers, regardless of equipment supplier, allowing for a unified global manufacturing ecosystem.&lt;/p&gt;
&lt;p&gt;For the fab, it means automated transport systems and stocker robots can interface with any tool’s load ports in a generic way. For integration engineers, it means far fewer special cases or custom scripts for carrier handling, the differences between vendors have largely been abstracted away.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Standardized Recipes and Jobs&lt;/code&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With E40 and E94, the mechanism to select recipes and run jobs is standardized. A factory host can create a process job (E40) for Tool X or Tool Y and the procedure to start that job is identical. The host knows it can pause or abort the job via the defined GEM messages, and the tool will respond in a known manner. E94’s control jobs allow the host to manage multi-lot workflows in a uniform style across the fab.&lt;/p&gt;
&lt;p&gt;This has enabled fabs to implement advanced scheduling and dispatching systems that treat all tools consistently, since the interface for queuing jobs and moving wafers between carriers doesn’t change from one vendor’s tool to another. The net result is improved interoperability, equipment from different manufacturers can cooperate in a single automated flow because they adhere to the same job control semantics.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Comprehensive State Models &amp;amp; Data&lt;/code&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GEM 300 forced equipment makers to expose much more of their internal state in a standard way.&lt;/p&gt;
&lt;p&gt;For example, beyond just the basic “equipment status” (which was defined in SEMI E10), GEM 300 standards define specific state machines for load ports (E87), for process jobs (E40/E94), and track each wafer’s state (E90). This means the MES can rely on getting detailed, &lt;code&gt;real-time data about what each tool is doing at every step&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Every GEM 300-compliant machine will report events like &lt;em&gt;Carrier Ready to Unload&lt;/em&gt;, &lt;em&gt;Job Started&lt;/em&gt;, &lt;em&gt;Wafer Completed&lt;/em&gt;, etc., using the same event IDs and formats. This predictability has made factory software much simpler, the integration layer can subscribe to a known set of standard events and be confident that if, say, “Lot ABC finished processing,” the tool will send the predefined E90/E94 events to indicate exactly that. Essentially, GEM 300 replaced ambiguity with explicit, standard messages and states.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;GEM 300 transformed the semiconductor equipment interface from a loose framework into a &lt;code&gt;predictable, standards-driven platform&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Factory integration engineers no longer have to &lt;code&gt;reverse-engineer each tool’s quirks or write extensive custom code&lt;/code&gt; for basic operations, those are now covered by the GEM 300 “common language.” The result is a semiconductor fab where tools from any vendor can be integrated with far less effort and with confidence that they will “speak” identically in terms of carriers, jobs, recipes, and wafer tracking. This &lt;em&gt;de facto&lt;/em&gt; standardization has enabled the highly automated 300 mm “smart fabs” of today, where material moves efficiently and processes are tightly coordinated by the host. In contrast to the past, when “each connection took far too much time to complete” due to variability, modern fabs enjoy a plug-and-play ecosystem – a testament to the power of the GEM 300 standards in achieving true interoperability.&lt;/p&gt;
&lt;p&gt;For CM MES this allowed for a whole new way of addressing SECS-GEM as a communication protocol, we moved from tailored solutions made for each machine into an &lt;code&gt;adaptable template&lt;/code&gt;. This has massively impacted implementation time and allowed us to partner with equipment vendors in order to have a streamlined path for interface and control.&lt;/p&gt;
&lt;p&gt;The reality in 2025 is that a GEM 300-compliant interface is expected on semiconductor equipment, and it delivers a level of integration predictability that was simply not possible in the early days of SECS/GEM. This predictability and consistency continue to be crucial as the industry pushes toward even greater automation and Industry 4.0 capabilities.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Industry News, Trends and Technology, and Standards Updates | SECS/GEM (2)
&lt;a
href="https://www.cimetrix.com/blog/topic/secsgem/page/2"
target="_blank"
&gt;https://www.cimetrix.com/blog/topic/secsgem/page/2&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GEM300 Introduction | Agileo Automation
&lt;a
href="https://www.agileo.com/en/resources/gem300-introduction"
target="_blank"
&gt;https://www.agileo.com/en/resources/gem300-introduction&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GEM300 - 300mm SEMI Standard for Factory Automation
&lt;a
href="https://www.cimetrix.com/gem300"
target="_blank"
&gt;https://www.cimetrix.com/gem300&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GEM300 SEMI Standards - PEER Group
&lt;a
href="https://www.peergroup.com/gem300-semi-standards/"
target="_blank"
&gt;https://www.peergroup.com/gem300-semi-standards/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SEMI GEM300 Standards - PDF Solutions
&lt;a
href="https://www.pdf.com/standards/semi-gem-300-standards/"
target="_blank"
&gt;https://www.pdf.com/standards/semi-gem-300-standards/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SEMI E40: Process Job Management - PEER Group
&lt;a
href="https://www.peergroup.com/definition-of-standard/semi-e40/"
target="_blank"
&gt;https://www.peergroup.com/definition-of-standard/semi-e40/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SEMI E87 - Specification for Carrier Management (CMS) - PDF Solutions
&lt;a
href="https://www.pdf.com/standards/semi-e87-specification-for-carrier-management-cms/"
target="_blank"
&gt;https://www.pdf.com/standards/semi-e87-specification-for-carrier-management-cms/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SEMI E90: Substrate Management - PEER Group
&lt;a
href="https://www.peergroup.com/definition-of-standard/semi-e90/"
target="_blank"
&gt;https://www.peergroup.com/definition-of-standard/semi-e90/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SEMI E94 - Specification for Control Job Management - PDF Solutions
&lt;a
href="https://www.pdf.com/standards/semi-e94/"
target="_blank"
&gt;https://www.pdf.com/standards/semi-e94/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GEM300 Tutorial Overview | SEMI
&lt;a
href="https://www.semi.org/en/standards-watch-2021Dec/gem300-tutorial-overview"
target="_blank"
&gt;https://www.semi.org/en/standards-watch-2021Dec/gem300-tutorial-overview&lt;/a&gt;&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20260105-gem300/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20260105-gem300/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Understanding OPC-UA Redundancy</title>
<link>https://j-roque.com/posts/20251230-opcuaredundancy/</link>
<pubDate>Tue, 30 Dec 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20251230-opcuaredundancy/</guid>
<description>Understanding OPC-UA Redundancy</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20251230-opcuaredundancy/featured.png" alt="Understanding OPC-UA Redundancy" /&gt;&lt;/p&gt;&lt;p&gt;Industrial systems often require continuous operation, where even a brief downtime can be costly. Even protocols that were traditionally more lax have over time introduced strategies to improve or bring about high availability and tighten security.&lt;/p&gt;
&lt;p&gt;In OPC-UA redundancy is a concern in order to guarantee the maximum up time possible. In the past OPC-UA servers have been tightly coupled either with the physical hardware of the machine or very near it. This makes redundancy rather pointless, as if the machine is in fact not operating, having a running OPC-UA server won&amp;rsquo;t be of much help.&lt;/p&gt;
&lt;p&gt;With the continuous improvement of PLC capabilities, OPC-UA is now also being used for line or multi-machine scenarios and even for abstraction layers of other OPC-UA servers. As OPC-UA goes farther from the machine, redundancy becomes a greater concern.&lt;/p&gt;
&lt;p&gt;In this article we are going through what OPC-UA specification guidelines on how we can build redundancy of OPC-UA servers.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;First off, if we have no redundancy strategy when your OPC-UA server goes down, all your OPC-UA clients will lose their connection and session.&lt;/p&gt;
&lt;p&gt;In OPC-UA, redundancy means devising ways to have backups to our server going down. Redundancy can be crucial in scenarios where systems must run 24/7, because it eliminates the single point of failure at the server level. In OPC-UA high availability is always guaranteed by somehow running redundant server sets, let&amp;rsquo;s see what are the ways this can operate.&lt;/p&gt;
&lt;p&gt;OPC UA defines two general modes of server redundancy: &lt;code&gt;transparent&lt;/code&gt; and &lt;code&gt;non-transparent&lt;/code&gt;. The difference lies in who handles the failover.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;transparent redundancy&lt;/code&gt;, the switch-over from one server to another is hidden from the client – the client isn’t even aware a failover occurred.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;non-transparent redundancy&lt;/code&gt;, the client is aware of the redundant servers and is responsible for detecting a failure and reconnecting to an alternate server.&lt;/p&gt;
&lt;p&gt;Both approaches aim to keep data flowing, but they place responsibilities on different parts of the system. We’ll examine each approach and the specific standby modes that non-transparent redundancy supports.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Transparent vs. Non-Transparent Redundancy
&lt;div id="transparent-vs-non-transparent-redundancy" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#transparent-vs-non-transparent-redundancy" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Transparent redundancy is the mode more analogous to other classical architectures. Where the client does not need to know that is being rerouted to a different server. In most web applications, when accessing a URL that is behind a proxy able to load balance, he will direct your request to whichever host is available to serve you, without the client having any idea that is being rerouted or if there are servers up or down.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Transparent Mode"
src="https://image.j-roque.com/posts/20251230-opcuaredundancy/Transparent.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In a &lt;code&gt;transparent&lt;/code&gt; redundancy setup, all servers in the group present themselves as a single server to the outside world. They typically share one Server URI and one Endpoint URL (often achieved via a virtual IP or network load balancer). The client connects as if to a single server and does not need any special logic to handle failover.&lt;/p&gt;
&lt;p&gt;Failover is handled by the infrastructure or cluster: when the primary server fails, the system automatically directs client communications to a backup server. The client continues sending and receiving data as normal, oblivious to the switch.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;advantage&lt;/code&gt; of transparent redundancy is its centralized approach any OPC UA client can benefit from redundancy without modification.
The &lt;code&gt;downside&lt;/code&gt; is the complexity on the server side and network: you need a robust clustering setup (for example, a virtual IP address or network load balancer that fronts multiple servers) and typically a mechanism to keep the servers’ state in sync.&lt;/p&gt;
&lt;p&gt;Even though it has a lot of advantages and this architecture is ubiquitous in the web world, for OPC-UA transparent redundancy is less common in practice because of the higher infrastructure effort.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Transparent Redundancy"
src="https://reference.opcfoundation.org/api/image/get/17/image029.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;non-transparent&lt;/code&gt; redundancy, each server in the redundant set has its own network identity (unique endpoint URLs and Server URI). This is already a big difference from transparent mode, as it delegates responsibility to the client of being aware that there are multiple endpoints able to serve.&lt;/p&gt;
&lt;p&gt;Therefore, the clients must be redundancy-aware, it knows about multiple servers and takes action during a failover. Essentially, the client manages the redundancy.&lt;/p&gt;
&lt;p&gt;OPC UA provides standard structures in the server’s address space so that a &lt;code&gt;client can discover the other servers&lt;/code&gt; in the group and understand the redundancy configuration. Each server exposes a ServerRedundancy object that indicates the redundancy mode and lists the servers in the set. Using a standard discovery service, the client can retrieve the Application Descriptions (endpoints) of all servers in the group.&lt;/p&gt;
&lt;p&gt;This means the client can connect to one server initially and learn about the backups automatically, rather than being pre-configured with all server addresses. The client then monitors the health of the current server and switches to a backup server if needed.&lt;/p&gt;
&lt;p&gt;Non-transparent redundancy shifts complexity to the client, but it gives the client fine-grained control over failover timing and selection. Modern OPC UA clients or middleware often include logic to handle non-transparent failover.&lt;/p&gt;
&lt;p&gt;Transparent redundancy requires a more sophisticated server clustering but lets even simple clients achieve high availability with no special code, but it guarantees absolutely no interruption in client connectivity. Non-transparent redundancy is easier to implement on the server side (no cluster IP needed) but requires clients to be aware of redundancy and handle reconnection logic.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Redundant Server Sets and Data Synchronization
&lt;div id="redundant-server-sets-and-data-synchronization" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#redundant-server-sets-and-data-synchronization" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;When configuring any OPC UA redundancy (transparent or non-transparent), all servers in the redundant set must behave like identical twins from the client’s perspective.&lt;/p&gt;
&lt;p&gt;The OPC UA specification requires that redundant servers have an &lt;code&gt;identical AddressSpace&lt;/code&gt;, including the same NodeIDs, browse paths, and data structure. In other words, the &lt;code&gt;information model and data provided by Server A should be indistinguishable from Server B&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is critical: it allows a client to seamlessly switch servers without having to re-discover nodes or fix broken item references. If any changes to the address space occur (adding or removing nodes), they should be propagated to all servers in the group to maintain consistency. If by any chance they are not the same, the failover to the new server may not work or provide inconsistent data to the OPC-UA clients.&lt;/p&gt;
&lt;p&gt;Beyond the address space, servers in a redundancy group must keep certain runtime data in sync to avoid inconsistencies. Time synchronization is important, all servers should have synchronized clocks (using NTP or PTP) so that timestamps on data and events are aligned.&lt;/p&gt;
&lt;p&gt;For example, if one server is a few seconds off, a client switching over might see timestamps jump backward or forward, which can confuse time-series processing. Another subtle issue is Event synchronization: in some redundancy modes, each server might generate events (alarms, conditions) with unique identifiers. If not handled, a client could mistakenly process the same real-world event twice after a failover (once from each server, with different Event IDs). The OPC UA spec addresses this by requiring that in fully synchronized modes (Transparent and HotAndMirrored), servers coordinate their EventIds to be unique across the cluster. Similarly, historical data or any buffered data should be kept consistent if possible, or the client may need to reconcile data from before/after the switch.&lt;/p&gt;
&lt;p&gt;Another key concept is ServiceLevel. This is a standard variable (Byte value 0–255) exposed by each server to indicate its current ability to serve data. Think of ServiceLevel as a health or priority score: the primary server will typically report the maximum value (e.g. 255) when fully operational, while a backup server might report a lower value (or 0 if not ready). Clients can read or subscribe to ServiceLevel on each server to decide which server is “active” or to detect when a server becomes unhealthy (ServiceLevel dropping to 0 or 1 signals a failure in some implementations). Ensuring all redundant servers correctly update their ServiceLevel is a best practice, as it provides a simple trigger for failover logic on the client side. Some vendors use fixed ServiceLevel values for primary vs. secondary (for example, one vendor’s redundant PLC OPC UA server sets primary = 255, secondary = 227). The OPC UA specification doesn’t fix exact values for each role, but it mandates that the highest ServiceLevel indicates the server currently able to supply data, and that value changes appropriately on failure or recovery.&lt;/p&gt;
&lt;p&gt;In summary, a redundant server set (or redundancy group) is a group of OPC UA servers presenting one logical data source. They must mirror each other’s address space and keep important state synchronized. As we saw, this may not be a trivial solution.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Non-Transparent Redundancy Modes (Cold, Warm, Hot, Mirrored)
&lt;div id="non-transparent-redundancy-modes-cold-warm-hot-mirrored" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#non-transparent-redundancy-modes-cold-warm-hot-mirrored" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Non-transparent redundancy supports several failover modes that define how the backup servers operate relative to the primary. These are commonly referred to as &lt;code&gt;Cold&lt;/code&gt;, &lt;code&gt;Warm&lt;/code&gt;, &lt;code&gt;Hot&lt;/code&gt;, and &lt;code&gt;HotAndMirrored&lt;/code&gt; redundancy. The modes differ in how actively the secondary servers run and how fast a failover can happen.&lt;/p&gt;
&lt;p&gt;Let’s explore each mode:&lt;/p&gt;
&lt;h3 class="relative group"&gt;Cold Failover
&lt;div id="cold-failover" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cold-failover" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;Cold redundancy&lt;/code&gt;, only one server is active at any given time, by itself it does not avoid some data loss.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;backup server(s) are essentially off or not running the OPC UA application until needed&lt;/code&gt;. This could mean the secondary servers are powered down, or the machines are on but the OPC-UA server software isn’t started until a failover.&lt;/p&gt;
&lt;p&gt;If the primary server fails, a backup must be started and then the client connects to it. This involves the longest delay and as the name implies is related with having a cold start of the backup OPC-UA server.&lt;/p&gt;
&lt;p&gt;The client can only be connected to one server at a time in cold mode. Upon detecting a failure (for instance, the client loses its connection to the primary), the client looks up an alternate server from the redundancy information and attempts to connect to it.&lt;/p&gt;
&lt;p&gt;The failover isn’t instantaneous, there will be a gap while the backup server initializes and the client establishes a new SecureChannel, session, and subscriptions. Data loss or missed updates are possible in this gap. Cold standby is the simplest to set up (only one server runs at a time), but it provides the lowest continuity. It’s suitable when downtime can be tolerated for a short period or when hardware resources are limited.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cold Mode"
src="https://image.j-roque.com/posts/20251230-opcuaredundancy/cold.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cold Failover"
src="https://reference.opcfoundation.org/api/image/get/17/image032.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Think of cold standby as having a spare server in a closet that you boot up when the primary fails, can be effective but not quick.&lt;/p&gt;
&lt;p&gt;From the client perspective, cold redundancy means the client only needs to maintain a connection to the active server. The OPC UA client should cache the list of backup server endpoints (provided by the ServerRedundancy object) and be prepared to create a new session on a backup when the active one disappears. No background communication with the backups is needed during normal operation. The trade-off is that on failure, there’s more work: the client will establish a fresh SecureChannel and Session to the backup, and then re-subscribe to all data items, which takes a certain amount of time, that time is variable based on the amount of data items being subscribed and the capabilities of the machine running the OPC-UA server.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Warm Failover
&lt;div id="warm-failover" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#warm-failover" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;Warm&lt;/code&gt;, the secondary servers are running and available, but &lt;code&gt;they are not actively pulling data&lt;/code&gt; from the field devices while the primary is active.&lt;/p&gt;
&lt;p&gt;This mode is common when the data source (e.g., a PLC or controller) can only accept one connection at a time, the backup servers stay ready but refrain from connecting to the PLC until they become active.&lt;/p&gt;
&lt;p&gt;In warm standby, a client will typically &lt;code&gt;connect to the primary server and may also open connections to the backups purely to monitor their status&lt;/code&gt; (for example, reading their ServiceLevel). Only one server (the primary) actually provides data to clients. The others might return a placeholder or an error like !Bad_NoCommunication!, if you tried to read actual data from them while they are in standby. This indicates they are online but not presently sourcing data.&lt;/p&gt;
&lt;p&gt;If the primary fails, one of the backups will take over the active role. The &lt;code&gt;transition in warm mode is faster than cold because the backup server’s process is already running&lt;/code&gt;, there’s no need to start the application from scratch. However, since the backup was not pulling live data until failover, there &lt;code&gt;could still be a short interruption and some data might not be collected during the switch&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Warm Mode"
src="https://image.j-roque.com/posts/20251230-opcuaredundancy/Warm.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Warm Failover"
src="https://reference.opcfoundation.org/api/image/get/17/image033.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For clients, warm redundancy implies maintaining at least a minimal connection to each backup server before a failover. A best practice is to create a session (and possibly subscriptions in an inactive state) on the backup server(s) in advance. The client can periodically check each server’s ServiceLevel value to see which one is primary (the one with the highest ServiceLevel is active). During normal operation, the client receives data only from the primary. On a failover, the client should activate subscriptions on a backup server (or create them if not pre-created) and start receiving data from it. Because the backup server might need to connect to the device (e.g. PLC) at failover time, there could be a brief delay.&lt;/p&gt;
&lt;p&gt;Warm mode strikes a balance between resource usage and recovery speed – it’s widely used when devices cannot handle parallel connections, but high availability is still required.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Hot Failover
&lt;div id="hot-failover" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hot-failover" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;Hot&lt;/code&gt; redundancy, &lt;code&gt;all servers are fully active&lt;/code&gt; and acquiring data in parallel (assuming the devices/protocols allow it).&lt;/p&gt;
&lt;p&gt;Every server in the redundant set is powered on, running, and typically connected to the data sources (for example, multiple OPC UA servers all reading from the same PLC or sensor network simultaneously). Because of this, a backup in hot mode already has current data at the moment of failover. If one server fails, the &lt;code&gt;client can immediately switch to another&lt;/code&gt; that has been getting the same updates, resulting in minimal to no data loss.&lt;/p&gt;
&lt;p&gt;The servers in a hot set operate independently, with only minimal knowledge of each other, mainly they might be aware of each other’s existence and health, but they don’t necessarily share runtime state (beyond possibly some coordination of ServiceLevel or who should be primary). When a server encounters a fault and drops out, its ServiceLevel will drop to a low value, signaling clients that it’s no longer suitable. Conversely, when that server comes back, it will announce itself with a ServiceLevel indicating it’s available again (likely as a backup until it catches up or is manually restored to primary).&lt;/p&gt;
&lt;p&gt;Hot redundancy generally provides the highest availability short of full state mirroring. However, it requires that the underlying devices or data sources can handle multiple concurrent connections. Not all PLCs or sensors allow that, so you must verify device capability (or use a data aggregation mechanism) for hot mode to work. Additionally, running all servers in parallel consumes more bandwidth and resources, since each server is doing the same work (polling devices, processing data).&lt;/p&gt;
&lt;p&gt;For OPC UA clients, hot redundancy is the most involved mode, because clients may choose to maintain subscriptions with multiple servers simultaneously. The OPC UA specification actually outlines two strategy options for clients in hot mode:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Hot A"
src="https://image.j-roque.com/posts/20251230-opcuaredundancy/HotA.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Option (&lt;code&gt;a&lt;/code&gt;): &lt;code&gt;One Reporting, Others Sampling&lt;/code&gt;. The &lt;code&gt;client connects to all servers and creates identical subscriptions on each.&lt;/code&gt; However, it initially enables data publishing (reporting) on only one server (the one with highest ServiceLevel), while the other servers’ subscriptions are kept active in sampling mode only (they collect values internally but do not send updates to the client). If the primary fails, the &lt;code&gt;client then enables reporting on one of the backups&lt;/code&gt; (which already has a buffer of recent samples) and thus continues receiving data with minimal interruption. To make this seamless, the client should set a suitable queue size on monitored items so that any data changes during the failover window are buffered and can be delivered once reporting is switched over. This approach avoids duplicate data flow during normal operation but &lt;code&gt;requires the client to orchestrate turning reporting on/off per server&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Hot B"
src="https://image.j-roque.com/posts/20251230-opcuaredundancy/HotB.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Option (&lt;code&gt;b&lt;/code&gt;): &lt;code&gt;All Reporting (Parallel feeds)&lt;/code&gt;. The &lt;code&gt;client subscribes to all servers and lets each server report values concurrently&lt;/code&gt;. The client then receives multiple streams of the “same” data. It &lt;code&gt;must filter out duplicates&lt;/code&gt; (for example, by using timestamps or sequence numbers to ignore older/duplicate values). The upside is that the client always has data from every server, so a failover is trivial – if one stream stops, the other is already providing data. The downside is increased network load and complexity in the client to reconcile parallel data. This approach might be used when &lt;code&gt;absolutely zero data drop is desired and the data update rate is not too high, or when a client is sophisticated enough to merge data streams&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In either hot strategy, clients should also monitor each server’s ServiceLevel continuously to identify which server is considered “primary” at any moment. Typically, &lt;code&gt;clients direct write or command actions to the server with the highest ServiceLevel&lt;/code&gt; (the primary) to avoid sending control commands twice. If a server fails, the client will see its ServiceLevel drop (often to 0) and will then interact with the next-highest server. Notably, automatic fail-back is not usually forced – if a failed server comes back, a client may or may not switch back to it immediately. The OPC UA spec suggests that clients should establish a connection to the recovered server, but not necessarily fail back automatically. It’s up to the application’s needs whether to revert to the original primary or continue with the new server as primary.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Hot Failover"
src="https://reference.opcfoundation.org/api/image/get/17/image034.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Hot And Mirrored Failover
&lt;div id="hot-and-mirrored-failover" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hot-and-mirrored-failover" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;HotAndMirrored&lt;/code&gt; is the most advanced redundancy mode defined in OPC UA.&lt;/p&gt;
&lt;p&gt;It combines the always-on approach of Hot standby with an additional twist: the servers actively mirror their internal state with each other. In HotAndMirrored mode, &lt;code&gt;multiple servers can be fully active and in sync to the point that they share session information, subscription state, monitored item queues&lt;/code&gt;, and so on.&lt;/p&gt;
&lt;p&gt;This means when a client is connected to one server, that server is propagating the client’s session and subscription state to its peer servers in the background. If a failover happens, the client does not need to create a new session or new subscriptions at all – the backup server already has an up-to-date copy of the session, including what data was being monitored. The &lt;code&gt;client can simply switch the SecureChannel to another server and call ActivateSession&lt;/code&gt;, and the session continues on the new server as if nothing happened. This results in a seamless failover: no data needs to be resubscribed, and the client’s context is maintained.&lt;/p&gt;
&lt;p&gt;In fact, with HotAndMirrored redundancy, a client typically only actively connects to one server at a time (unlike hot mode where it might connect to all) because it trusts that the servers are mirroring everything behind the scenes. The client might optionally keep secondary sessions open to other servers just to be ready, but it should not duplicate subscriptions on them (to avoid unnecessary load). Instead, it can occasionally poll the backups’ ServiceLevel or use heartbeat mechanisms to ensure they’re alive.&lt;/p&gt;
&lt;p&gt;The benefit of HotAndMirrored is near-zero interruption and no need for reinitialization on failover – ideal for mission-critical systems that demand transparency and control. It effectively offers the transparency of a cluster (since the client’s session is preserved) while still being a non-transparent approach (the client is aware of multiple endpoints, but failover is very fast). Clients still initiate the failover (e.g., when they notice a drop in connection or ServiceLevel they perform the channel switch and ActivateSession), but this process is much faster than creating new sessions and subscriptions.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Hot and Mirrored Failover"
src="https://reference.opcfoundation.org/api/image/get/17/image035.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Due to all servers sharing the load of maintaining client state, this mode can facilitate &lt;code&gt;load balancing&lt;/code&gt;: clients might connect to the server with highest ServiceLevel for reads/writes, and the system could distribute clients across multiple servers, since each server knows the others will mirror the sessions anyway. HotAndMirrored is the most resource-intensive and complex mode – servers must have a high-speed synchronization mechanism for state, and network and CPU overhead is higher to keep everything mirrored. It’s typically found in high-end systems (for example, redundant PLC pairs or DCS systems with redundant OPC UA interfaces) where maximum availability is needed.&lt;/p&gt;
&lt;p&gt;This method is very similar to the transparent mode, it just delegates to the client the assignment of the server.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Client Interaction and Failover Mechanisms
&lt;div id="client-interaction-and-failover-mechanisms" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#client-interaction-and-failover-mechanisms" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Designing for redundancy means considering both sides of the OPC UA communication: the servers must be configured appropriately, and the clients must know how to react. It also means understanding what is acceptable threshold for data loss and data recovery.&lt;/p&gt;
&lt;p&gt;There are many aspects to consider when deciding not just the failover mode, but how to rollback into a normal state (fail-back). Some systems prefer a manual or delayed fail-back to avoid transitioning between servers. OPC-UA leaves this decision to the client/application design. A best practice is to allow the now-active backup to continue servicing until it’s convenient to switch back (for example, during a low-load period) or not switch back at all if it isn’t necessary. The redundant servers will update their ServiceLevel values as they change roles, so a client could detect when the original primary is healthy again (it might report a high ServiceLevel on return). The client may then either ignore it (staying with the current server) or orchestrate a controlled switch back to it, depending on requirements.&lt;/p&gt;
&lt;p&gt;Security is another aspect to consider: &lt;code&gt;clients must trust all the servers in a redundancy set&lt;/code&gt;. This means deploying the necessary SSL/TLS certificates or trust lists on clients for each server, since a client might end up connecting to any of them. The discovery mechanism (FindServers) helps here by providing application certificates of the alternate servers in the response, but an integrator should ensure the trust chain is in place in advance so that a failover connection isn’t blocked by an untrusted certificate.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Real-World Applications and Best Practices
&lt;div id="real-world-applications-and-best-practices" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#real-world-applications-and-best-practices" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;OPC-UA server redundancy is used anywhere reliability and uptime are critical and where the failure of the server does not directly correlate to a machine failure.&lt;/p&gt;
&lt;p&gt;Common examples include large manufacturing lines, power generation and distribution systems, oil &amp;amp; gas facilities, building automation for mission-critical infrastructure, and other scenarios under the Industry 4.0/IIoT umbrella where data must always be available.&lt;/p&gt;
&lt;p&gt;Consider a SCADA system monitoring a power grid: an OPC UA server polls hundreds of substations. If that server goes down, even for a few minutes, operators could lose visibility into the grid’s status. By deploying a secondary OPC UA server (perhaps on a separate physical machine or a fault-tolerant PLC module) in a redundant configuration, the SCADA client can instantly switch to the backup and continue receiving telemetry.&lt;/p&gt;
&lt;p&gt;Another scenario is in batch manufacturing: an MES (Manufacturing Execution System) client might subscribe to a stream of quality data from an OPC UA server. Redundancy ensures that if the primary server or its PC reboots, production doesn’t have to stop; the MES seamlessly pulls from the backup server. In building automation, redundant OPC UA servers might interface with HVAC and security systems to guarantee that critical alarms (fire, intrusion, etc.) are always delivered to monitoring stations, even if one server fails.&lt;/p&gt;
&lt;p&gt;When implementing OPC UA redundancy, here are some best practices to consider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Match Servers 1:1 with Data Sources&lt;/code&gt;: Ensure your redundant servers have equal access to the data source. If you have redundant PLCs (primary/secondary controllers), it often makes sense to pair each OPC UA server with a specific PLC. If you have a single data source (single PLC), check if it supports multiple simultaneous OPC UA sessions for hot redundancy. If not, you may be limited to warm or cold modes. Design the architecture (including network topology) such that a failure in one component doesn’t cut off all servers. For example, redundant servers should ideally run on separate hardware or VMs, and if possible on separate network paths to the client (some systems use dual-network redundancy, where each server communicates over independent networks for higher resilience).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Keep Configurations Identical&lt;/code&gt;: As noted, all servers in the set should have the same address space and data. Use configuration management to deploy the same OPC UA information model, namespace, and NodeIDs on each. If the server is pulling data from external devices, make sure the polling rates, timeouts, and item lists are the same. Any difference could lead to inconsistent data or client errors when switching. Also synchronize user permissions and roles across servers, so that a user/client authorized on one server doesn’t get denied on the backup. Essentially, treat the redundant set as a single system during configuration.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Time Synchronization&lt;/code&gt;: Always synchronize system clocks of redundant servers. This will ensure that data timestamps and event times are coherent regardless of which server provided them. It also helps when the client is merging data streams or comparing sequences – if one server’s clock lags, its data might appear out-of-order. Using an NTP server or IEEE 1588 PTP on all machines is highly recommended. Furthermore, design your data collection with buffering in mind: if using hot or warm standby, consider configuring a buffer (queue) in subscriptions to catch data changes during the small failover window. This can prevent data loss at the client side.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Test Alarm and Condition Handling&lt;/code&gt;: If your system uses OPC UA Alarms &amp;amp; Conditions, make sure to handle them correctly in a failover. As discussed, in Cold/Warm/Hot modes, &lt;code&gt;event IDs won’t be synchronized between servers&lt;/code&gt;. This means after a failover, the client might not know which alarms are still active or which have already been reported. The recommended practice is for the client to call a ConditionRefresh on the backup server as soon as it connects, which forces the server to resend the current alarm states. This way, the client can reconcile any alarms that might have been missed or duplicated during the transition. It’s a simple step but often overlooked – integrators should verify that their client software performs this (many HMI/SCADA systems do a refresh on reconnect by default for this reason).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Use ServiceLevel and Heartbeats&lt;/code&gt;: Leverage the ServiceLevel value and/or heartbeat mechanisms (such as a subscribed status node or heartbeat signal) to detect failures promptly. Ensure that the &lt;code&gt;servers are configured to downgrade their ServiceLevel instantly on a critical failure&lt;/code&gt; if possible. Some OPC UA servers might even &lt;code&gt;generate an Event or audit entry when failover occurs&lt;/code&gt; – if available, subscribe to those for logging or operator notification. Also consider exposing a diagnostic in your client UI that shows which server (primary or secondary) is currently active, so operators are aware of the system’s state.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Plan the Failover Policy&lt;/code&gt;: Decide whether the failover should be automatic or manual. OPC-UA gives you the tools for automatic client-side failover, but in certain industries, operators prefer to manually confirm a switch to backup (to avoid false triggers). Similarly, decide on fai-back: will the system automatically revert to the primary when it’s back, or run on the secondary until a maintenance window? There’s no one-size-fits-all answer – it depends on the process criticality and stability of your servers. What’s important is to configure timeouts and thresholds in the client so that it doesn’t oscillate or switch unnecessarily. For example, you might require a server’s ServiceLevel to be bad for a few consecutive checks or a few seconds before declaring it failed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Document and Train&lt;/code&gt;: Document the redundancy setup for your operations team. This includes which server is primary, what the roles of each are, and how to force a switch if needed. Provide guidelines on how to bring a failed server back into the group (ensuring it has the latest data/state if required, before letting clients use it). Train personnel on monitoring both servers – for instance, a secondary server might silently fail and not be noticed until failover is needed, which is too late. &lt;code&gt;Regularly test switchover&lt;/code&gt; procedures to make sure everything works as designed and to keep the team familiar with the process.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;OPC-UA server redundancy is a powerful feature for building resilient industrial systems. It also is increasing in demand as OPC-UA servers start being federators of multiple machines and aggregators of other OPC-UA servers.&lt;/p&gt;
&lt;p&gt;I personally &lt;code&gt;always suggest having a transparent redundancy&lt;/code&gt;. It is much easier to control your OPC-UA server, than all the OPC-UA clients that exist throughout your shopfloor. Also, remember that you may have some OPC-UA clients that support one mode and not the other. It is always preferable to handle this in a centralized way and remove that complexity from all the clients that may exist.&lt;/p&gt;
&lt;p&gt;Nevertheless, by understanding the nuances of transparent vs. non-transparent modes and the spectrum from cold standby to hot mirrored standby, system integrators can design architectures that meet their uptime requirements. The key is clear planning: align the redundancy mode with your hardware capabilities (device connection limits, network design), and ensure your &lt;code&gt;client applications are equipped to handle the chosen mode’s failover process&lt;/code&gt;. Modern OPC UA toolkits simplify a lot of this, but it’s still crucial to know what’s happening under the hood so you can troubleshoot and optimize the failover performance.&lt;/p&gt;
&lt;p&gt;Remember that redundancy is not just about technology – it’s about &lt;code&gt;operational continuity&lt;/code&gt;. Thus, always consider the operational scenario:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How quickly do we need to recover? How much data can we afford to lose, if any?&lt;/li&gt;
&lt;li&gt;Who or what detects the failure?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The OPC UA standard provides a flexible framework to answer these questions, and with proper implementation, you can achieve near-zero downtime in the face of server failures.
In summary, OPC UA server redundancy ensures that data keeps flowing even when servers don’t. By deploying redundant server sets and leveraging OPC UA’s built-in redundancy support, you can build industrial systems that are robust against failures. Whether you choose a straightforward cold standby or invest in a hot mirrored solution, the result is the same in principle: higher reliability and confidence that your critical data will always be available when you need it. With careful design and adherence to best practices, OPC UA redundancy becomes a dependable backbone for high-availability automation systems, keeping factories running, lights on, and processes under control even in the face of unexpected outages.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;UA Part 4: Services - 6.6.2 Server Redundancy - &lt;a
href="https://reference.opcfoundation.org/Core/Part4/v104/docs/6.6.2"
target="_blank"
&gt;https://reference.opcfoundation.org/Core/Part4/v104/docs/6.6.2&lt;/a&gt;
Transparent redundancy - Unified Automation Forum - &lt;a
href="https://forum.unified-automation.com/viewtopic.php?t=611"
target="_blank"
&gt;https://forum.unified-automation.com/viewtopic.php?t=611&lt;/a&gt;
Redundancy - &lt;a
href="https://infosys.beckhoff.com/content/1033/tf6100_tc3_opcua_server/17803657739.html?id=4892807151891666650"
target="_blank"
&gt;https://infosys.beckhoff.com/content/1033/tf6100_tc3_opcua_server/17803657739.html?id=4892807151891666650&lt;/a&gt;
Non-transparent Mode (non-transparent Redundancy) - &lt;a
href="https://docs.tia.siemens.cloud/r/simatic_et_200eco_pn_manual_collection_eses_20/function-manuals/communication-function-manuals/communication/communication-with-the-redundant-system-s7-1500r/h/using-an-opc-ua-server-in-an-s7-1500r/h-system/non-transparent-mode-non-transparent-redundancy?contentId=wSatTJReGHhcjO9sDY_tUA"
target="_blank"
&gt;https://docs.tia.siemens.cloud/r/simatic_et_200eco_pn_manual_collection_eses_20/function-manuals/communication-function-manuals/communication/communication-with-the-redundant-system-s7-1500r/h/using-an-opc-ua-server-in-an-s7-1500r/h-system/non-transparent-mode-non-transparent-redundancy?contentId=wSatTJReGHhcjO9sDY_tUA&lt;/a&gt;
UA Part 5: Information Model - 12.5 RedundancySupport - &lt;a
href="https://reference.opcfoundation.org/Core/Part5/v104/docs/12.5"
target="_blank"
&gt;https://reference.opcfoundation.org/Core/Part5/v104/docs/12.5&lt;/a&gt;
OPC UA Client - Redundancy - WinCC OA - &lt;a
href="https://www.winccoa.com/documentation/WinCCOA/3.18/en_US/OPC_UA/opc_ua_c_redundancy.html"
target="_blank"
&gt;https://www.winccoa.com/documentation/WinCCOA/3.18/en_US/OPC_UA/opc_ua_c_redundancy.html&lt;/a&gt;&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20251230-opcuaredundancy/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20251230-opcuaredundancy/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Machine Learning a Manufacturing Point of View</title>
<link>https://j-roque.com/posts/20251215-machinelearning/</link>
<pubDate>Mon, 15 Dec 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20251215-machinelearning/</guid>
<description>Machine Learning a Manufacturing Point of View</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20251215-machinelearning/featured.png" alt="Machine Learning a Manufacturing Point of View" /&gt;&lt;/p&gt;&lt;p&gt;This blog post will be a very high level and utilitarian overview of machine learning and its application in the shopfloor.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;It&amp;rsquo;s important to understand what is happening when we are making predictions and why is machine learning different from other approaches.&lt;/p&gt;
&lt;p&gt;The first step when trying to make accurate predictions about reality is to trim down and narrow our scope of prediction. We break down the phenomenon into a set of inputs that are the context and set of outputs which are the result or outcome of our prediction.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Studying Reality"
src="https://image.j-roque.com/posts/20251215-machinelearning/StudyingReality.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine a simple example of just that. We want to &lt;code&gt;predict housing prices&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Using a statistical approach, we can postulate that housing prices have something to do with the size of the house.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Making a Model"
src="https://image.j-roque.com/posts/20251215-machinelearning/MakingAModel.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can say that as inputs we will have &lt;code&gt;house size&lt;/code&gt; and &lt;code&gt;house prices&lt;/code&gt;. We look at the data and formulate a hypothesis (in this case a linear regression) and say &amp;ldquo;Each additional 500 sqft increases price by about $50,000&amp;rdquo;. Therefore, we can say that being &lt;code&gt;Y, the price of a house&lt;/code&gt; and &lt;code&gt;X the size of a house&lt;/code&gt;, the price of a house can be calculated with &lt;code&gt;Y=100+0.1X&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now we can predict a house price, just by knowing its size, for example if I have a house of 2200 sqft: &lt;code&gt;Y=100+0.1(2200)=320&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This example is very simple and rudimentary. Nevertheless, it already constructs an important way of thinking. We understand intuitively that we have inputs, and that our goal is to create a model of reality, that given those inputs is able to give us accurate predictions.&lt;/p&gt;
&lt;p&gt;The problem we face in our day-to-day is that &lt;code&gt;reality even for narrow problems is very complex&lt;/code&gt;. By complex we mean that it&amp;rsquo;s multi-variate in nature.&lt;/p&gt;
&lt;p&gt;In our simple case we depend just of size, but anyone that has ever looked at buying a house knows that actually a house depends on many more factors. Using traditional deterministic or semi-deterministic models, we would try to collect as much relevant and meaningful inputs and create an explanation in the form of an equation that modelled reality.&lt;/p&gt;
&lt;p&gt;If size is more important than, a pool, then size would be weighted more heavily. This method can become very complex and requires a deep knowledge of the object of study and how all factors interact with each other in order to construct a model.&lt;/p&gt;
&lt;p&gt;As problems become more complex and dynamic other approaches surfaced. This is where machine learning comes from, an attempt to have an evolving probabilistic answer.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Machine Learning"
src="https://image.j-roque.com/posts/20251215-machinelearning/MachineLearning.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we gather all relevant datapoints, but instead of knowing the model and applying a formulaic approach, we will create a feedback mechanism to teach our model to fit to what the right answer is.&lt;/p&gt;
&lt;p&gt;Machine Learning is an exploratory method, focused on &lt;em&gt;describing reality, not on explaining reality&lt;/em&gt;. It is based on feedback mechanisms for auto correction in search of the model that best answers our queries. It has the ability to make predictions and to surface hidden relations or patterns.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Machine Learning
&lt;div id="machine-learning" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#machine-learning" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s try and understand what is machine learning, how it&amp;rsquo;s used and what are the goals and the methods.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Supervised Learning
&lt;div id="supervised-learning" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#supervised-learning" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Is a type of machine learning that &lt;code&gt;trains algorithms on labeled (every input has a matching output) data&lt;/code&gt;. The model is provided with a labeled dataset.&lt;/p&gt;
&lt;p&gt;The algorithm will split the data into a percentage, to &lt;code&gt;train&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;test&lt;/code&gt;. The model will then try and make predictions and compare itself with the validation dataset, as the models receives the feedback of how accurate he is, he will readjust its internal weights to maximize his accuracy.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see a simple example of this. We have a &lt;code&gt;dataset of fruit images and their matching names&lt;/code&gt;. The model will train and validate itself, after that when provided with an &lt;code&gt;image of a fruit&lt;/code&gt;, in this case a strawberry he will be able to &lt;code&gt;predict its name&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="SL Dataset"
src="https://image.j-roque.com/posts/20251215-machinelearning/SLDataset.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The interesting element here is that our image does not need to be one exactly like the one provided in the original dataset, but one close enough, that the model is able to understand it&amp;rsquo;s a strawberry. The model is not making deterministic claims he is making a weighted decision to provide the answer with the highest probability of being correct.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="SL Prediction"
src="https://image.j-roque.com/posts/20251215-machinelearning/SLPrediction.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can start seeing the value of machine learning. We departed from an original dataset and are now able to make predictions about items that are even outside our original dataset.&lt;/p&gt;
&lt;p&gt;We use this in our day to day, google photos uses our photos as a dataset and when we catalog it with names to the faces, he is able to catalog and correctly identify our pictures.&lt;/p&gt;
&lt;p&gt;In supervised learning, generically there are two different goals, let&amp;rsquo;s see what they are.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Classification
&lt;div id="classification" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#classification" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;Classification is when the model is optimized to try and sort elements into different categories. The &lt;code&gt;simplest classification is binary&lt;/code&gt;, yes or no, true or false.&lt;/p&gt;
&lt;p&gt;The model after training and validation will be able to predict, if &lt;code&gt;for a given set of inputs, the output is either true or false&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Classification"
src="https://image.j-roque.com/posts/20251215-machinelearning/Classification.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Here, our model can answer the question:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is this image a strawberry? It would then predict if it&amp;rsquo;s yes or no.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Another type is multiclass classification, here the elements are sorted into more than two categories. Here we would have several categories of fruits, and it would sort the strawberry in the correct category.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Regression
&lt;div id="regression" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#regression" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;Regression, the goal is to be able to &lt;code&gt;predict a continuous numerical outcomes&lt;/code&gt; (like price, temperature, or sales) by finding the mathematical relationship between input features (independent variables) and the target (dependent variable) in historical data, essentially fitting the best line or curve to the data to make future predictions.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;ve already seen this. In our housing example we were doing a &lt;code&gt;linear regression&lt;/code&gt;, in order to predict housing prices.&lt;/p&gt;
&lt;p&gt;In a regression the model is able to &lt;code&gt;fit an algorithm into the best approach, in order to be able to predict the values of the output&lt;/code&gt;. It is possible that the model discovers that the best fit is some kind of linear regression, as the complexity increases the fitted curve can become more complex as well as it tries to fit in N dimensional fields.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Regression"
src="https://image.j-roque.com/posts/20251215-machinelearning/Regression.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Our model, now is not answering things like if an image is or not a strawberry, but as we saw before trying to discover numerical values. Like house prices, temperatures, number of defects, etc.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Unsupervised Learning
&lt;div id="unsupervised-learning" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#unsupervised-learning" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;As we saw before, supervised learning is predicated on having a labeled (every input has a matching output) dataset that we use to train our model and then perform predictions. In &lt;code&gt;unsupervised learning, our data is unlabeled&lt;/code&gt;. The goal here is to &lt;code&gt;find patterns and relationships between the data&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If we have a dataset of unsorted data, with animal pictures, the goal of our model is to be able to create groups or clusters of animals.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Unsupervised Learning"
src="https://image.j-roque.com/posts/20251215-machinelearning/UnsupervisedLearning.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;These models are very helpful. As we saw, they are used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Clustering&lt;/code&gt;: grouping unlabeled data; identify patterns and relationships.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Association Rule Learning&lt;/code&gt;: understanding rules between relationships (i.e. customer that buys milk, also buys cereals).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Dimensionality Reduction&lt;/code&gt;: reduce the number of features without losing information.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Machine Learning in the Shopfloor
&lt;div id="machine-learning-in-the-shopfloor" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#machine-learning-in-the-shopfloor" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We saw the examples and types of algorithms in machine learning, but what do they have to do with reality of the shopfloor?&lt;/p&gt;
&lt;p&gt;In manufacturing, data prediction has to be &lt;strong&gt;exploratory&lt;/strong&gt;, not just purely deterministic and has to adapt to an ever changing reality. That is why machine learning shines in the shopfloor. It does not aim to perfectly determine why something happens and all the confounding factors, instead it focuses on &lt;strong&gt;describing reality well enough to make useful predictions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A helpful way to frame it is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Statistics&lt;/strong&gt; looks at data to validate hypotheses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Machine learning&lt;/strong&gt; looks at data to recognize patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both are valuable, but machine learning aligns better with the variability, noise, and constant change of real production environments. Imperfect predictions, if early and actionable, are often far more valuable than perfect explanations that come too late.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s get back to our types and see how they fit in the shopfloor reality.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Supervised Learning: Learning From Past Outcomes
&lt;div id="supervised-learning-learning-from-past-outcomes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#supervised-learning-learning-from-past-outcomes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Supervised learning works with &lt;strong&gt;labeled data&lt;/strong&gt; — data where the outcome is known.&lt;/p&gt;
&lt;p&gt;On the shopfloor, this is extremely common.&lt;/p&gt;
&lt;p&gt;We have several examples of &lt;code&gt;classification&lt;/code&gt; in every shop-floor:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Pass / Fail&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OK / Defective&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Machine &lt;code&gt;Running / Stopped&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We even have examples of &lt;code&gt;multiclass classification&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Material has Defect &lt;code&gt;A / B / C&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Die has Quality &lt;code&gt;1 / 2 / 3&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Regressions&lt;/code&gt; are also very common:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Yield value&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Production Rate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temperature level&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Typical goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Classification&lt;/strong&gt;: predict a category&lt;br&gt;
&lt;em&gt;Will this material fail inspection?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regression&lt;/strong&gt;: predict a continuous value&lt;br&gt;
&lt;em&gt;What will the final yield be?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Unsupervised Learning: Finding Patterns You Didn’t Define
&lt;div id="unsupervised-learning-finding-patterns-you-didnt-define" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#unsupervised-learning-finding-patterns-you-didnt-define" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Unsupervised learning uses &lt;strong&gt;unlabeled data&lt;/strong&gt;. No predefined answers — just observations.&lt;/p&gt;
&lt;p&gt;This is useful when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You don’t yet know what “bad” looks like&lt;/li&gt;
&lt;li&gt;You want to understand variability&lt;/li&gt;
&lt;li&gt;You want to detect abnormal behavior early&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Typical goals include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clustering&lt;/strong&gt; similar production runs or machine behaviors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pattern discovery&lt;/strong&gt; across parameters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dimensionality reduction&lt;/strong&gt; to identify what really matters&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For engineers, &lt;code&gt;unsupervised learning is often the first step to insight&lt;/code&gt; — helping define what should later become a supervised use case.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Data Quality: Where Most Machine Learnings Projects Go To Die
&lt;div id="data-quality-where-most-machine-learnings-projects-go-to-die" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#data-quality-where-most-machine-learnings-projects-go-to-die" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The most common misconception about machine learning is that the hard part is choosing the algorithm.&lt;/p&gt;
&lt;p&gt;In reality, the hardest part is &lt;strong&gt;data quality&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Machine learning requires:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Structured data&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Clear timestamps&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Consistent units&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Context&lt;/code&gt; (material, machine, process, operator)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Minimal noise&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is exactly where an &lt;code&gt;MES makes the difference&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;An MES already:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Enforces structure through process models&lt;/li&gt;
&lt;li&gt;Tracks genealogy end-to-end&lt;/li&gt;
&lt;li&gt;Normalizes events via canonical data models&lt;/li&gt;
&lt;li&gt;Eliminates ambiguity by design&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of cleaning spreadsheets or reconciling multiple sources, engineers get &lt;strong&gt;ML-ready data as a byproduct of execution&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Why Machine Learning Belongs Inside the MES
&lt;div id="why-machine-learning-belongs-inside-the-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-machine-learning-belongs-inside-the-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;An MES is uniquely positioned for machine learning because it sits at the intersection of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Process&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Action&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MES:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Controls the shopfloor&lt;/li&gt;
&lt;li&gt;Ingests equipment, material, and quality data&lt;/li&gt;
&lt;li&gt;Provides structured datasets&lt;/li&gt;
&lt;li&gt;Executes workflows in real time&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;MES Data Ingestion and Canonical Events
&lt;div id="mes-data-ingestion-and-canonical-events" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mes-data-ingestion-and-canonical-events" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In Critical Manufacturing MES, shopfloor data is ingested through &lt;strong&gt;canonical IoT event definitions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Equipment data&lt;/li&gt;
&lt;li&gt;Material movements&lt;/li&gt;
&lt;li&gt;Telemetry&lt;/li&gt;
&lt;li&gt;Defects&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All follow a consistent structure.&lt;/p&gt;
&lt;p&gt;As data flows in:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Events are normalized and standard&lt;/li&gt;
&lt;li&gt;Context is added automatically&lt;/li&gt;
&lt;li&gt;Data is stored in a unified model&lt;/li&gt;
&lt;li&gt;Datasets are generated and reused&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We can see how a simple change of Resource State generates a CDM event with all the contextual information.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="CDM Event"
src="https://image.j-roque.com/posts/20251215-machinelearning/CDMEvent_fixed.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the MES the user can create their own datasets or use the one&amp;rsquo;s already provided by the system to train and use their machine learning models.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MES ML Diagram"
src="https://image.j-roque.com/posts/20251215-machinelearning/MESMLDiagram.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;When producing CDM Events, we can also run additional business logic, perform third party integrations and run our ML Models to perform predictions.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="CDM Event Diagram"
src="https://image.j-roque.com/posts/20251215-machinelearning/CDMEvents.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;A Simple, Real Use Case: SMT Reflow Oven Defect Prediction
&lt;div id="a-simple-real-use-case-smt-reflow-oven-defect-prediction" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#a-simple-real-use-case-smt-reflow-oven-defect-prediction" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In an SMT line, the reflow oven melts solder paste so components are fixed to the PCB. Temperature anomalies often lead to solder defects, but these defects are only detected later by visual inspection with an AOI machine.&lt;/p&gt;
&lt;p&gt;From the MES perspective:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Oven temperature data is collected as telemetry (&lt;code&gt;Post Telemetry&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Material movement is tracked (&lt;code&gt;Material Movement&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;AOI creates defect records (&lt;code&gt;Material Defect&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This gives us labeled data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Inputs&lt;/strong&gt;: oven temperature profiles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Output&lt;/strong&gt;: pass or fail at AOI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From what we saw before, now it&amp;rsquo;s clear we&amp;rsquo;ve entered into a supervised learning, &lt;code&gt;classification scenario&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="SMT Scenario"
src="https://image.j-roque.com/posts/20251215-machinelearning/SMTScenario.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can already break it down as the set of types of canonical data model events that we will use to create our training dataset.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="SMT CDM"
src="https://image.j-roque.com/posts/20251215-machinelearning/SMTCDM.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Training the Model Inside the MES
&lt;div id="training-the-model-inside-the-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#training-the-model-inside-the-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Using supervised learning:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create an MES dataset correlating oven telemetry with AOI results&lt;/li&gt;
&lt;li&gt;Train a classification model using AutoML&lt;/li&gt;
&lt;li&gt;Normalize and validate automatically&lt;/li&gt;
&lt;li&gt;Deploy the model inside the MES&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Create ML Model"
src="https://image.j-roque.com/posts/20251215-machinelearning/CreateMLModel_fixed.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Train ML Model"
src="https://image.j-roque.com/posts/20251215-machinelearning/TrainMLModel_fixed.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;No scripts. No notebooks. No external platforms.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Making Predictions Part of Execution
&lt;div id="making-predictions-part-of-execution" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-predictions-part-of-execution" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Once deployed, the ML model becomes part of the execution flow.&lt;/p&gt;
&lt;p&gt;As telemetry is ingested, in the Reflow Oven Integration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The MES runs a prediction&lt;/li&gt;
&lt;li&gt;If a defect is likely, a workflow is triggered&lt;/li&gt;
&lt;li&gt;Notifications or actions occur immediately&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Workflow"
src="https://image.j-roque.com/posts/20251215-machinelearning/Workflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Defects are no longer discovered only at inspection — they are &lt;strong&gt;anticipated&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ML Run"
src="https://image.j-roque.com/posts/20251215-machinelearning/MLRun_fixed.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ML Run Notification"
src="https://image.j-roque.com/posts/20251215-machinelearning/MLRunDefect.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Other Examples
&lt;div id="other-examples" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#other-examples" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Outlier Detection
&lt;div id="outlier-detection" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#outlier-detection" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Outlier detection is a process of identifying data points that deviate significantly from the normal pattern in a dataset. These outliers often indicate potential issues such as equipment failures, anomalies in production, or irregular environmental conditions.&lt;/p&gt;
&lt;p&gt;By setting up an outlier detection low code workflow, in CM MES you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proactively address issues before they escalate.&lt;/li&gt;
&lt;li&gt;Monitor production data for unusual trends.&lt;/li&gt;
&lt;li&gt;Integrate alerts and actions for seamless operations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For this particular use case we have a sample with two clear patterns. We need a quick way of segmenting data into two groups: &lt;code&gt;normal&lt;/code&gt; and &lt;code&gt;abnormal&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For that we will use &lt;a
href="https://en.wikipedia.org/wiki/Isolation_forest"
target="_blank"
&gt;&lt;code&gt;isolation forest algorithm&lt;/code&gt;&lt;/a&gt; provided by Data Platform.&lt;/p&gt;
&lt;p&gt;Data Platform comes out of the box with a large selection of algorithms, choose the one more appropriate to your goal.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; After the model is trained, we can use the score parameter to identify anomalies by applying thresholds.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;We will have a low code workflow that is using the machine learning task with the above model to generate predictions. We are then sending the prediction value into a new IoT Event.&lt;/p&gt;
&lt;p&gt;In order for us to be able to see the algorithm selection we created a Grafana dashboard that looks into all the stored values for the new IoT Event.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Outlier Detection"
src="https://image.j-roque.com/posts/20251215-machinelearning/dashboard_outlier_detection.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the dashboard we can easily see two clusters of data and some anomalies. In our low code workflow we are just visualizing the data, but as mentioned above we could perform further actions depending on the prediction.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Predictive Maintenance
&lt;div id="predictive-maintenance" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#predictive-maintenance" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Predictive maintenance has become a cornerstone of the next leap of modern smart manufacturing, and this milling machine use case demonstrates how operational data can be transformed into actionable insight.&lt;/p&gt;
&lt;p&gt;By continuously collecting telemetry such as tool wear, temperatures, torque, rotational speed, and power consumption, the system moves away from reactive maintenance and toward a data-driven, proactive approach. Instead of relying on fixed schedules or operator intuition, maintenance decisions are informed by real machine behavior and statistically validated failure patterns. With an MES we are able to use not only machine data, but also process data, product data and past maintenance data.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Problem Statement"
src="https://image.j-roque.com/posts/20251215-machinelearning/pdm_diagram.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;At the core of the solution is a machine-learning model that estimates Remaining Useful Life (RUL) and classifies multiple failure modes, including tool wear, heat dissipation issues, power anomalies, overstrain, and rare random failures. The model is trained on historical production data and automatically optimized using an AutoML approach, allowing teams to focus on domain knowledge rather than algorithm selection. This design makes the solution both robust and maintainable, while ensuring predictions remain aligned with real-world operating conditions&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Dataset test"
src="https://image.j-roque.com/posts/20251215-machinelearning/dataset_test.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Incoming IoT events trigger predictions that are immediately stored, visualized, and monitored through dashboards, enabling engineers and operators to track degradation trends as they happen. RUL estimates are not static reports but living signals that evolve with machine usage, supporting faster decision-making and reducing unplanned downtime&lt;/p&gt;
&lt;p&gt;Ultimately, this predictive maintenance workflow illustrates how advanced analytics can be operationalized at scale. By closing the loop between data collection, machine learning, and live monitoring, manufacturers gain earlier failure detection, improved asset utilization, and more predictable production outcomes. The result is a practical, production-ready example of how AI can deliver measurable value on the shop floor today—not as an experiment, but as a core operational capability.&lt;/p&gt;
&lt;p&gt;As in the outlier, we can either perform actions based on predictions or setup dashboards to bring visibility.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Dashboard"
src="https://image.j-roque.com/posts/20251215-machinelearning/pdm_dashboard.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Machine Learning as a Feedback Loop
&lt;div id="machine-learning-as-a-feedback-loop" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#machine-learning-as-a-feedback-loop" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Machine learning is not a one-time project.&lt;/p&gt;
&lt;p&gt;Models:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Learn from historical data&lt;/li&gt;
&lt;li&gt;Predict future outcomes&lt;/li&gt;
&lt;li&gt;Receive feedback from reality&lt;/li&gt;
&lt;li&gt;Improve continuously&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MES systems naturally support this loop because outcomes are always recorded. ML becomes a &lt;strong&gt;continuous improvement mechanism&lt;/strong&gt;, not a static tool.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The SMT oven example is intentionally simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One resource&lt;/li&gt;
&lt;li&gt;One outcome&lt;/li&gt;
&lt;li&gt;One prediction&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And yet, it delivers real value. Imagine a applying the same mindset to your whole to crossing information from the whole factory.&lt;/p&gt;
&lt;p&gt;If you already use a CM MES you already have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The data&lt;/li&gt;
&lt;li&gt;The structure&lt;/li&gt;
&lt;li&gt;The context&lt;/li&gt;
&lt;li&gt;The execution layer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Machine learning simply connects the dots.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Which problems are you already solving manually that the MES could help you predict instead?&lt;/strong&gt;&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20251215-machinelearning/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20251215-machinelearning/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Why UNS is not for Semiconductors?</title>
<link>https://j-roque.com/posts/20251211-uns-semiconductors/</link>
<pubDate>Thu, 11 Dec 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20251211-uns-semiconductors/</guid>
<description>Why UNS is not for Semiconductors?</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20251211-uns-semiconductors/featured.png" alt="Why UNS is not for Semiconductors?" /&gt;&lt;/p&gt;
&lt;h1 class="relative group"&gt;Why the Semiconductor Industry Doesn’t Need a Unified Namespace
&lt;div id="why-the-semiconductor-industry-doesnt-need-a-unified-namespace" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-the-semiconductor-industry-doesnt-need-a-unified-namespace" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h1&gt;
&lt;h2 class="relative group"&gt;Introduction
&lt;div id="introduction" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#introduction" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Unified Namespace was a great innovation, not of technology but of architecture and philosophy. In industrial automation, the &lt;strong&gt;Unified Namespace (UNS)&lt;/strong&gt; as a concept is gaining not just traction as a solution to fragmented data systems, but a way to pivot traditional manufacturing, making industry 4.0 a more tangible and reachable goal. It aims to centralize operational data in real-time, enabling machines, sensors, control systems, and business platforms to speak a common language using standards like MQTT. While this is transformative for industries that are behind on their automation and data journey, with siloed systems and rigid ISA-95 hierarchies, it &lt;strong&gt;misses the mark in the semiconductor sector&lt;/strong&gt; &lt;a
href="https://www.symphonyai.com/industrial/unified-namespace-complete-guide/#:~:text=A%20Unified%20Namespace%20,point%20integrations"
target="_blank"
&gt;22&lt;/a&gt;. In the last MESI 4.0 Summit 2025, I had an opportunity to watch live &lt;a
href="https://www.youtube.com/@4.0Solutions"
target="_blank"
&gt;Walker Reynolds of 4.0 Solutions&lt;/a&gt;, pitching all of the virtues of UNS and its ability to impact industries.&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/_bZwzm663fM?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;p&gt;Semiconductor manufacturing is already one of the &lt;strong&gt;most automated and digitally integrated industries&lt;/strong&gt; in the world. Leading-edge fabs operate under tight cleanliness, precision, and yield constraints that have driven extreme automation and deep system interoperability for decades. The goal for decades has not just been to collect data, it became about a deep control of everything that happens in the shopfloor in real-time. In this environment, implementing a UNS adds &lt;strong&gt;redundancy, not value&lt;/strong&gt; &lt;a
href="https://www.criticalmanufacturing.com/industries/semiconductor-manufacturing/"
target="_blank"
&gt;23&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Why UNS Works for Lagging Industries
&lt;div id="why-uns-works-for-lagging-industries" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-uns-works-for-lagging-industries" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In many sectors like food processing, packaging, or metalworking, &lt;strong&gt;data fragmentation is rampant&lt;/strong&gt;. Machines often run in isolation, in the best case, with their data passing through siloed layers: PLCs to SCADA, then to MES, and maybe ERP. This pyramid structure delays insights and creates fragile, expensive integrations. UNS addresses this by creating a real-time, context-aware, centralized data layer where devices publish to a shared broker, dramatically reducing integration costs and latency.&lt;/p&gt;
&lt;p&gt;UNS is therefore a &lt;strong&gt;modern solution to decades-old problems&lt;/strong&gt;, ideal for low-tech or digitally immature operations. It democratizes access to OT data and opens paths to analytics, dashboards, and AI-driven optimization — &lt;strong&gt;but only where these fundamentals are lacking&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t try to be a complex centralized system of shopfloor control, but it leverages decentralized information silos in the shop-floor. It adds context hooks to information from data nodes through the factory. This enables with little effort to move from a babel, where everything talks different languages, into a Babel that comes with yellow pages, of where the information is coming and what it&amp;rsquo;s about. This provides the end user with just enough to try and manipulate and aggregate the data to create information for real-time monitoring.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Semiconductor Manufacturing: The Outlier
&lt;div id="semiconductor-manufacturing-the-outlier" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#semiconductor-manufacturing-the-outlier" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;The Most Automated Industry
&lt;div id="the-most-automated-industry" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-most-automated-industry" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Semiconductor manufacturing is built on &lt;strong&gt;extreme levels of precision, cleanliness, and scale&lt;/strong&gt;, which makes manual intervention infeasible. In state-of-the-art 300mm wafer fabs, &lt;strong&gt;over 85% of wafer transport is handled by robotics&lt;/strong&gt;, and &lt;strong&gt;lights-out operations&lt;/strong&gt; (with no human intervention) are standard &lt;a
href="https://standardbots.com/blog/lights-out-manufacturing"
target="_blank"
&gt;1&lt;/a&gt;. ASE alone operated &lt;strong&gt;56 fully lights-out factories&lt;/strong&gt; by 2024 &lt;a
href="https://www.aseglobal.com/csr/sustainability-governance/smart-factory"
target="_blank"
&gt;2&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All new 300mm fabs now use &lt;strong&gt;automated material handling systems (AMHS)&lt;/strong&gt; such as overhead hoist transport (OHT), which eliminates manual wafer movement. By 2024, &lt;strong&gt;68% of fabs deployed OHT&lt;/strong&gt;, up from 52% in 2020, with the average number of OHT vehicles exceeding 470 per fab &lt;a
href="https://www.marketgrowthreports.com/market-reports/semiconductor-oht-overhead-hoist-transport-market-103785"
target="_blank"
&gt;3&lt;/a&gt;. These systems handle more than &lt;strong&gt;80% of all wafer movement&lt;/strong&gt; in advanced fabs &lt;a
href="https://www.marketgrowthreports.com/market-reports/semiconductor-oht-overhead-hoist-transport-market-103785"
target="_blank"
&gt;4&lt;/a&gt;, and help cut &lt;strong&gt;human-induced defects by nearly 60%&lt;/strong&gt; &lt;a
href="https://www.intelmarketresearch.com/robotsemiconductor-market-9793"
target="_blank"
&gt;5&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Compared to other industries, the semiconductor sector is &lt;strong&gt;decades ahead&lt;/strong&gt;. In 2020, semiconductors and electronics overtook automotive in global robot adoption, accounting for &lt;strong&gt;29% of all new industrial robot installations&lt;/strong&gt;, versus 21% in automotive and ~3% in food and beverage &lt;a
href="https://www.weforum.org/press/2022/02/semiconductors-electronics-and-pharmaceuticals-lead-digital-transformation-in-manufacturing"
target="_blank"
&gt;6&lt;/a&gt;.&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/h5I8i9hNcLU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Deep, Standardized Integration
&lt;div id="deep-standardized-integration" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#deep-standardized-integration" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;While other sectors scramble to unify OT data, semiconductors already &lt;strong&gt;solved that problem in the 1980s and &amp;rsquo;90s&lt;/strong&gt;. For semiconductors, the challenge becomes not how to incorporate these systems in the shopfloor, but how to update all the systems that were built in the past in new software that can give them platforms for advancing into the future. These systems, have become very complex, highly customized and tuned to their companies particular realities. As their systems become legacy, they require strategies to build programs to standardize and migrate into new platforms.&lt;/p&gt;
&lt;p&gt;The industry created the &lt;strong&gt;SECS/GEM communication standard&lt;/strong&gt; (&lt;a
href="https://j-roque.com/posts/20250227-secsgem/"
target="_blank"
&gt;Secs-GEM blog post&lt;/a&gt;), ensuring every tool can report its state, receive recipes, and be managed centrally &lt;a
href="https://www.semi.org/en/standards-watch-2022-Sept/intro-to-semi-communication-standards"
target="_blank"
&gt;8&lt;/a&gt;. Today, fabs use MES (Manufacturing Execution Systems) and host computers to orchestrate thousands of tools and steps with &lt;strong&gt;nanometer-level accuracy and zero human touch&lt;/strong&gt; &lt;a
href="https://thl.com/articles/thls-podcast-ep-3-automation-in-action-beyond-the-chips-how-automation-supercharges-semiconductor-production"
target="_blank"
&gt;9&lt;/a&gt;. MES go to such a level of control, that the industry created the SEMI E142 standard for wafer mapping. The MES tracks everything that occurred in each layer of the die and is always interfacing with process machines to keep them informed of everything contained in the wafer &lt;a
href="https://www.criticalmanufacturing.com/wp-content/uploads/2022/06/CMF211863-Data-Sheet-Mapping.pdf"
target="_blank"
&gt;19&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Wafer Map"
src="https://image.j-roque.com/posts/20251211-uns-semiconductors/wafermap.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Wafer Map E142"
src="https://image.j-roque.com/posts/20251211-uns-semiconductors/wafermap_e142.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Machine Learning also is becoming ubiquitous, either with direct machine interfacing, collecting a whole set of machine events to perform accurate predictions, or with advanced algorithms for AI-powered image classification &lt;a
href="https://www.criticalmanufacturing.com/mes-for-industry-4-0/apps/c-alice/"
target="_blank"
&gt;20&lt;/a&gt;.&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/mj8iWMnJMpI?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;p&gt;This level of integration means a typical fab already has a &lt;strong&gt;&amp;ldquo;practical UNS&amp;rdquo;&lt;/strong&gt; embedded through SECS/GEM protocols and centralized scheduling. Machines, MES, and even ERP are already linked in real-time. They are able not just to monitor but also to have direct real-time control of everything that happens in the shopfloor.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 class="relative group"&gt;Advanced Analytics Already Embedded
&lt;div id="advanced-analytics-already-embedded" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#advanced-analytics-already-embedded" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Fabs have long relied on &lt;strong&gt;Statistical Process Control (SPC)&lt;/strong&gt; and &lt;strong&gt;Advanced Process Control (APC)&lt;/strong&gt; to optimize operations. These have evolved into ML-powered &lt;strong&gt;virtual metrology&lt;/strong&gt;, &lt;strong&gt;predictive maintenance&lt;/strong&gt;, and &lt;strong&gt;real-time fault detection&lt;/strong&gt; (FDC). For example, &lt;strong&gt;Amkor increased engineering productivity by 60%&lt;/strong&gt; by deploying FDC systems &lt;a
href="https://semiengineering.com/smart-manufacturing-makes-gains-in-chip-industry"
target="_blank"
&gt;10&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;TSMC pioneered &lt;strong&gt;AI-driven visual inspection&lt;/strong&gt; and &lt;strong&gt;automated wafer warehousing&lt;/strong&gt;, reducing manual handling by 95% &lt;a
href="https://www.yolegroup.com/industry-news/tsmc-pioneers-the-worlds-first-automated-wafer-inbound-outbound-system-reducing-13-million-manual-tasks-per-year"
target="_blank"
&gt;11&lt;/a&gt;. A single fab can generate &lt;strong&gt;millions of data points per day&lt;/strong&gt; &lt;a
href="https://www.semi.org/en/blogs/technology-and-trends/semiconductor-manufacturing-in-the-industry-40-era-an-ai-use-case"
target="_blank"
&gt;12&lt;/a&gt;, all flowing into tightly coupled analytics engines. McKinsey estimates that AI could add &lt;strong&gt;$85–95 billion in annual EBITDA&lt;/strong&gt; to the industry by 2025 &lt;a
href="https://www.semi.org/en/blogs/technology-and-trends/semiconductor-manufacturing-in-the-industry-40-era-an-ai-use-case"
target="_blank"
&gt;13&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Investment in Automation: A Decade of Acceleration
&lt;div id="investment-in-automation-a-decade-of-acceleration" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#investment-in-automation-a-decade-of-acceleration" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;From 2020 to 2025, semiconductor firms poured &lt;strong&gt;billions into automation and smart fab initiatives&lt;/strong&gt;. Global capex for semiconductor equipment reached &lt;strong&gt;$114 billion in 2024&lt;/strong&gt;, and projections show over &lt;strong&gt;$500 billion in fab investment by 2030&lt;/strong&gt;, much of it focused on automation infrastructure &lt;a
href="https://www.intelmarketresearch.com/robotsemiconductor-market-9793"
target="_blank"
&gt;14&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Additionally, &lt;strong&gt;35–40% of equipment R&amp;amp;D budgets now target software development&lt;/strong&gt; (MES, control, analytics), up from 15% a decade ago &lt;a
href="https://www.questglobal.com/insights/thought-leadership/modernizing-semiconductor-equipment-development"
target="_blank"
&gt;16&lt;/a&gt;. Unplanned downtime can cost &lt;strong&gt;over $1 million per hour&lt;/strong&gt;, further driving investment into AI, APC, and resilient automation &lt;a
href="https://worktrek.com/blog/predictive-maintenance-trends"
target="_blank"
&gt;17&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;UNS in a Fab: Solving a Problem That Doesn&amp;rsquo;t Exist
&lt;div id="uns-in-a-fab-solving-a-problem-that-doesnt-exist" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#uns-in-a-fab-solving-a-problem-that-doesnt-exist" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Implementing UNS in a fab would be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Redundant&lt;/strong&gt;: Equipment is already connected to centralized control systems via standardized interfaces (SECS/GEM, EDA).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Risky&lt;/strong&gt;: Semiconductor fabs are uptime-sensitive and data-secure environments. Introducing a broad, generic MQTT layer could introduce vulnerabilities.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oversimplified&lt;/strong&gt;: UNS abstracts data context in ways that might work in low-tech operations, but &lt;strong&gt;fab data is deeply contextual&lt;/strong&gt; (e.g., recipe ID, slot mapping, process window).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disruptive&lt;/strong&gt;: Fabs already have orchestration frameworks, such as factory-level automation controllers, tailored to coordinate MES, AMHS, and dispatch logic in real time &lt;a
href="https://www.criticalmanufacturing.com/blog/lights-out-automation-creating-resilient-factories"
target="_blank"
&gt;18&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;In Contrast: Where UNS Makes Sense
&lt;div id="in-contrast-where-uns-makes-sense" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#in-contrast-where-uns-makes-sense" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;UNS works well for sectors that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Still rely on paper or spreadsheet-based production tracking.&lt;/li&gt;
&lt;li&gt;Lack standardized machine interfaces.&lt;/li&gt;
&lt;li&gt;Have siloed OT and IT networks.&lt;/li&gt;
&lt;li&gt;Are beginning their Industry 4.0 journey.&lt;/li&gt;
&lt;li&gt;Very Mixed industry companies with different levels of maturity.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In these settings, UNS can accelerate modernization, enable analytics, and reduce costly system integration efforts. But that’s &lt;strong&gt;not the case in semiconductors&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;UNS Is for Catch-Up, Not for Leaders
&lt;div id="uns-is-for-catch-up-not-for-leaders" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#uns-is-for-catch-up-not-for-leaders" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The semiconductor industry is not a typical industrial environment. It’s a &lt;strong&gt;mature, automated, data-rich sector&lt;/strong&gt; with decades of experience integrating machines, software, and data. It has already &lt;strong&gt;solved the problems&lt;/strong&gt; UNS was invented to address.&lt;/p&gt;
&lt;p&gt;While UNS can help industries with fragmented data catch up to the digital age, &lt;strong&gt;semiconductor fabs are already there&lt;/strong&gt;. Adopting a Unified Namespace in this context is like installing a garden hose next to a high-pressure water main: unnecessary and potentially disruptive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Let low-tech factories unify their namespaces. Fabs already did – decades ago.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Still on the Fence?
&lt;div id="still-on-the-fence" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#still-on-the-fence" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Don&amp;rsquo;t get me wrong, UNS has a lot going for it. The truth is, if you are at a high level of maturity, emitting UNS events for all your operations should be as simple as pushing a button. As we move downstream in the supply chain and leave the semi space. UNS has an ability to quickly create a scalable data monitoring system, avoiding having to deal with all the complexity of actual process control. With integrated supply chains, it has opened the use of ISA95 to be the lever on which we can tie in and understand information.&lt;/p&gt;
&lt;p&gt;The question should not be, does your MES system support UNS. It must be does your MES support UNS and shop floor control and machine integration. UNS can be a great place to start, but it&amp;rsquo;s about the first step in industry 4.0 not the last one. You need to have a platform able to support your journey, it&amp;rsquo;s ok to start with UNS, but you need to choose a platform that allows you to grow and start moving to a real-time control of your shop-floor &lt;a
href="https://www.criticalmanufacturing.com/blog/democratizing-manufacturing-analytics-series-2-from-cdm-to-uns-bridging-manufacturing-analytics-and-real-time-operations/"
target="_blank"
&gt;21&lt;/a&gt;.&lt;/p&gt;
&lt;a href="https://www.criticalmanufacturing.com/blog/democratizing-manufacturing-analytics-series-2-from-cdm-to-uns-bridging-manufacturing-analytics-and-real-time-operations/" target="_blank" rel="noopener" class="group block relative rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-700 hover:shadow-lg transition no-lightbox"&gt;
&lt;div class="aspect-video relative bg-zinc-800"&gt;
&lt;img
src="https://image.j-roque.com/posts/20251211-uns-semiconductors/UNS-CM.png"
alt="Democratizing Manufacturing Analytics – From CDM to UNS"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105 no-lightbox"
loading="lazy"
decoding="async"
&gt;
&lt;div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"&gt;&lt;/div&gt;
&lt;div class="absolute top-2 right-2 bg-white/90 dark:bg-zinc-900/80 text-xs text-zinc-800 dark:text-zinc-100 px-2 py-0.5 rounded shadow"&gt;
External
&lt;/div&gt;
&lt;/div&gt;
&lt;/a&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20251211-uns-semiconductors/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20251211-uns-semiconductors/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>What is an MES?</title>
<link>https://j-roque.com/posts/20251209-whatisanmes/</link>
<pubDate>Tue, 09 Dec 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20251209-whatisanmes/</guid>
<description>What is an MES?</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20251209-whatisanmes/featured.png" alt="What is an MES?" /&gt;&lt;/p&gt;&lt;p&gt;We have dedicated a lot of effort to complex topics. Let&amp;rsquo;s get back to the basics, this blog post focuses on explaining what is an MES and why your organization needs one.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Every time we talk about a software that is intrinsicly B2B (business to business), we already lose a big part of the population and only talk to a subset of people. We also create knowledge silos and specific language that serve as barriers of entry for new people. Every person knows what an ecommerce website is, they interact with them everyday, but when people hear words like MES, PLM, ERP, for some is an intrinsic part of their day to day, for most others they are completely alien.&lt;/p&gt;
&lt;p&gt;Before diving into explaining an MES it is important to understand that MES started very niche, but are becoming an ubiquitous presence in all things manufacturing, let&amp;rsquo;s understand why.&lt;/p&gt;
&lt;p&gt;An MES or &lt;code&gt;Manufacturing Execution System&lt;/code&gt; is the software layer responsible for managing everything that happens in the shopfloor in order to be able to produce a finished good. It is the brain of a factory, it will coordinate people, machines, processes and quality to ensure a product is built correctly, efficiently and under total traceability and control.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Digitalization Process
&lt;div id="digitalization-process" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#digitalization-process" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The MES has become the keystone of every digitalization process. It is the nexus of all other applications of the shopfloor.&lt;/p&gt;
&lt;p&gt;Throughout the years, it is common that small single target applications to target solving small problems end up being created. This may be great at solving localized problems, but as they grow either in scope, or in complexity, or as time goes by and the know-how is lost, this becomes an organizational wide problem.&lt;/p&gt;
&lt;p&gt;The MES is commonly used as the tool to centralize all that happens in the shopfloor, bringing clarity and structure. It also either simplifies and deprecates those systems, introducing a standard way to address common issues. It also fosters collaboration between different departments, collaborating to find solutions to similar issues.&lt;/p&gt;
&lt;h2 class="relative group"&gt;System Stakeholders Shopfloor
&lt;div id="system-stakeholders-shopfloor" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#system-stakeholders-shopfloor" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Throughout the years as systems become more advanced a lot of barriers have become fuzzier. MES now does a lot more equipment integration than in the past, ERP systems now have MES modules and PLCs get smarter every year. But let&amp;rsquo;s try and define what each of these words mean in order to understand what the MES typically is not.&lt;/p&gt;
&lt;p&gt;Nevertheless, there is a typical a pyramid that illustrates how systems relate in the shopfloor.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Manufacturing Software Pyramid"
src="https://8sigma.eu/wp-content/uploads/2021/04/Untitled-design-12-640x360.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Source: &lt;a
href="https://8sigma.eu/erp-mes-scada-we-are-offering-an-all-in-one-solution-not/"
target="_blank"
&gt;8Sigma – Manufacturing Software Pyramid&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s try and understand other key systems that are stakeholders of the shopfloor.&lt;/p&gt;
&lt;h3 class="relative group"&gt;ERP - Enterprise Resource Planning
&lt;div id="erp---enterprise-resource-planning" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#erp---enterprise-resource-planning" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;An ERP system is a software that aggregates a lot of different facets of a company. It creates a central platform to manage finance, purchasing, inventory, HR, sales and planning. It tries to unify all of these diverse aspects into a cohesive and centralized system.&lt;/p&gt;
&lt;p&gt;It tries to answer these questions:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;What do we sell?&lt;/em&gt; &lt;em&gt;What do we buy?&lt;/em&gt; &lt;em&gt;What do we own?&lt;/em&gt; &lt;em&gt;What do we owe?&lt;/em&gt; &lt;em&gt;What should we produce next?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In a shopfloor context it is &lt;code&gt;commonly known as the software application responsible for keeping track of money and orders&lt;/code&gt;. It follows the money, by controlling inventory, stock levels, supplier lead times, raw material cost, payroll, etc. It is also responsible form managing purchase order requests, making sure they are delivered and understanding what was the profit and loss on that order.&lt;/p&gt;
&lt;p&gt;The MES interacts with the ERP importing production orders and products, reporting material scraps, maintenances, etc. The MES is the boots on the ground for the ERP and is responsible for providing accurate and real data, moving from manual data.&lt;/p&gt;
&lt;h3 class="relative group"&gt;PLC - Programmable Logic Controller / SCADA - Supervisory Control and Data Acquisition
&lt;div id="plc---programmable-logic-controller--scada---supervisory-control-and-data-acquisition" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#plc---programmable-logic-controller--scada---supervisory-control-and-data-acquisition" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A PLC is an industrial computer. It&amp;rsquo;s a computer that is able to live in the shopfloor and support all the dust, vibrations and so on. More importantly it is &lt;code&gt;focused on controlling a lot of inputs and outputs very fast&lt;/code&gt;. It runs extensive logic to be run 24/7 for years controlling machines. It performs actions like motor on, motor off, rotate, read sensors, execute safety interlocks, detect alarms, etc. Over time the logic that they were able to run, became more complex as the computers themselves became more advanced.&lt;/p&gt;
&lt;p&gt;A SCADA can be thought of as the eyes and ears of a PLC system. It displays machine status, shows alarms, logs historical data and allows operators to perform some machine interactions.&lt;/p&gt;
&lt;p&gt;The MES interacts with those systems extracting information and controlling machines, via interfacing with PLCs and specific communication protocols.&lt;/p&gt;
&lt;h3 class="relative group"&gt;PLM - Product Lifecycle Management
&lt;div id="plm---product-lifecycle-management" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#plm---product-lifecycle-management" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A PLM is an engineering software focused on &lt;code&gt;managing a product's entire lifecycle&lt;/code&gt;. It covers from idea → design → engineering → production → service → retirement. These are very specific systems focused on R&amp;amp;D (Research and Development) and in the productization process.&lt;/p&gt;
&lt;p&gt;The MES can import information like ECADs, engineering drawings and product revision control. Creating an easy and detailed view from the production engineers into R&amp;amp;D specification.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Learning by Analogy - Human Body
&lt;div id="learning-by-analogy---human-body" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#learning-by-analogy---human-body" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the most common analogies used to describe an MES is to think of the factory as a living and breathing human body.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Human Body"
src="https://cdn.britannica.com/07/192107-050-CE043374/anatomy-charts-human-body-muscle-systems-skeletal.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Source: &lt;a
href="https://www.britannica.com/science/human-body"
target="_blank"
&gt;human body&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Manufacturing Execution System&lt;/code&gt; is the factory’s &lt;em&gt;nervous system&lt;/em&gt;. It controls three main systems:&lt;/p&gt;
&lt;h3 class="relative group"&gt;Nerves - Real-Time Awareness
&lt;div id="nerves---real-time-awareness" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nerves---real-time-awareness" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;It senses everything. It has &lt;code&gt;real-time awareness of everything that is happening&lt;/code&gt;. Where the human body senses touch, pain, pressure, the MES senses, machine states (Productive, Scheduled Down, &amp;hellip;), operator actions, quality measurements. The &lt;code&gt;MES is collecting all the information that is happening in real-time and delivering in a way that is understandable&lt;/code&gt;, just like the human body interacting with the environment.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Reflexes - Makes Immediate Decisions
&lt;div id="reflexes---makes-immediate-decisions" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reflexes---makes-immediate-decisions" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Your brain is &lt;code&gt;continuously managing your body&lt;/code&gt;, either keeping the temperature stable, regulating hormones, killing cells with defects or simply breathing, all of this are reflex actions that we don&amp;rsquo;t even think about but are key. Can you imagine going through your day having to think, every few seconds, to breath? This would consume so much mental power that you would be paralyzed, just trying to survive.&lt;/p&gt;
&lt;p&gt;The MES is just like the human body, it automatically quarantines materials with defects, it rejects bad parts, provides machines and operators with recipe and job instructions and automatically routes all the materials you are producing in your shopfloor.&lt;/p&gt;
&lt;p&gt;Can you imagine managing a shopfloor where all of these actions are manual actions and accurately decide at every particular instance? How would you have time to think about improving? You would waste all your time trying to keep breathing.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Muscle Actions - Execution &amp;amp; Coordination
&lt;div id="muscle-actions---execution--coordination" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#muscle-actions---execution--coordination" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Your &lt;code&gt;nervous system is not just sensing and providing data, it also is coordinating complex systems in order to provide simple results&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Walking, an action that every human being is able to do after some months of existing is a highly complex action.&lt;/p&gt;
&lt;p&gt;Your leg and foot have to rise and fall a certain optimum level every time in sync and to top that the optimum level is always changing depending on the context (floor type, shoes, etc). What about the force your leg should do to place your foot down, this is also an constant optimization to keep you balanced and not exert wasteful force. This means that a simple action like walking when broken down in steps it is actually a set of highly dynamic complex actions.&lt;/p&gt;
&lt;p&gt;The MES is exactly the same, a simple action like replenishing raw materials for the machines to be able to produce, becomes a complex inter-operation between systems. From interfacing with the machine to understand stock levels, to interacting with warehouse management softwares to create a request for material, to talk with Autonomous Mobile Robots (AMR), to perform the pick up and the machine replenishment. This can even become more complex if you wish to have algorithms to use first what you have available in the shopfloor, before requesting from the warehouse.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;The MES is responsible to interface between all these different realities to transform a simple action like replenishment actually simple, when in fact is a set of dynamic and complex actions.&lt;/code&gt;&lt;/p&gt;
&lt;h3 class="relative group"&gt;Other Parts
&lt;div id="other-parts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#other-parts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;h4 class="relative group"&gt;Organs &amp;amp; Muscles
&lt;div id="organs--muscles" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#organs--muscles" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;We can think of the actual organs and muscles of the human body are all the machines, AMRs and operators that perform guided actions throughout the shop floor.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Blood
&lt;div id="blood" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#blood" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;The blood of a shopfloor is no doubt all the materials, raw materials and spare parts, that continuously circulate in the factory. They move between different organs and muscles (machines and operators), in order to at the end create finished goods.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Brain (Long Term Planning)
&lt;div id="brain-long-term-planning" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#brain-long-term-planning" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;No doubt the MES shares in the brain analogy with the ERP (Enterprise Resource Planning). While the MES is focused on short and medium term actions, the ERP is focused on creating and managing needs. It&amp;rsquo;s like our long terms goals, of buying a new house or a new car. It is a software responsible for feeding the MES with orders the MES must fullfil, like build 10000 units of materials X. The MES will then translate that order into actionable actions, like the order will be fulfilled by machine A with the operator B, using the raw materials C.&lt;/p&gt;
&lt;p&gt;The ERP will then be notified by the MES of how the order is going, mainly interested in things that affect costing and schedule of the order, like how much scrap is being generated and the macro stage of where the material is at and finally how many materials of that order have been finished.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Spinal Cord (Direct Machine Control)
&lt;div id="spinal-cord-direct-machine-control" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#spinal-cord-direct-machine-control" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;PLC (Programmable Logic Controller) and Scada (Supervisory Control and Data Acquisition) systems serve as the middle man between the MES and the machine. They are responsible for direct machine control and safety. Imagine a conveyor belt and a set of machines. The MES is focused on understanding if the material is supposed to be at a particular machine, if the recipe of the machine is correct, if there are enough raw materials. The PLC is going to concern itself in moving the conveyor belt, in controlling the specific action the machine is doing in order to fulfill the recipe and making sure everything is done is safe manner.&lt;/p&gt;
&lt;p&gt;ERP is the brain that plans the future, PLCs are the muscles that move, and MES is the nervous system that senses, decides, coordinates, and remembers everything happening right now.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With a cheaper cost of entry and more intelligent machines, MES systems are becoming ever more prevalent.&lt;/p&gt;
&lt;p&gt;Controlling a shopfloor without an MES system is like trying to catch water with your hands, you will waste so much time trying to hold it, you won&amp;rsquo;t have time to drink it.&lt;/p&gt;
&lt;p&gt;The MES is a key system for a digitalization process as it will democratize and universalize shopfloor control systems.&lt;/p&gt;
&lt;p&gt;The age of AI also opens the opportunity for a more intelligent MES. The thinking factory blends the control and information gathering that MES lives and breathes by, with the potential for inference and knowledge reasoning that genAI brings to the table.&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/M18HIc06qPU?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20251209-whatisanmes/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20251209-whatisanmes/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>IoT Profiler &amp; Debugger</title>
<link>https://j-roque.com/posts/20251007-profilerdebugger/</link>
<pubDate>Tue, 07 Oct 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20251007-profilerdebugger/</guid>
<description>Using a debugger and profiler from inside the MES UI</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20251007-profilerdebugger/featured.png" alt="IoT Profiler &amp; Debugger" /&gt;&lt;/p&gt;&lt;p&gt;In version &lt;code&gt;11.2&lt;/code&gt; there was a big revamp of the Connect IoT designer. We have pushed even further in version &lt;code&gt;11.2&lt;/code&gt; and have fully revamped the IoT Remote Debugger and Profiler.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the common issues of handling on-edge applications is to have visibility and control. CM already provides a lot of support for visibility and control. We have covered observability &lt;a
href="https://j-roque.com/posts/20250325-observability/"
target="_blank"
&gt;Edge Observability&lt;/a&gt; and have shown control through our use cases where we have been stopping and starting all our on-edge managers through the GUI, we have also done a presentation that covered some of this in the &lt;a
href="https://www.youtube.com/watch?v=mSYQyeLJwQM&amp;amp;list=PLqoflv7roQpbwDZcllDgllsP_3HiEMxb_&amp;amp;index=3"
target="_blank"
&gt;MESi 4.0 - Good things come in small packages Unlock value by solving small form factor device challenges&lt;/a&gt; where we had an Automation Manager Controller running inside a Microshift cluster and we were handling the full deploy and update cycle, directly from the MES.&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/mSYQyeLJwQM?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;p&gt;In this blog post we want to cover two features that are directly embedded in the CM MES UI and that can be very helpful to quickly troubleshoot issues, either in a development setting or on a production setting.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Profiler
&lt;div id="profiler" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#profiler" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We created a very simple Connect IoT Workflow. It is monitoring a folder and whenever a file is dropped in a folder it will read the content of the file, log it and delete the file. When the file is deleted an event is triggered signaling that a file was deleted.&lt;/p&gt;
&lt;p&gt;The profiler is a very useful tool that &lt;strong&gt;works directly in the Automation Controller page&lt;/strong&gt;. It allows for the user to record everything that is happening in a set of workflow pages. The user will see a &lt;strong&gt;step by step of all the actions that occurred during the start and stop time of the profiler&lt;/strong&gt;. The user can then travel through the runtime execution, forwards or backwards.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Profiler"
src="https://image.j-roque.com/posts/20251007-profilerdebugger/profiler.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The execution can be recorded to a file by adding it in the &lt;a
href="https://help.criticalmanufacturing.com/installationguide/post-installation/connect-iot/connectiotautomationmanagerconfig/#controller-structure"
target="_blank"
&gt;Automation Manager Configuration&lt;/a&gt;, or by default it will export to the &lt;code&gt;${temp}/ConnectIoT/ProfilerSessions&lt;/code&gt; folder. The profiler execution can then be imported in the Automation Controller page and executed.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
    &amp;#34;id&amp;#34;: &amp;#34;File Raw Manager&amp;#34;,
    &amp;#34;cache&amp;#34;: &amp;#34;${temp}/ConnectIoT/Cache&amp;#34;,
    &amp;#34;hostName&amp;#34;: &amp;#34;localhost&amp;#34;,
    &amp;#34;monitorApplication&amp;#34;: &amp;#34;${pwd}/monitor.js&amp;#34;,
    &amp;#34;repository&amp;#34;: {
        &amp;#34;type&amp;#34;: &amp;#34;System&amp;#34;
    },
    &amp;#34;controller&amp;#34;: {
        &amp;#34;profilerSessionsLocation&amp;#34;: &amp;#34;C:/temp/ProfilerSessions&amp;#34;
    },
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Profiler Record"
src="https://image.j-roque.com/posts/20251007-profilerdebugger/profilerrecord.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In a production setting and even in development a robust logging strategy is a must in order to understand and pinpoint complex issues. With the ability to see a walkthrough of what is happening in the low code designer it is a much more intuitive experience.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Debugger
&lt;div id="debugger" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debugger" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The debugger is also a very interesting feature, it allows for the UI to &lt;strong&gt;add breakpoints&lt;/strong&gt; in the workflow designer,** without impacting the runtime**. In traditional debuggers a breakpoint will stop the application from continuing, here as we do not wish to negatively impact a running system we are able to follow the execution trace in the UI while the system continues to run.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Debugger"
src="https://image.j-roque.com/posts/20251007-profilerdebugger/debugger.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Notice how we have two breakpoints and both are hit.&lt;/p&gt;
&lt;p&gt;After finishing an executing and pressing &lt;code&gt;Resume&lt;/code&gt; we already have a new breakpoint hit in the first breakpoint, this is due to the controller continuing to execute. The debugger will the execution from start to finish and then move on to the next execution.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We are working to have the Connect IoT experience better each version. With the debugger and profiler we have added more transparency and easy visualization, bringing what is running on edge closer to what the low code developer is viewing.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20251007-profilerdebugger/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20251007-profilerdebugger/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Building an AMQP Publisher Driver</title>
<link>https://j-roque.com/posts/20250929-amqp-driver/</link>
<pubDate>Mon, 29 Sep 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250929-amqp-driver/</guid>
<description>Creating a simple AMQP Publisher</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250929-amqp-driver/featured.png" alt="Building an AMQP Publisher Driver" /&gt;&lt;/p&gt;&lt;p&gt;An example of how to build a driver using CM Connect IoT, for a very popular driver. All the source code of this driver is available &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/AMQP/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-amqp"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;AMQP 1.0 is a very common &lt;code&gt;open standard&lt;/code&gt; in the world of enterprise messaging and distributed systems. It was designed to allow different applications, services, and devices to communicate using a reliable, interoperable messaging protocol.&lt;/p&gt;
&lt;p&gt;Unlike vendor-specific solutions, AMQP 1.0 is vendor agnostic and topology agnostic — meaning it does not assume a specific messaging model (like queues or topics). Instead, it defines a standard way to move messages over the network, leaving the broker or router to decide how to store, route, or fan-out those messages.&lt;/p&gt;
&lt;p&gt;This makes AMQP 1.0 perfect for cloud platforms, hybrid systems, and microservice architectures where components need to talk to each other in a reliable way without being tightly coupled.&lt;/p&gt;
&lt;p&gt;It is a key part of the cloud-native messaging ecosystem and is increasingly popular in Industry 4.0 and IoT solutions because it works across vendors and technology stacks.&lt;/p&gt;
&lt;p&gt;AMQP 1.0 is designed around a network of nodes rather than a centralized queue model. This means any node can send messages directly to any other node — enabling peer-to-peer, brokerless, or broker-mediated communication depending on how your system is deployed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interoperability&lt;/strong&gt; – Any AMQP 1.0 client can talk to any AMQP 1.0 broker or peer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliability&lt;/strong&gt; – Supports at-least-once, at-most-once, and exactly-once message delivery.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backpressure &amp;amp; Flow Control&lt;/strong&gt; – Built-in credit-based mechanism to avoid overloading consumers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt; – TLS encryption and SASL/OAuth2 authentication are built in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flexibility&lt;/strong&gt; – Works for queues, topics, pub/sub, RPC, and streaming use cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class="relative group"&gt;Architecture
&lt;div id="architecture" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#architecture" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;AMQP 1.0 follows a connection–session–link model.&lt;/p&gt;
&lt;p&gt;Client ↔ Connection ↔ Session ↔ Link ↔ Node (Queue/Topic)&lt;/p&gt;
&lt;p&gt;Let’s break it down step by step:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Client&lt;/code&gt; – The application that sends or receives messages. This could be a microservice, an IoT gateway, or a backend service.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Connection&lt;/code&gt; – A persistent, secure TCP (or TLS) connection between the client and the broker.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Session&lt;/code&gt; – A virtual channel inside the connection, allowing multiple streams of messages to coexist independently.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Link&lt;/code&gt; – The logical path to send or receive messages. Links are unidirectional — there are sender links (for producing messages) and receiver links (for consuming messages).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Node&lt;/code&gt; – An addressable endpoint in the network (queue, topic, service endpoint, or even another client).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In AMQP 1.0, a node can be:&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;queue&lt;/strong&gt; or &lt;strong&gt;topic&lt;/strong&gt; in a &lt;strong&gt;broker&lt;/strong&gt;. A dynamic node created on demand (temporary queue). Another client acting as a message endpoint.&lt;/p&gt;
&lt;p&gt;This means you can have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Brokerless peer-to-peer messaging&lt;/strong&gt; — clients communicate directly if they know each other’s address.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Routed topologies&lt;/strong&gt; — with intermediaries forwarding messages between nodes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Classic brokered queues/topics&lt;/strong&gt; — if that’s what your architecture requires.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes AMQP 1.0 much more flexible than protocols that force you into a broker-and-queue model.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Communication Flow
&lt;div id="communication-flow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#communication-flow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The typical flow of a messaging session in AMQP 1.0 looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Open a Connection&lt;/strong&gt; - The client connects to the broker and performs any authentication (e.g., SASL, TLS).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Begin a Session&lt;/strong&gt; - The client opens a session to group message exchanges.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attach a Link&lt;/strong&gt; - The client declares whether it wants to send (producer) or receive (consumer) messages for a specific address (queue, topic, etc.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grant Credits&lt;/strong&gt; - The receiver tells the sender how many messages it is ready to accept — this is flow control to prevent overload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transfer Messages&lt;/strong&gt; - The sender transmits messages. Each message has metadata (headers, properties) and a payload (the actual data).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Acknowledge Messages&lt;/strong&gt; - The receiver accepts, rejects, or releases messages. This is how AMQP ensures messages are delivered reliably.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detach and Close&lt;/strong&gt; - When done, links are detached, sessions ended, and the connection closed cleanly.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 class="relative group"&gt;Message Routing
&lt;div id="message-routing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#message-routing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Because any node can send to any node, AMQP 1.0 supports multiple topologies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Point-to-Point&lt;/strong&gt; – One producer sends to one consumer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish/Subscribe&lt;/strong&gt; – Multiple consumers can receive the same message.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request/Reply&lt;/strong&gt; – Direct RPC-like communication between services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mesh or Bus&lt;/strong&gt; – Nodes exchange messages with each other without a central broker.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class="relative group"&gt;AMQP Message
&lt;div id="amqp-message" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#amqp-message" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;An AMQP 1.0 message has a flexible, structured format:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Header&lt;/strong&gt; – TTL, durability, priority&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Properties&lt;/strong&gt; – message ID, correlation ID, reply-to&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application Properties&lt;/strong&gt; – user-defined key-value metadata&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt; – the actual payload (binary, JSON, text, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes AMQP messages self-describing, which helps with routing, filtering, and debugging.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Building a Connect IoT Driver
&lt;div id="building-a-connect-iot-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-connect-iot-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For our example we will use the &lt;strong&gt;simplest form of AMQP&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A driver that is a &lt;code&gt;publisher of messages to a broker&lt;/code&gt;. In further blogs we will address other facets of AMQP, like subscribing to the Broker and being able to receive and send Peer-to-Peer messages.&lt;/p&gt;
&lt;p&gt;One of the first decisions in any driver is what &lt;strong&gt;tools do you require to build driver&lt;/strong&gt; support. For most drivers the starting point is made easy by the availability of &lt;strong&gt;SDKs&lt;/strong&gt; or other tools made &lt;strong&gt;available by vendors or foundations&lt;/strong&gt;. AMQP is an open standard and has support across different programming languages and engines.&lt;/p&gt;
&lt;p&gt;We will use a very common &lt;code&gt;nodejs&lt;/code&gt; library &lt;a
href="https://github.com/amqp/rhea"
target="_blank"
&gt;rhea&lt;/a&gt;. &lt;strong&gt;Rhea&lt;/strong&gt; is a pure javascript library to interface with amqp.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; In our blog post about &lt;a
href="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/"
target="_blank"
&gt;MTConnect&lt;/a&gt; we had some additional complexity as we were using a mix of nodejs and .Net. This driver will be a pure javascript driver.&lt;/span&gt;
&lt;/div&gt;
&lt;h3 class="relative group"&gt;Scaffolding a Driver
&lt;div id="scaffolding-a-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scaffolding-a-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Connect IoT Driver is a component of a customization package for Connect IoT. In a CM customization project workspace, let&amp;rsquo;s create a &lt;strong&gt;cmf new iot package&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Scaffolding"
src="https://image.j-roque.com/posts/20250929-amqp-driver/newdriveramqp.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Right away the scaffolding helps us a lot, by giving us a solution where we can create our interface with AMQP. We have already a &lt;strong&gt;driver skeleton, that is buildable has linting and runs tests&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The scaffolding already provides a full on buildable and packable solution. In order to interact with the driver we can use the same commands that will be used by the pipeline to create a package with &lt;code&gt;cmf build&lt;/code&gt; and &lt;code&gt;cmf pack&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="CMF Build"
src="https://image.j-roque.com/posts/20250929-amqp-driver/builddriveramqp.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can also decompose them into their subcommands.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;npm i&lt;/code&gt; to install the npm packages, &lt;code&gt;npm run build&lt;/code&gt; to build the code, &lt;code&gt;npm run test&lt;/code&gt; to test the code and &lt;code&gt;npm run packagePacker&lt;/code&gt; to create a &lt;strong&gt;.tgz&lt;/strong&gt; file with the driver bundled. Additionally, we also have support for the use of watchers that continuously build the code on changes, like &lt;code&gt;npm run watchPackage&lt;/code&gt;, with this after every change to your source code, the code will automatically compile.&lt;/p&gt;
&lt;p&gt;We are using &lt;a
href="https://docs.npmjs.com/cli/v8/using-npm/workspaces"
target="_blank"
&gt;npm workspaces&lt;/a&gt;. Commands in the root workspace will invoke commands in each package. We can see how we can build in the root, in the package itself, how we can generate an IoT package and how we can have the terminal continuously building our driver.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Commands"
src="https://image.j-roque.com/posts/20250929-amqp-driver/tourcommandsdriver.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Defining our Settings for the UI
&lt;div id="defining-our-settings-for-the-ui" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#defining-our-settings-for-the-ui" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A good place to start the driver development is to start defining what are the &lt;strong&gt;settings the user will need to interact&lt;/strong&gt; with.&lt;/p&gt;
&lt;p&gt;For the driver, we will need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;address&lt;/strong&gt; - ip or hostname of the AMQP Broker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;port&lt;/strong&gt; - port of the Broker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;username&lt;/strong&gt; - username for the user defined in the Broker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;password&lt;/strong&gt; - password for the user defined in the Broker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;certificate&lt;/strong&gt; - content of the certificate to pass on to the AMQP Broker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;privateKey&lt;/strong&gt; - content of the private key to pass on to the AMQP Broker&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In order for these settings to be available in the UI, we will need them to be part of our driver &lt;code&gt;package.json&lt;/code&gt;. The json will be imported to the UI when creating a protocol and dynamically generate the required fields.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;parameters&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;address&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;AMQP Broker Address&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Address where the AMQP Broker interface will be connecting to&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;127.0.0.1&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;port&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;AMQP Broker  Port&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Port where AMQP Broker interface is listening&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;integer&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: 5672
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;certificatesRootFolder&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Certificates Root Folder&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Absolute path of the root folder for the Public Key Infrastructure. Allows the use of tokens ${temp} or ${tmp} for the OS temporary folder and token ${id} for replacing with the respective driver instance id&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;${temp}/ConnectIoT/AMQP/Certificates/${id}&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;certificate&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Certificate&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Own certificate - can be either a path to a file or the contents of the file in plain text. Format should be &amp;#39;pem&amp;#39;&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;text&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;privateKey&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Private Key&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Own private key - can be either a path to a file or the contents of the file in plain text. Format should be &amp;#39;pem&amp;#39;&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;text&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;username&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Driver Username&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Username to use for authentication. Use with amqps.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;password&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Driver Password&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Password to use for authentication. Use with amqps.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;password&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From analyzing the Protocol and &lt;strong&gt;rhea&lt;/strong&gt; we can also see what are the required settings for our publishing commands.&lt;/p&gt;
&lt;p&gt;We will support dynamically overriding the default address of the command (i.e the topic or queue name).&lt;/p&gt;
&lt;p&gt;Then we will have settings relevant for our sender connection. The &lt;code&gt;Capabilities&lt;/code&gt; will be the setting responsible for telling the broker how to interpret the message.&lt;/p&gt;
&lt;p&gt;Your broker may support additional capabilities, you can use the &lt;code&gt;extraCapabilities&lt;/code&gt; to add more.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;command&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;address&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Address of Queue/Exchange/Topic&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Defines the Queue or Exchange name.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;durable&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Durable&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;How to configure connection durability.\nNotDurable - No durability (default) - Nothing persists if connection drops\nDurable - Configuration persists - Queue config survives, but unsettled messages may be lost\nFullDurability - Full durability  - Queue AND unsettled messages survive connection drops&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;enum&amp;#34;,
    &amp;#34;values&amp;#34;: [
      &amp;#34;NotDurable&amp;#34;,
      &amp;#34;Durable&amp;#34;,
      &amp;#34;FullDurability&amp;#34;
    ],
    &amp;#34;defaultValue&amp;#34;: &amp;#34;NotDurable&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;expirationPolicy&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;ExpirationPolicy&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;How to set up the expiration policy.\nSessionEnd - Delete when AMQP session ends (default) - Cleaned up when session closes\nLinkDetach - Delete when link detaches - Cleaned up when sender.close() called\nConnectionClose - Delete when connection closes  - Cleaned up when connection drops\nNever - Never auto-delete (manual cleanup required)  - Persists until manually deleted&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;enum&amp;#34;,
    &amp;#34;values&amp;#34;: [
      &amp;#34;SessionEnd&amp;#34;,
      &amp;#34;LinkDetach&amp;#34;,
      &amp;#34;ConnectionClose&amp;#34;,
      &amp;#34;Never&amp;#34;
    ],
    &amp;#34;defaultValue&amp;#34;: &amp;#34;SessionEnd&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;capabilities&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Capabilities&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Message send configuration.\nQueue - Direct routing - one-to-one routing based on exact key match\nTopic - Topic routing - pattern-based routing with wildcards\nFanout - Fanout routing - broadcast to all bound queues\nHeaders - Headers-based routing (message headers instead of routing key)&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;enum&amp;#34;,
    &amp;#34;values&amp;#34;: [
      &amp;#34;Queue&amp;#34;,
      &amp;#34;Topic&amp;#34;,
      &amp;#34;Fanout&amp;#34;,
      &amp;#34;Headers&amp;#34;
    ],
    &amp;#34;defaultValue&amp;#34;: &amp;#34;Queue&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;extraCapabilities&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Additional Capabilities&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Define additional capabilities (i.e. [&amp;#39;alternate-exchange&amp;#39;, &amp;#39;publisher-confirms&amp;#39;], etc).&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: null
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;dynamic&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Dynamic&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Auto-create unique queue.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;boolean&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: false
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;dynamicNodeProperties&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Dynamic Node Properties&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Define node properties (i.e. { &amp;#39;queue.max-size&amp;#39;:10000, &amp;#39;queue.message-ttl&amp;#39;: 6000 },  etc).&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: null
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;timeout&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Sender Timeout&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Auto-delete sender after X amount of seconds of inactivity, in milliseconds&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;integer&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: null
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As our driver will be &lt;strong&gt;only an AMQP publisher&lt;/strong&gt; we will &lt;strong&gt;only require commands&lt;/strong&gt;. In the &lt;code&gt;package.json&lt;/code&gt; we can disable the events and properties from the driver definition and give a friendly name to the deviceId.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;deviceIds&amp;#34;: {
  &amp;#34;property&amp;#34;: {
    &amp;#34;label&amp;#34;: &amp;#34;Unique Property&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Describe what the user should enter here&amp;#34;,
    &amp;#34;mandatory&amp;#34;: true,
    &amp;#34;unique&amp;#34;: true
  },
  &amp;#34;event&amp;#34;: {
    &amp;#34;label&amp;#34;: &amp;#34;Unique Event&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Describe what the user should enter here&amp;#34;,
    &amp;#34;mandatory&amp;#34;: true,
    &amp;#34;unique&amp;#34;: true
  },
  &amp;#34;command&amp;#34;: {
    &amp;#34;label&amp;#34;: &amp;#34;Sender Unique Name&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;AMQP Sender unique name&amp;#34;,
    &amp;#34;mandatory&amp;#34;: true,
    &amp;#34;unique&amp;#34;: true
  }
},
&amp;#34;characteristics&amp;#34;: {
  &amp;#34;hasProperties&amp;#34;: false,
  &amp;#34;hasEvents&amp;#34;: false,
  &amp;#34;hasCommands&amp;#34;: true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After defining our package.json &lt;strong&gt;we can already create a package&lt;/strong&gt; with this skeleton and uploaded it to the MES UI.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Deploy"
src="https://image.j-roque.com/posts/20250929-amqp-driver/amqpprotocol.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the UI we are able to create an &lt;code&gt;Automation Protocol&lt;/code&gt; with all our communication settings.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Note that this is a development flow, for a productive flow, the &lt;code&gt;cmf pack&lt;/code&gt; will create an installable package that will deploy the AMQP driver..&lt;/span&gt;
&lt;/div&gt;
&lt;h3 class="relative group"&gt;Defining our Settings for the Driver
&lt;div id="defining-our-settings-for-the-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#defining-our-settings-for-the-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;package.json&lt;/strong&gt; will describe the visualization settings for the UI, we can now define them for our driver.&lt;/p&gt;
&lt;p&gt;In the &lt;strong&gt;communicationSettings.ts&lt;/strong&gt; we can now declare our settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;export interface AMQPCommunicationSettings {
    // Add driver specific settings here
    address: string,
    port: number,
// Common/driver WS settings
    heartbeatInterval: number;
    setupTimeout: number;
    intervalBeforeReconnect: number;
    connectingTimeout: number;
/** ********** Security ********** */
    username: string;
    password: string;
    /** Certificates root folder */
    certificatesRootFolder: string;
    /** Certificate to use for communication */
    certificate: string;
    /** Certificate&amp;#39;s private key */
    privateKey: string;
}
/** Default Communication Settings */
export const aMQPDefaultCommunicationSettings: AMQPCommunicationSettings = {
    // Add driver specific default settings here
    address: &amp;#34;127.0.0.1&amp;#34;,
    port: 5671,
// Common/driver WS settings
    heartbeatInterval: 30000,
    setupTimeout: 10000,
    intervalBeforeReconnect: 5000,
    connectingTimeout: 30000,
/** ********** Security ********** */
    username: &amp;#34;&amp;#34;,
    password: &amp;#34;&amp;#34;,
    /** EndPoint certificates root folder for amqps*/
    certificate: &amp;#34;&amp;#34;,
    /** EndPoint certificate to use for communication for amqps*/
    certificatesRootFolder: &amp;#34;${tmp}/ConnectIoT/AMQP/Certificates/${id}&amp;#34;,
    /** EndPoint certificate&amp;#39;s private key for amqps*/
    privateKey: &amp;#34;&amp;#34;,
};&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Driver Implementation
&lt;div id="driver-implementation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#driver-implementation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The driver implementation is a key part of our driver. In the driver implementation we will focus on some key methods.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Set Communication Configurations
&lt;div id="set-communication-configurations" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#set-communication-configurations" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;When the &lt;code&gt;setCommunicationConfiguration&lt;/code&gt; is invoked in the lifecycle of the driver it will reconcile what was defined in the task &lt;code&gt;Equipment Configuration&lt;/code&gt;, the &lt;code&gt;package.json&lt;/code&gt; and the communication settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/**
  * Notification regarding the communication parameters being available.
  * Validate the integrity of the values
  * Note: Called by the driverBase
  * @param communication Communication settings object
  */
public async setCommunicationConfiguration(communication: any): Promise&amp;lt;void&amp;gt; {
    this._communicationSettings = Object.assign({}, aMQPDefaultCommunicationSettings, communication);
// eslint-disable-next-line
    const pJson = require(&amp;#34;../package.json&amp;#34;);
    validateCommunicationParameters(pJson, this._communicationSettings);
// Prepare the extended data
    validateCommands(pJson, this.configuration.commands);
    validateCommandParameters(pJson, this.configuration.commands);
this.manageCertificates();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our implementation requires the use of certificates. In the &lt;code&gt;setCommunicationConfiguration&lt;/code&gt; we will setup our certificates.&lt;/p&gt;
&lt;p&gt;This method will retrieve the templated folder for the certificates. Will retrieve the certificate content and store it in a file locally.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Connect
&lt;div id="connect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;As we stated in the overview we will be using rhea as our main library for AMQP interactions.&lt;/p&gt;
&lt;p&gt;The connect is split into &lt;strong&gt;three main parts&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The first part is responsible for building a configuration object, the second part is creating a new amqp connection using &lt;strong&gt;rhea&lt;/strong&gt;, the last part is creating event listeners for errors.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/**
 * Connect to the equipment.
 * Note: Called by the driverBase
 */
public async connectToDevice(): Promise&amp;lt;void&amp;gt; {
    this.setCommunicationState(CommunicationState.Connecting);
try {
// #region 1. Create Connection Object
        const connectionConfig: any = {
            host: this._communicationSettings.address,
            port: this._communicationSettings.port
        };
// Add TLS if certificates are provided
        if (this._communicationSettings.certificate) {
            connectionConfig.transport = &amp;#34;tls&amp;#34;;
            connectionConfig.key = fs.readFileSync(this._privateKeyFile);
            connectionConfig.cert = fs.readFileSync(this._certificateFile);
        }
// Add credentials if provided
        if (this._communicationSettings.username &amp;amp;&amp;amp; this._communicationSettings.password) {
            connectionConfig.username = this._communicationSettings.username;
            connectionConfig.password = this._communicationSettings.password;
        }
        // #endregion
// #region 2. Connect to AMQP Broker
        this._amqpConnection = rhea.connect(connectionConfig);
        // #endregion
// #region 3. Register Event Listeners for errors
        this._amqpConnection.on(&amp;#34;connection_error&amp;#34;, async (context) =&amp;gt; {
            if (!this._isDisconnecting) {
                this._isDisconnecting = true;
                this.logger.error(`There was a connection error &amp;#39;${context?.error?.message}&amp;#39;, will disconnect.`);
                this.setCommunicationState(CommunicationState.ConnectingFailed);
                this.disconnect();
            }
        });
this._amqpConnection.on(&amp;#34;disconnected&amp;#34;, async (context) =&amp;gt; {
            if (!this._isDisconnecting) {
                this._isDisconnecting = true;
                this.logger.error(`Disconnected from Broker &amp;#39;${context?.error?.message ?? JSON.stringify(context?.error?.errors ?? &amp;#34;&amp;#34;)}&amp;#39;, will force disconnect.`);
                this.setCommunicationState(CommunicationState.Disconnected);
                this.disconnect();
            }
        });
        // #endregion
// Provide an empty object as a fallback to avoid errors
        const { $id, ...cleanCommunicationSettings } = this._communicationSettings as any || {};
        this.logger.info(`Using the following configurations: ${JSON.stringify(cleanCommunicationSettings, undefined, &amp;#34; &amp;#34;)}`);
// Wait for Connection to Open
        await AMQPDeviceDriver.waitFor(this.connectingTimeout, `Connection was never opened`, () =&amp;gt; (this._amqpConnection?.is_open() ?? false) &amp;amp;&amp;amp; !this._isDisconnecting);
// Notify the communication was a success and it is now ready for the setup process
        this.setCommunicationState(CommunicationState.Setup);
    } catch (error) {
        this.logger.error(`Failed to connect to device: ${error.message}`);
        this.setCommunicationState(CommunicationState.ConnectingFailed);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After finishing the connection cycle, the driver will be &lt;strong&gt;available to create sender links&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Disconnect
&lt;div id="disconnect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#disconnect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The disconnect will make sure to clean all the elements of the driver that were instantiated in the start cycle.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/**
 * Disconnect the communication with the equipment
 * Note: Called by the driverBase
 */
public async disconnectFromDevice(): Promise&amp;lt;void&amp;gt; {
    this.setCommunicationState(CommunicationState.Disconnecting);
try {
        this._amqpConnection.close();
    } catch (error) {
        this.logger.error(`Failed to disconnect from device: ${error.message}`);
    }
    this._isDisconnecting = false;
    this.setCommunicationState(CommunicationState.Disconnected);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Execute Command
&lt;div id="execute-command" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#execute-command" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This driver is focused on publishing messages to an amqp broker. The driver will have only commands.
The goal of the &lt;strong&gt;execute method will be to translate the command&lt;/strong&gt; as defined in the driver definition and then as filled in the controller workflow &lt;strong&gt;into a send request for AMQP&lt;/strong&gt;.
The execute, will receive a command. It will check if the command address has ever had a sender link created. In order to do this, due to the ability to provide dynamic addresses it will create a hash value that joins the command name with an address.
If the sender link doesn&amp;rsquo;t already exist, it will create a new sender link. It &lt;strong&gt;will wait for the sender link to be sendable and will then perform a sender request&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async execute(command: Command, parameters: Map&amp;lt;CommandParameter, any&amp;gt;): Promise&amp;lt;any&amp;gt; {
// Safeguard against publish commands before connecting
    if (!this._amqpConnection || !this._amqpConnection.is_open()) {
        this.logger.warning(&amp;#34;Driver is not ready, will ignore execute&amp;#34;);
        return;
    }
// Parse command and command parameters
    const { parameterAddress, commandOptions, body, subject }: { parameterAddress: string; commandOptions: CommandExtendedData; body: Record&amp;lt;string, any&amp;gt;; subject: string | undefined; } = this.parseCommandParameters(command, parameters);
// #region Retrieve/Create sender
    const uniqueIdentifier = createHash(&amp;#34;sha256&amp;#34;).update((command.deviceId &amp;#43; &amp;#34;_&amp;#34; &amp;#43; parameterAddress)).digest(&amp;#34;hex&amp;#34;);
    let sender = this._amqpConnection.find_sender((s: Sender) =&amp;gt; s.name === uniqueIdentifier);
if (!sender) {
        sender = this.registerNewSender(sender, commandOptions, uniqueIdentifier, parameterAddress, command);
    }
if (sender?.sendable() ?? false) {
        await AMQPDeviceDriver.waitFor(10,
            `Sender never became sendable &amp;#39;${command.deviceId}&amp;#39; address &amp;#39;${parameterAddress}&amp;#39; &amp;#39;${uniqueIdentifier}&amp;#39;`,
            () =&amp;gt; sender?.sendable() ?? false);
    }
    // #endregion
sender.send({
        body: JSON.stringify(body),
        content_type: &amp;#34;application/json&amp;#34;,
        content_encoding: &amp;#34;utf-8&amp;#34;,
        subject,
        message_id: Date.now().toString(),
        creation_time: new Date()
    });
return (true);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;registerNewSender&lt;/code&gt; method is responsible for &lt;strong&gt;creating a new sender link&lt;/strong&gt; and also to create &lt;strong&gt;listeners to monitor the health of the sender&lt;/strong&gt;. It will provide visibility to the user if there is any issue in the lifecycle of the sender link.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;private registerNewSender(sender: rhea.Sender | undefined, commandOptions: CommandExtendedData, uniqueIdentifier: string, parameterAddress: string, command: Command) {
    const capabilities: string[] = [commandOptions.capabilities, ...commandOptions.extraCapabilities ?? []];
sender = this._amqpConnection.open_sender({
        name: uniqueIdentifier,
        target: {
            address: parameterAddress,
            durable: commandOptions.durable?.valueOf(),
            expiry_policy: commandOptions.expirationPolicy,
            dynamic: commandOptions.dynamic,
            dynamic_node_properties: commandOptions.dynamicNodeProperties,
            capabilities: capabilities ?? [],
            timeout: commandOptions.timeout,
        }
    });
sender.on(&amp;#34;sender_open&amp;#34;, () =&amp;gt; {
        this.logger.debug(`Sender opened: command &amp;#39;${command.deviceId}&amp;#39; address &amp;#39;${parameterAddress}&amp;#39; &amp;#39;${uniqueIdentifier}&amp;#39;`);
    });
sender.on(&amp;#34;rejected&amp;#34;, (context) =&amp;gt; {
        this.logger.warning(`Sender rejected: command &amp;#39;${command.deviceId}&amp;#39; address &amp;#39;${parameterAddress}&amp;#39; &amp;#39;${uniqueIdentifier}&amp;#39;`);
    });
sender.on(&amp;#34;sendable&amp;#34;, () =&amp;gt; {
        this.logger.debug(`Sender sendable: command &amp;#39;${command.deviceId}&amp;#39; address &amp;#39;${parameterAddress}&amp;#39; &amp;#39;${uniqueIdentifier}&amp;#39;`);
    });
sender.on(&amp;#34;sender_error&amp;#34;, (context) =&amp;gt; {
        this.logger.error(`Sender error: command &amp;#39;${command.deviceId}&amp;#39; address &amp;#39;${parameterAddress}&amp;#39; &amp;#39;${uniqueIdentifier}&amp;#39; - &amp;#39;${JSON.stringify(context?.sender?.error ?? &amp;#34;&amp;#34;)}&amp;#39;`);
    });
return sender;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this simple methods we now have a &lt;strong&gt;working AMQP publisher driver&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Running It
&lt;div id="running-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Now that we finished our driver we can see it working.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Download and Run
&lt;div id="download-and-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#download-and-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;First, let&amp;rsquo;s generate a new package driver and create a driver definition and an automation controller.
We are able to create a new Driver Definition. This is our &lt;strong&gt;contract interface between the controller and driver&lt;/strong&gt;.
Our interface is very simple.
We will have one command called &lt;code&gt;Queue Command&lt;/code&gt;, this command will have two parameters. One parameter will allow for the &lt;strong&gt;address of the queue&lt;/strong&gt; and the other will receive the &lt;strong&gt;payload of the command body&lt;/strong&gt;.
In our Controller, we will have two workflows. The first workflow is the setup workflow, here we will configure the connection to our AMQP driver. The second workflow is very simple, &lt;strong&gt;every 10 seconds&lt;/strong&gt; we will emit a command to &lt;strong&gt;post a message&lt;/strong&gt; to a queue &lt;code&gt;test&lt;/code&gt;.
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connect Controller"
src="https://image.j-roque.com/posts/20250929-amqp-driver/amqpcontroller.gif"
&gt;&lt;/figure&gt;
Now we can &lt;strong&gt;download and start the manager&lt;/strong&gt;. Notice that the manager in the first run will download the packages from the repository and store them in the local cache.
For this example, we will use &lt;a
href="https://docs.solace.com/API/AMQP/AMQP-get-started.htm"
target="_blank"
&gt;Solace AMQP Broker&lt;/a&gt;. In the broker we create a queue &lt;code&gt;test&lt;/code&gt;.
&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Download and Start Manager"
src="https://image.j-roque.com/posts/20250929-amqp-driver/amqprunning.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Debugging
&lt;div id="debugging" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debugging" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have now done a start with the automation manager and downloaded the packages. In order to debug we can find more information &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_driver_dotnet/#debugging"
target="_blank"
&gt;here&lt;/a&gt;. We also go into detail in the &lt;a
href="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/#debugging"
target="_blank"
&gt;MTConnect&lt;/a&gt; blog post.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Testing
&lt;div id="testing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#testing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the key topics when building any kind of integration is the ability to test. In our &lt;a
href="https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/"
target="_blank"
&gt;MTConnect blog post&lt;/a&gt; we focused in using the IoT Test Orchestrator tool. This tool focuses a lot on testing workflows and business logic.
When creating drivers it is also helpful to use unit tests and even integration tests.
In our example, we would like to create a test that interacts with an AMQP Broker. In order to be able to do this we will use the container that we used for Solace in our tests.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Connection Test
&lt;div id="connection-test" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connection-test" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The first and most basic tests is to test the ability to &lt;strong&gt;open a connection to an AMQP broker&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Before creating our test we need to setup our test broker.&lt;/p&gt;
&lt;p&gt;In order to do that we will use a tool called &lt;a
href="https://github.com/testcontainers/testcontainers-node"
target="_blank"
&gt;testcontainers&lt;/a&gt;. Testcontainers is a tool that exists in a lot of platforms and allows the developer to spin up test containers of several different technologies.&lt;/p&gt;
&lt;p&gt;For our test we will spin up a &lt;strong&gt;Solace container&lt;/strong&gt;. We will boot up the Solace container and provide it with enough resources to run.&lt;/p&gt;
&lt;p&gt;We could also use other containers like &lt;code&gt;rabbitmq:3-alpine&lt;/code&gt;, which would be even slimmer.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;describe(&amp;#34;Command Tests&amp;#34;, () =&amp;gt; {
  let container: inversify.Container;
  let startTestContainer: StartedTestContainer;
  let amqpPort: number;
  let hostname: string;
before(async () =&amp;gt; {
      hostname = os.hostname();
      startTestContainer = await new GenericContainer(&amp;#34;solace/solace-pubsub-standard:latest&amp;#34;)
          .withExposedPorts(5672, 8080, 55555) // AMQP, SEMP, SMF
          .withEnvironment({
              &amp;#34;username_admin_globalaccesslevel&amp;#34;: &amp;#34;admin&amp;#34;,
              &amp;#34;username_admin_password&amp;#34;: &amp;#34;admin&amp;#34;,
              &amp;#34;system_scaling_maxconnectioncount&amp;#34;: &amp;#34;100&amp;#34;,  // Lower connection limit for faster boot
              &amp;#34;service_webtransport_enabled&amp;#34;: &amp;#34;false&amp;#34;,     // Disable web transport service
              &amp;#34;service_mqtt_enabled&amp;#34;: &amp;#34;false&amp;#34;             // Disable MQTT if not needed
          })
          // CRITICAL: Solace requires at least 1GB shared memory
          .withSharedMemorySize(1024 * 1024 * 1024) // 1GB in bytes
// Optional: Resource quotas (not supported in rootless Docker)
          .withResourcesQuota({
              memory: 4, // 2GB memory limit
              cpu: 2     // 2 CPU cores
          })
          .withWaitStrategy(Wait.forListeningPorts())
          .withStartupTimeout(120000) // Solace takes longer to start
          .start();
amqpPort = startTestContainer.getMappedPort(5672);
  });
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before each test we will create all the required dependency injection containers to be able to run.&lt;/p&gt;
&lt;p&gt;Here is where we are able to replace the driver &amp;ldquo;real&amp;rdquo; DI containers with our test containers.&lt;/p&gt;
&lt;p&gt;For this example we can see the &lt;code&gt;Logger&lt;/code&gt;, we will replace the system &lt;code&gt;Logger&lt;/code&gt; with a mock logger. We can also provide some set of default driver configurations.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;beforeEach((done) =&amp;gt; {
    MainContainer.snapshot();
    container = new inversify.Container();
    container.parent = MainContainer;
// in this case, the logger is deeper and must be removed, otherwise the subscription is logging to the console
    if (container.parent &amp;amp;&amp;amp; container.parent.parent) {
        container.parent.parent.rebind(COMMON_TYPES.Logger).to(LoggerMock).inSingletonScope();
    }
container.bind&amp;lt;Logger&amp;gt;(COMMON_TYPES.Logger).to(LoggerMock).inSingletonScope();
container.parent.bind&amp;lt;string&amp;gt;(DRIVER_TYPES.DriverId).toConstantValue(&amp;#34;test-driver-id&amp;#34;);
    container.bind&amp;lt;DeviceDriver&amp;gt;(COMMUNICATION_TYPES.Device.Driver).to(AMQPDeviceDriver).inSingletonScope();
container.bind(&amp;#34;Configurations&amp;#34;).toConstantValue({
        commands: [],
        communication: {},
        events: [],
        properties: []
    });
done();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Creating a test to connect with Solace. In our test we will retrieve the driver DI container. Retrieve our Solace settings and start our driver boot cycle. Note that after connect the driver awaits from a validation of the setup result, in order to finish the boot cycle.&lt;/p&gt;
&lt;p&gt;The goal in our test is to validate that the correct driver states were reached.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;it(&amp;#34;Connect to Solace&amp;#34;, async () =&amp;gt; {
const driver: AMQPDeviceDriver = container.get&amp;lt;AMQPDeviceDriver&amp;gt;(COMMUNICATION_TYPES.Device.Driver);
  const configs: any = container.get(&amp;#34;Configurations&amp;#34;);
  const comSettings = (configs.communication as AMQPCommunicationSettings);
  comSettings.address = hostname;
  comSettings.port = amqpPort;
await driver.initialize();
  await driver.setConfigurations(configs);
  await driver.connect();
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Setup&amp;#39;&amp;#34;, () =&amp;gt; {
      chai.expect(driver.communicationState).to.equal(CommunicationState.Setup);
  });
  await driver.setupResult(true);
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Communicating&amp;#39;&amp;#34;, () =&amp;gt; {
      chai.expect(driver.communicationState).to.equal(CommunicationState.Communicating);
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&amp;rsquo;s see now a similar test but with basic auth.&lt;/p&gt;
&lt;p&gt;Note that the test is very similar, but now we add to differences.&lt;/p&gt;
&lt;p&gt;The first is we create a user in Solace, the second is that we now pass on a username and a password in or driver communication settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;it(&amp;#34;Connect to Solace Basic Auth - Happy Path&amp;#34;, async () =&amp;gt; {
    await createUserInSolace(startTestContainer, hostname);
container.rebind(&amp;#34;Configurations&amp;#34;).toConstantValue({
        commands: [],
        communication: {
            &amp;#34;username&amp;#34;: &amp;#34;testuser&amp;#34;,
            &amp;#34;password&amp;#34;: &amp;#34;testpass123&amp;#34;
        },
        events: [],
        properties: []
    });
const driver: AMQPDeviceDriver = container.get&amp;lt;AMQPDeviceDriver&amp;gt;(COMMUNICATION_TYPES.Device.Driver);
    const configs: any = container.get(&amp;#34;Configurations&amp;#34;);
    const comSettings = (configs.communication as AMQPCommunicationSettings);
    comSettings.address = hostname;
    comSettings.port = amqpPort;
await driver.initialize();
    await driver.setConfigurations(configs);
    await driver.connect();
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Setup&amp;#39;&amp;#34;, () =&amp;gt; {
        chai.expect(driver.communicationState).to.equal(CommunicationState.Setup);
    });
    await driver.setupResult(true);
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Communicating&amp;#39;&amp;#34;, () =&amp;gt; {
        chai.expect(driver.communicationState).to.equal(CommunicationState.Communicating);
    });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our Solace container &lt;strong&gt;behaves like a normal Solace instance&lt;/strong&gt;. We can interact with it via its API.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;async function createUserInSolace(startTestContainer: StartedTestContainer, hostname: string) {
    const sempPort = startTestContainer.getMappedPort(8080);
    const sempUrl = `http://${hostname}:${sempPort}`;
    const adminAuth = &amp;#39;Basic &amp;#39; &amp;#43; Buffer.from(&amp;#39;admin:admin&amp;#39;).toString(&amp;#39;base64&amp;#39;);
// 1. Create the client user
    let result = await fetch(`${sempUrl}/SEMP/v2/config/msgVpns/default/clientUsernames`, {
        method: &amp;#39;POST&amp;#39;,
        headers: {
            &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;,
            &amp;#39;Authorization&amp;#39;: adminAuth
        },
        body: JSON.stringify({
            clientUsername: &amp;#39;testuser&amp;#39;,
            password: &amp;#39;testpass123&amp;#39;,
            enabled: true
        })
    });
// 2. Enable basic authentication on the message VPN
    result = await fetch(`${sempUrl}/SEMP/v2/config/msgVpns/default`, {
        method: &amp;#39;PATCH&amp;#39;,
        headers: {
            &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;,
            &amp;#39;Authorization&amp;#39;: adminAuth
        },
        body: JSON.stringify({
            authenticationBasicEnabled: true,
            authenticationBasicType: &amp;#34;internal&amp;#34;
        })
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Command Test
&lt;div id="command-test" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#command-test" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;After validating the ability to connect to an AMQP Broker, we can now test publishing to the broker.&lt;/p&gt;
&lt;p&gt;The first thing we do is we create a queue in Solace. Then we provide to the driver configurations the information about the command we want to execute. We will have a command called &lt;code&gt;Queue&lt;/code&gt;, with a single command parameter &lt;code&gt;Body&lt;/code&gt;. The command will specify a queue address &lt;code&gt;test-queue&lt;/code&gt; and the capability - Queue. The parameter type will inform the driver of how to interpret the parameter, in this case it is Body, so the driver will use it as the message payload.&lt;/p&gt;
&lt;p&gt;The start driver will handle the communication start of the driver.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;checkQueueMessages&lt;/code&gt; will retrieve the &lt;strong&gt;number of messages the queue has received&lt;/strong&gt;. For this test we are just validating the message was sent and received, we could also create a subscriber for this queue that would receive the message being broadcast.
Finally, we create a dummy command, this would be analogous as in the workflow providing a input values to our task &lt;code&gt;Execute Equipment Command&lt;/code&gt; and call the driver executeCommand with the matching name of the command in the driver definition.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;it(&amp;#34;Publish to Queue&amp;#34;, async () =&amp;gt; {
await createQueue(hostname, startTestContainer);
container.rebind(&amp;#34;Configurations&amp;#34;).toConstantValue({
        commands: [
            ({
                deviceId: &amp;#34;Queue&amp;#34;,
                name: &amp;#34;Queue&amp;#34;,
                systemId: &amp;#34;Queue&amp;#34;,
                extendedData: {
                    address: &amp;#34;test-queue&amp;#34;,
                    capabilities: Capabilities.Queue
                } as CommandExtendedData,
                parameters: [{
                    name: &amp;#34;body&amp;#34;,
                    systemId: &amp;#34;body&amp;#34;,
                    deviceId: &amp;#34;body&amp;#34;,
                    dataType: &amp;#34;Object&amp;#34;,
                    deviceType: &amp;#34;Object&amp;#34;,
                    extendedData: {
                        parameterType: ParameterType.Body
                    } as CommandParameterExtendedData
                } as CommandParameter],
            } as Command),
        ],
        communication: {},
        events: [],
        properties: []
    });
const driver: AMQPDeviceDriver = await startDriver(container, hostname, amqpPort);
const queueMessages = await checkQueueMessages(&amp;#34;test-queue&amp;#34;);
    const cp = new Map&amp;lt;string, any&amp;gt;();
    cp.set(&amp;#34;body&amp;#34;, { &amp;#34;test&amp;#34;: &amp;#34;test123&amp;#34; });
await driver.executeCommand(&amp;#34;Queue&amp;#34;, cp);
chai.expect(await checkQueueMessages(&amp;#34;test-queue&amp;#34;) === queueMessages &amp;#43; 1, &amp;#34;Invalid number of messages&amp;#34;);
await driver.executeCommand(&amp;#34;Queue&amp;#34;, cp);
chai.expect(await checkQueueMessages(&amp;#34;test-queue&amp;#34;) === queueMessages &amp;#43; 2, &amp;#34;Invalid number of messages&amp;#34;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As mentioned, the start driver is a helper method that will handle the startup of the communication, that can be used in all our tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;async function startDriver(container: inversify.Container, hostname: string, amqpPort: number) {
    const driver: AMQPDeviceDriver = container.get&amp;lt;AMQPDeviceDriver&amp;gt;(COMMUNICATION_TYPES.Device.Driver);
    const configs: any = container.get(&amp;#34;Configurations&amp;#34;);
    const comSettings = (configs.communication as AMQPCommunicationSettings);
    comSettings.address = hostname;
    comSettings.port = amqpPort;
await driver.initialize();
    await driver.setConfigurations(configs);
    await driver.connect();
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Setup&amp;#39;&amp;#34;, () =&amp;gt; {
        chai.expect(driver.communicationState).to.equal(CommunicationState.Setup);
    });
    await driver.setupResult(true);
await TestUtilities.waitForNoError(2000, &amp;#34;CommunicationState is not &amp;#39;Communicating&amp;#39;&amp;#34;, () =&amp;gt; {
        chai.expect(driver.communicationState).to.equal(CommunicationState.Communicating);
    });
    return driver;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;createQueue&lt;/code&gt; and &lt;code&gt;checkQueueMessages&lt;/code&gt;, will query the Solace API to either create a Queue or to retrieve metadata from that Queue.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;async function createQueue(hostname: string, startTestContainer: StartedTestContainer, queueName: string = &amp;#39;test-queue&amp;#39;) {
    const sempUrl = `http://${hostname}:${startTestContainer.getMappedPort(8080)}`;
    const adminAuth = &amp;#39;Basic &amp;#39; &amp;#43; Buffer.from(&amp;#39;admin:admin&amp;#39;).toString(&amp;#39;base64&amp;#39;);
await fetch(`${sempUrl}/SEMP/v2/config/msgVpns/default/queues`, {
        method: &amp;#39;POST&amp;#39;,
        headers: {
            &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;,
            &amp;#39;Authorization&amp;#39;: adminAuth
        },
        body: JSON.stringify({
            queueName,
            accessType: &amp;#39;exclusive&amp;#39;,
            permission: &amp;#39;consume&amp;#39;,
            ingressEnabled: true,
            egressEnabled: true
        })
    });
}
async function checkQueueMessages(queueName: string) {
const sempUrl = `http://${hostname}:${startTestContainer.getMappedPort(8080)}`;
const adminAuth = &amp;#39;Basic &amp;#39; &amp;#43; Buffer.from(&amp;#39;admin:admin&amp;#39;).toString(&amp;#39;base64&amp;#39;);
    const response = await fetch(`${sempUrl}/SEMP/v2/monitor/msgVpns/default/queues/${queueName}`, {
        headers: {
            &amp;#39;Authorization&amp;#39;: adminAuth
        }
    });
const queueData = await response.json();
    if (queueData.meta.responseCode == 400) {
        throw new Error(queueData.meta.error.description);
    }
    const messageCount: number = queueData.data.spooledMsgCount;
return messageCount;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Seeing it Run
&lt;div id="seeing-it-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#seeing-it-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In order to run our tests we can simply run &lt;code&gt;npm run test&lt;/code&gt;. This will invoke all our configured tests and output the results.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Test run"
src="https://image.j-roque.com/posts/20250929-amqp-driver/runtestsamqp.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can also generate a coverage report by running &lt;code&gt;npm run test:cover&lt;/code&gt;. It also generates an html report where we can see all lines traversed by our testing and the one&amp;rsquo;s we are still missing.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Coverage"
src="https://image.j-roque.com/posts/20250929-amqp-driver/testcoverage.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this use case we can see how with some simple methods we have created a driver interface for an AMQP publisher.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250929-amqp-driver/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250929-amqp-driver/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Keeping Data in the IoT Layer</title>
<link>https://j-roque.com/posts/20250923-iot-persistency/</link>
<pubDate>Tue, 23 Sep 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250923-iot-persistency/</guid>
<description>A Journey through the IoT Persistency</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250923-iot-persistency/featured.png" alt="Keeping Data in the IoT Layer" /&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This blog post will try to explain the different layers of an integration and what is the correct level to store information.&lt;/p&gt;
&lt;p&gt;We will also see new improvements in version &lt;strong&gt;11.2 to the persistency API&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Layers
&lt;div id="layers" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#layers" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;MES&lt;/strong&gt; is great at keeping all the &lt;strong&gt;information&lt;/strong&gt; regarding &lt;strong&gt;what is happening in the shop floor&lt;/strong&gt;. Nevertheless, there are different levels of information required for each of the shopfloor applications.&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;ERP&lt;/strong&gt;, from a manufacturing perspective, is focused on &lt;strong&gt;information&lt;/strong&gt; that will have &lt;strong&gt;financial impact&lt;/strong&gt;. It focuses on material losses, raw materials spent and materials produced. It is focused on storing information that will be helpful in assessing the financial health of the organization. The MES is harder to pinpoint, it holds information about multiple facets of the shop floor, from production to quality to machine recipes.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;integration layer&lt;/strong&gt; also requires a particular subset of information that may be only relevant for its layer. It is important for the integration layer to have the ability to be somewhat stateful. This can be due to performance or simply to &lt;strong&gt;keep machine logic in the integration layer, separate from the business layer&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Connect IoT often leverages the MES in order to infer and provide information and context.&lt;/p&gt;
&lt;p&gt;If for a particular resource, with a particular product, the recipe is &lt;code&gt;Recipe A&lt;/code&gt;, the MES is the owner of this context, as the inference cannot be done just by the context of the integration layer. This tight link between Connect IoT and the MES, enables us to simplify what would otherwise be very complex problems. Where the integration layer would have to integrate with third party systems to try and understand in what context it is at and what is the proper response.&lt;/p&gt;
&lt;p&gt;Connect IoT is nevertheless an on edge application and storing information in the MES and retrieving it has a performance penalty. This can be meaningless or can be vital to have a viable integration strategy.&lt;/p&gt;
&lt;p&gt;There is information that is relevant both for MES and IoT, but we can think of examples where that information is only relevant for the integration.&lt;/p&gt;
&lt;p&gt;One common example is, some machines use the concept of virtual ids, they receive materials from load ports and then attribute them a virtual id. This virtual id has no meaning outside of the integration. It is common for virtual ids to be stored in the integration layer, kept as a mapping between the material name, known to the MES, and the virtual id, known to the machine.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Vid Scenario"
src="https://image.j-roque.com/posts/20250923-iot-persistency/vidscenario.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Vid Scenario IoT"
src="https://image.j-roque.com/posts/20250923-iot-persistency/vidscenarioiot.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Storing Information in Connect IoT
&lt;div id="storing-information-in-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#storing-information-in-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Connect IoT has out of the box support for storing data. Data can either be stored &lt;strong&gt;in memory&lt;/strong&gt; or &lt;strong&gt;in file&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The file persistency consists of two layers, a memory cache and a file persistency. This allows the user to have very fast data access and resiliency. In case of an application crash, the system is able to load the information stored in the file, loading the file to memory.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Temporary Storage
&lt;div id="temporary-storage" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#temporary-storage" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s see a simple example. In this example the user wants to store the current material being processed, provided via a message bus message. We will also have a mirror message to retrieve the stored value.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store Temporary Workflow"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storeMaterialNameWorkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We will store this information as &lt;code&gt;Temporary&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set as Temporary"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storeMaterialName.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s do a small experiment where we will perform a store and a retrieve.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store and Retrieve Temporary"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storeandretrieveTemporary.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Note that this is stored in the volatile cache. If the manager is restarted the stored information is lost.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store and Retrieve Temporary fail"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storeandretrieveTemporaryNotFound.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Persistent Storage
&lt;div id="persistent-storage" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#persistent-storage" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s now go back and use the &lt;code&gt;Persistent&lt;/code&gt; option. Now if we restart the manager the information is still available.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store and Retrieve Persistent"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storeandretrievePersistent.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Well, it does seem magical, but looking a bit under the hood, we can understand how this mechanism works.&lt;/p&gt;
&lt;p&gt;In the Automation Manager configuration tab we provide a set of settings that control the storage, this information is detailed &lt;a
href="https://help.criticalmanufacturing.com/installationguide/post-installation/connect-iot/connectiotautomationmanagerconfig/#storage-structure"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Manager Configuration"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storageConfigurations.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;If we take a look at our manager persistency in the temp folder:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Stored Information File"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storedFile.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Here we see some metadata and the value stored.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; When the value stored is an object, it will create a new file and keep a pointer to that file in the &lt;code&gt;connectIoT_ControlFile.json&lt;/code&gt;.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;It is important to understand that the file storage will only be used if the in memory cache does not have the value and needs to be recreated from the file persistency. In a normal running scenario the in memory cache will be used in order to be more performant.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store Data IoT Persistent"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storedataiot.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Retrieve Data IoT Persistent"
src="https://image.j-roque.com/posts/20250923-iot-persistency/retrievedataiot.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Persistent Storage - Code Task &amp;amp; Custom Task
&lt;div id="persistent-storage---code-task--custom-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#persistent-storage---code-task--custom-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;persistency API&lt;/strong&gt; is also available either in the Code Task or with the use of customization.&lt;/p&gt;
&lt;p&gt;In the &lt;code&gt;Code Task&lt;/code&gt; we can access it in the &lt;code&gt;framework&lt;/code&gt; object.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import { Framework } from &amp;#39;framework&amp;#39;;
export default class {
/** Allows accessing external functions */
    private framework: Framework;
constructor(framework: Framework) {
        this.framework = framework;
    }
/*
     * Entry point of the class (IMPORTANT: don&amp;#39;t change the signature of this method)
     * Should return an object containing the values for each output to emit
     * If necessary, use the parameter &amp;#34;outputs&amp;#34; to emit data while running the code.
     */
    public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
await this.framework.dataStore.store(&amp;#34;materialName&amp;#34;, inputs.materialName, &amp;#34;Temporary&amp;#34;);
const retrievedValue = await this.framework.dataStore.retrieve(&amp;#34;materialName&amp;#34;, &amp;#34;&amp;#34;);
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a &lt;code&gt;Custom Task&lt;/code&gt; we can access the storage by accessing the &lt;code&gt;this._datastore&lt;/code&gt; object.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        this.activate = undefined;
this._dataStore.store(&amp;#34;materialName&amp;#34;, this.materialName, DataStoreLocation.Persistent);
const retrievedValue = this._dataStore.retrieve(&amp;#34;materialName&amp;#34;, &amp;#34;&amp;#34;);
this.success.emit(true);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These apis provide the groundwork for more complex implementations and manipulations of stored objects.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Even Better
&lt;div id="even-better" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#even-better" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In &lt;strong&gt;11.2&lt;/strong&gt; the &lt;strong&gt;persistency API&lt;/strong&gt; was one of the main focus of improvement.&lt;/p&gt;
&lt;p&gt;There were two main focus:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Queues&lt;/li&gt;
&lt;li&gt;Expiration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now the apis have native support for queueing and dequeueing data and for storing data that has a time to live.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Queues
&lt;div id="queues" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#queues" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Queues open a whole range of use cases. For now the supported types of queues are FIFO (First in First Out), LIFO (Last in First Out) and FEFO (First Expired First Out).&lt;/p&gt;
&lt;p&gt;Starting with the classical case, we will store values 1, 2 and 3 and we will retrieve them in FIFO order.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="FIFO Workflow"
src="https://image.j-roque.com/posts/20250923-iot-persistency/FIFOWorkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now seeing it run, we can see that we are doing a consecutive storage of 1, 2 and 3. Then we are using a loop to retrieve the values of the queue, in the order they entered the queue.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="FIFO Example"
src="https://image.j-roque.com/posts/20250923-iot-persistency/fifoexample.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Doing the same but changing the order, we will now retrieve them with a LIFO:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="LIFO Example"
src="https://image.j-roque.com/posts/20250923-iot-persistency/lifoexample.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Expiration
&lt;div id="expiration" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expiration" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Some values are no longer useful if they are stale. This is where expiration can help us. We can store elements and at the same time provide more information of the time where they are still valid or can still be used. After the time threshold is reached the value will be discarded.&lt;/p&gt;
&lt;p&gt;We can try and create a simple scenario to demonstrate this behavior.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store With Expiration"
src="https://image.j-roque.com/posts/20250923-iot-persistency/storewithexpiration.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Expiration Example"
src="https://image.j-roque.com/posts/20250923-iot-persistency/expiration.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The value can be continuously updated to update its expiration.&lt;/p&gt;
&lt;h3 class="relative group"&gt;11.2 - Code Task &amp;amp; Custom Task
&lt;div id="112---code-task--custom-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#112---code-task--custom-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The api has been expanded for version 11.2, from queues and expirations to additional helper methods, like &lt;strong&gt;delete&lt;/strong&gt; (deletes a stored value), &lt;strong&gt;clear&lt;/strong&gt; (clears all values) and &lt;strong&gt;listKeys&lt;/strong&gt; (Lists all identifiers for stored values).&lt;/p&gt;
&lt;p&gt;For queues we now have the &lt;strong&gt;storeInQueue&lt;/strong&gt; (stores an element in a queue), &lt;strong&gt;retrieveFromQueue&lt;/strong&gt; (retrieves an element from a queue) and the &lt;strong&gt;peekFromQueue&lt;/strong&gt; (retrieves the first valid element from a queue without removing it from the queue).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="API"
src="https://image.j-roque.com/posts/20250923-iot-persistency/api.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Expiration is part of the options of the &lt;strong&gt;storeInQueue&lt;/strong&gt; and the &lt;strong&gt;store&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Using the code task, we now have a StoreOptions type that allows us to configure the expiration and the queue settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import { Framework, StoreOptions } from &amp;#39;framework&amp;#39;;
export default class {
/** Allows accessing external functions */
    private framework: Framework;
constructor(framework: Framework) {
        this.framework = framework;
    }
/*
     * Entry point of the class (IMPORTANT: don&amp;#39;t change the signature of this method)
     * Should return an object containing the values for each output to emit
     * If necessary, use the parameter &amp;#34;outputs&amp;#34; to emit data while running the code.
     */
    public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
        // Get Next Day counting from this moment
        var nextDay = new Date();
        nextDay.setDate(nextDay.getDate() &amp;#43; 1);
await this.framework.dataStore.store(&amp;#34;materialName&amp;#34;, inputs.materialName, &amp;#34;Temporary&amp;#34;, {
            validUntil: nextDay.getUTCMilliseconds()
        } as StoreOptions);
await this.framework.dataStore.storeInQueue(&amp;#34;myqueue&amp;#34;, inputs.materialName, {
            location: &amp;#34;Temporary&amp;#34;,
            validUntil: nextDay.getUTCMilliseconds()
        } as StoreOptions);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a Custom Task it will be similar.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        this.activate = undefined;
this._dataStore.store(&amp;#34;materialName&amp;#34;, this.materialName, DataStoreLocation.Persistent, {
            validUntil: nextDay.getUTCMilliseconds()
        } as StoreOptions);
this._dataStore.store(&amp;#34;myqueue&amp;#34;, this.materialName, DataStoreLocation.Persistent, {
            location: &amp;#34;Temporary&amp;#34;,
            validUntil: nextDay.getUTCMilliseconds()
        } as StoreOptions);
this.success.emit(true);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when building your customization you are able to easily manipulate the persistency, from creating queues, managing expirations and having all the create, delete and clear commands.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Storing data on edge is an essential consideration when building on edge integrations. Either as a separation of concerns, storing information only where it is needed, or as a performance consideration. Being able to have a simple and easy way to persist information in a resilient way is a must have for any IoT Platform. In this blog post we mentioned out of the box approaches, in a previous blog post we mentioned the use of SQL Lite as an alternative to be able to perform SQL queries, &lt;a
href="https://j-roque.com/posts/20250627-sqlite-iii/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250923-iot-persistency/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250923-iot-persistency/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part II - MTConnect Driver - Building Some Tests</title>
<link>https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/</link>
<pubDate>Thu, 28 Aug 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/</guid>
<description>MTConnect Driver - Building Some Tests</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/featured.png" alt="Part II - MTConnect Driver - Building Some Tests" /&gt;&lt;/p&gt;&lt;p&gt;In the last blog post we took some time assembling brick-by-brick a customization driver in Connect IoT. We choose to create a driver for &lt;strong&gt;MTConnect&lt;/strong&gt; a common standard for CNC Machines.&lt;/p&gt;
&lt;p&gt;The full implementation can be found &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/MTConnect/Cmf.Custom.Tests/Cmf.Custom.Tests.IoT/Tests/MTConnect.cs"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this blog post the goal will be to join two previous posts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://j-roque.com/posts/20250516-testinglowcode/"
target="_blank"
&gt;Testing Low Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/"
target="_blank"
&gt;Building an MTConnect Driver&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This will be a very simple and to the point, entry on how you can create your low code workflows and quickly create simple and easy to run tests.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating Some Workflows
&lt;div id="creating-some-workflows" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-some-workflows" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Even though the test tool supports all kinds of cases, we just want to focus on testing &lt;strong&gt;MTConnect&lt;/strong&gt; in very simple flows. So let&amp;rsquo;s create some simple workflows&lt;/p&gt;
&lt;h3 class="relative group"&gt;Setup
&lt;div id="setup" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#setup" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Here we will use the templated setup workflow.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Probe Event
&lt;div id="probe-event" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#probe-event" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Probe Event"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/probeEvent.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Current Command
&lt;div id="current-command" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#current-command" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Current Command"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/currentCommand.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we have some very simple workflows, but how can we run build tests for them? Do we need a live system to run our tests? Let&amp;rsquo;s try and answer all these questions.&lt;/p&gt;
&lt;h2 class="relative group"&gt;IoT Test Orchestrator
&lt;div id="iot-test-orchestrator" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#iot-test-orchestrator" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;CM provides a library to create tests for low code Connect IoT workflows, the IoT Test Orchestrator. With this library we can leverage existing plugins for equipment simulators or leverage existing simulators. The MTConnect client is essentially a REST Client that has known APIs.&lt;/p&gt;
&lt;p&gt;Fortunately, the IoT Test Orchestrator already provides a &lt;strong&gt;REST Server simulator&lt;/strong&gt;, so we don&amp;rsquo;t need to create a simulator plugin from scratch.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Exporting the Integration from the MES
&lt;div id="exporting-the-integration-from-the-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#exporting-the-integration-from-the-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the Automation Controller view we can export not only the Automation Controller but also all entities that it depends on, like Automation Driver Definitions and Automation Protocols.&lt;/p&gt;
&lt;p&gt;After exporting we can include it in our CLI IoT Data Package.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Preparing the Test Workspace
&lt;div id="preparing-the-test-workspace" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#preparing-the-test-workspace" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In our test solution we can now add all the nugets of the Test Orchestrator:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cmf.ConnectIoT.TestOrchestrator.Core.LBOs&lt;/li&gt;
&lt;li&gt;Cmf.ConnectIoT.TestOrchestrator.Core.ScenarioBuilder&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/plugins/simulators/RestServer/"
target="_blank"
&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.Simulator.RestServer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/configuration/startmode/"
target="_blank"
&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.StartMode.Local&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/plugins/system/"
target="_blank"
&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.System.MockSystem&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Personally, I like to add my Master Data with a link, but there are several other ways.&lt;/p&gt;
&lt;p&gt;In the &lt;code&gt;csproj&lt;/code&gt; add our implementation Master Data.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;ItemGroup&amp;gt;
  &amp;lt;None Include=&amp;#34;../../Cmf.Custom.IoT/Cmf.Custom.IoT.Data/Masterdata/**&amp;#34; CopyToOutputDirectory=&amp;#34;Always&amp;#34; /&amp;gt;
&amp;lt;/ItemGroup&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Creating a Test Scenario
&lt;div id="creating-a-test-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-test-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Our goal for this test is to be able to &lt;strong&gt;run tests of our low code implementation without any live MES System&lt;/strong&gt; (the tool also supports tests with a live MES System). In order to configure our tool we need to build our test scenario.&lt;/p&gt;
&lt;p&gt;The first important part of the configuration is defining how our Automation Manager for this test will behave. All of the configurations are defined &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/configuration/config/"
target="_blank"
&gt;here&lt;/a&gt;. For this test we will define a &lt;strong&gt;ManagerId&lt;/strong&gt; and a place to store our &lt;strong&gt;packages cache&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;this.configuration = new ScenarioConfiguration()
  .ManagerId(&amp;#34;MTConnectManager&amp;#34;)
  //.WriteLogsTo(@&amp;#34;C:\IoT\logs.txt&amp;#34;)
  .Config(new Common.Objects.Config.ConfigBuilder()
      .CacheLocation(Path.GetTempPath())
      .Build())&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The start mode will define how the Automation Manager will start. With the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/configuration/startmode/"
target="_blank"
&gt;local start mode&lt;/a&gt; plugin the manager will receive a repository (or a package location) and will download the manager specified, untar it and run it.&lt;/p&gt;
&lt;p&gt;For my test I used a local drive, but it also supports network drives.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;private const string PACKAGES_LOCATION = @&amp;#34;c:\IoT\Repository&amp;#34;;
private const string MANAGER = @&amp;#34;criticalmanufacturing-connect-iot-manager-11.1.7-dev.tgz&amp;#34;;
(...)
  .StartMode&amp;lt;LocalStartMode.PluginMain&amp;gt;(new LocalStartMode.Plugin.SettingsBuilder()
      .ManagerLocation(Path.Join(PACKAGES_LOCATION, MANAGER))
      .Build())&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/test-orchestrator/plugins/system/"
target="_blank"
&gt;system&lt;/a&gt; plugin will mock all CM&amp;rsquo;s required components like the CM MES and the message bus. It will receive a master data configuration and create a mirror MES system.&lt;/p&gt;
&lt;p&gt;We can pass all our information via &lt;strong&gt;master data&lt;/strong&gt; or also leverage our builder for some entities.&lt;/p&gt;
&lt;p&gt;In Connect IoT it is common to provide definitions to the customer and not instances, so we can leave that part to the test, also true for MES modeling.&lt;/p&gt;
&lt;p&gt;We are also randomizing the port, just to avoid test concurrency.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;var masterdata = @&amp;#34;1.0.0/DemoMTController.json&amp;#34;;
var port = new Random().Next(5000, 30000);
File.WriteAllText(masterdata, File.ReadAllText(masterdata).Replace(&amp;#34;5000&amp;#34;, port.ToString()));
(...)
  .SystemPlugin&amp;lt;MockSystem.PluginMain&amp;gt;(new MockSystem.Plugin.SettingsBuilder()
      .Repository(PACKAGES_LOCATION)
      .MasterdataPath(masterdata)
      .AddSite(&amp;#34;TestSite&amp;#34;)
      .AddManager(&amp;#34;MTConnectManager&amp;#34;)
      .AddControllerInstance(&amp;#34;Demo MTConnect Controller [A.1]&amp;#34;, &amp;#34;MTConnectManager&amp;#34;, &amp;#34;TestSite&amp;#34;)
      .AddDriverInstance(&amp;#34;Demo MTConnect Controller [A.1]&amp;#34;, &amp;#34;MTConnect&amp;#34;, &amp;#34;MTConnectManager&amp;#34;, &amp;#34;TestSite&amp;#34;)
      .Build())&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we can create the configuration for our test simulator. The REST Server has a very interesting feature, a &lt;strong&gt;REST request mapping recorder&lt;/strong&gt;. We can set the test with the mapping recorder and then mapped all the required APIs, let&amp;rsquo;s see how to do that.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;(...)
  .AddSimulatorPlugin&amp;lt;RestServer.PluginMain&amp;gt;(new RestServer.Plugin.SettingsBuilder()
      .Address(&amp;#34;http://127.0.0.1&amp;#34;, port)
      .ServerSettings(new WireMock.Settings.WireMockServerSettings()
      {
          StartAdminInterface = true,
          ProxyAndRecordSettings = new WireMock.Settings.ProxyAndRecordSettings()
          {
              ProxyAll = true,
              Url = &amp;#34;http://localhost:5000&amp;#34;,
              SaveMapping = true,
              SaveMappingToFile = true,
              AllowAutoRedirect = true,
          }
      })
      .Build());&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the &lt;strong&gt;ServerSettings&lt;/strong&gt; we start the admin interface and the recorder, then we route all my traffic to the MTConnect TrackHound &lt;a
href="https://github.com/TrakHound/MTConnect.NET/tree/master/agent/MTConnect.NET-Agent"
target="_blank"
&gt;agent&lt;/a&gt; we used for development. Now we can get all the mappings generated by the boot up cycle of the driver.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Record Requests"
src="https://image.j-roque.com/posts/20250828-iot-mtconnectdriver-ii/recordmappings.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now, we can see all the requests that we need to map. For our tests we need to map the &lt;strong&gt;Probe Requests&lt;/strong&gt; and the &lt;strong&gt;Current Request&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;  private static SystemReply DefaultProbeReply()
  {
      var reply = new SystemReply(200, &amp;#34;&amp;lt;MTConnectDevices&amp;gt;(...)/MTConnectDevices&amp;gt;&amp;#34;);
      reply.Headers.Clear();
      reply.Headers.Add(&amp;#34;Content-Type&amp;#34;, &amp;#34;application/xml&amp;#34;);
      return reply;
  }
private static SystemReply DefaultCurrentReply()
  {
      var reply = new SystemReply(200, &amp;#34;&amp;lt;MTConnectStreams&amp;gt;(...)/MTConnectStreams&amp;gt;&amp;#34;);
      reply.Headers.Clear();
      reply.Headers.Add(&amp;#34;Content-Type&amp;#34;, &amp;#34;application/xml&amp;#34;);
      return reply;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Creating a Test for the Probe Event
&lt;div id="creating-a-test-for-the-probe-event" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-test-for-the-probe-event" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Remember that our workflow for the probe event was very simple. We are just logging a message &lt;code&gt;RECEIVED A PROBE&lt;/code&gt; when we receive a probe event in the &lt;code&gt;On Equipment Event&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The test is also very simple, we register our probe event to check if a request is received and to provide a demo reply.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;[TestMethod]
public void ConnectToMTConnect_ProbeEvent()
{
    new TestScenario(this.configuration).Run((scenario, context) =&amp;gt;
    {
        #region Setup
var system = (MockSystem.PluginMain)context.System;
        var restSimulator = (RestServer.PluginMain)context.Simulators[&amp;#34;RestServer&amp;#34;];
var controllerInstance = context.Instances[&amp;#34;Controller&amp;#34;] as IControllerInstanceContext;
        var controllerInstanceLog = (controllerInstance as IInstanceContext).Log;
var probeRequest = false;
        restSimulator.Register(&amp;#34;/Mazak/probe&amp;#34;, (ISystemRequest request) =&amp;gt;
        {
            probeRequest = true;
            return DefaultProbeReply();
        }, Common.Objects.Rest.RestVerb.Get);
system.WaitForAllComunicating(20);
#endregion Setup
scenario.Utilities.WaitFor(50, &amp;#34;No Probe Request Was called&amp;#34;, () =&amp;gt;
        {
            return probeRequest;
        });
Assert.IsTrue(controllerInstanceLog.Any(log =&amp;gt; log.Contains(&amp;#34;RECEIVED A PROBE&amp;#34;)));
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Creating a Test for Execute Command for Current API
&lt;div id="creating-a-test-for-execute-command-for-current-api" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-test-for-execute-command-for-current-api" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This test is also similar. In a particular polling time, the workflow will execute an equipment command and log the outcome. For this test we will register our default reply for the current api and validate that the log was logged correctly.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;[TestMethod]
public void ConnectToMTConnect_CurrentCommand()
{
    new TestScenario(this.configuration).Run((scenario, context) =&amp;gt;
    {
        #region Setup
var system = (MockSystem.PluginMain)context.System;
        var restSimulator = (RestServer.PluginMain)context.Simulators[&amp;#34;RestServer&amp;#34;];
var controllerInstance = context.Instances[&amp;#34;Controller&amp;#34;] as IControllerInstanceContext;
        var controllerInstanceLog = (controllerInstance as IInstanceContext).Log;
restSimulator.Register(&amp;#34;/Mazak/current&amp;#34;, (ISystemRequest request) =&amp;gt;
        {
            return DefaultCurrentReply();
        }, Common.Objects.Rest.RestVerb.Get);
system.WaitForAllComunicating(20);
#endregion Setup
scenario.Utilities.WaitFor(50, &amp;#34;No Current Request Was called&amp;#34;, () =&amp;gt;
        {
            var msg1 = controllerInstanceLog.Any(log =&amp;gt; log.Contains(&amp;#34;Current Command|task_1934|equipmentCommand] Successfully executed request command &amp;#39;connect.iot.driver.template.executeCommand&amp;#39; with Message &amp;#39;CurrentCommand&amp;#39;.&amp;#34;));
            var msg2 = controllerInstanceLog.Any(log =&amp;gt; log.Contains(&amp;#34;Current Command|task_1979|logMessage] {\&amp;#34;Header\&amp;#34;:{\&amp;#34;InstanceId\&amp;#34;:1756395673&amp;#34;));
            return msg1 &amp;amp;&amp;amp; msg2;
        });
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we can run all our tests.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Record Requests"
src="https://image.j-roque.com/posts/20250828-iot-mtconnectdriver-ii/runningtests.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Note that with the &lt;code&gt;WriteLogsTo&lt;/code&gt; configuration enabled we are able to see live what the test is doing and intercept logs from all components. This is very helpful to troubleshoot any issue.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With this example we are able to not just build an MTConnect driver, but start testing our project workflows all with minimal effort.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250828-iot-mtconnectdriver-ii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part I - MTConnect Driver</title>
<link>https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/</link>
<pubDate>Thu, 14 Aug 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/</guid>
<description>MTConnect Driver - Implementing a driver</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/featured.png" alt="Part I - MTConnect Driver" /&gt;&lt;/p&gt;&lt;p&gt;An example of how to build a driver using CM Connect IoT, for a very popular driver. All the source code of this driver is available &lt;a
href="https://github.com/jrk94/cm-demo-repos/tree/main/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-mtconnect"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;MTConnect&lt;/strong&gt; is a very common standard in the world of &lt;strong&gt;CNC (Computer Numerical Control) machines&lt;/strong&gt;. These are machines that have very long life cycles and typically have had very disparate interface support. Some are very capable, able to have full control and recipe management but most being quite limited.&lt;/p&gt;
&lt;p&gt;MTConnect is an &lt;strong&gt;open standard&lt;/strong&gt; that tries to mitigate this gap, by having an &lt;strong&gt;easy and standard way to make information available&lt;/strong&gt;. It is &lt;strong&gt;vendor agnostic and is read only&lt;/strong&gt;. It is part of the push of Industry 4.0 to surface machine information that was until now being ignored or discarded.&lt;/p&gt;
&lt;p&gt;It uses an XML based format to provide machine information through HTTP. It allows for machines that were, until know, black boxes to easily start servicing information.&lt;/p&gt;
&lt;p&gt;The MES is a common consumer of this type of machine data, for process control and material tracking. Therefore an MTConnect driver for Connect IoT makes a lot of sense.&lt;/p&gt;
&lt;p&gt;The standard is maintained by the &lt;a
href="https://www.mtconnect.org/"
target="_blank"
&gt;MTConnect Institute&lt;/a&gt;, and it’s widely adopted for CNC machines, robots, sensors, and other shop floor equipment.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Architecture
&lt;div id="architecture" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#architecture" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s take some time understanding how the protocols works. MTConnect follows a &lt;strong&gt;client–agent–adapter&lt;/strong&gt; model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Device → Adapter → Agent → Client Applications&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MTConnect Architecture"
src="https://images.squarespace-cdn.com/content/v1/54011775e4b0bc1fe0fb8494/1580756684018-N5RCU59TFLRBVMYDV664/mtconnect-web-02.jpg?format=2500w"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Device&lt;/strong&gt; will be the &lt;strong&gt;equipment&lt;/strong&gt; that we want to gather information from.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Adapter&lt;/strong&gt; is a &lt;strong&gt;translation layer&lt;/strong&gt; that is able to convert the machine data into MTConnect’s Observation format. This can be a piece of software or a hardware adapter running the needed software. It turns the machine data into very simple key/value text lines.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spindle_speed|1250
feedrate|300&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;Agent&lt;/strong&gt; is agnostic to the machine and transforms the key/values into standardized MTConnect XML format. It also is responsible for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;hosting an HTTP Rest server&lt;/strong&gt;, that services the MTConnect endpoints&lt;/li&gt;
&lt;li&gt;implementing caching&lt;/li&gt;
&lt;li&gt;multiplexer mechanisms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It acts as the data cache and historical snapshot point.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Client&lt;/strong&gt; will be the &lt;strong&gt;data consumer&lt;/strong&gt;. It is the application that will be making requests to the &lt;strong&gt;Agent&lt;/strong&gt; to extract device information. The MES is a &lt;strong&gt;Client&lt;/strong&gt;, so our driver will be the creation of an MTConnect Client.&lt;/p&gt;
&lt;h3 class="relative group"&gt;MTConnect Endpoints
&lt;div id="mtconnect-endpoints" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mtconnect-endpoints" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In MTConnect the agent provides a set of endpoints.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;/probe        → device discovery
/current      → latest values
/sample       → historical time-series data
/assets       → tooling or program assets
/asset        → tooling or program asset&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;/probe&lt;/strong&gt; – Device Discovery&lt;/p&gt;
&lt;p&gt;Returns the device model — a hierarchical description of all components, data items, and capabilities of the connected machines.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Usage&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Called at the start of a client session to understand what the agent can provide.&lt;/li&gt;
&lt;li&gt;Defines available DataItems (e.g., spindle speed, feed rate, temperature).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Output&lt;/strong&gt;: XML or JSON that matches the Devices schema.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;GET http://&amp;lt;agent&amp;gt;/probe&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;Device name=&amp;#34;CM_VF2&amp;#34; uuid=&amp;#34;12345&amp;#34;&amp;gt;
  &amp;lt;Component id=&amp;#34;c1&amp;#34; name=&amp;#34;spindle&amp;#34;&amp;gt;
    &amp;lt;DataItem category=&amp;#34;SAMPLE&amp;#34; id=&amp;#34;d1&amp;#34; type=&amp;#34;SPINDLE_SPEED&amp;#34; units=&amp;#34;REVOLUTION/MINUTE&amp;#34;/&amp;gt;
  &amp;lt;/Component&amp;gt;
&amp;lt;/Device&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It exists to be called when configuration changes or at client initialization.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;/current&lt;/strong&gt; – Latest Values&lt;/p&gt;
&lt;p&gt;Returns the most recent value for each DataItem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Usage&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Quick “snapshot” of the machine’s current state.&lt;/li&gt;
&lt;li&gt;Useful for dashboards that don’t need time-series history.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Output&lt;/strong&gt;: XML or JSON with a timestamp for each DataItem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;GET http://&amp;lt;agent&amp;gt;/current&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;SpindleSpeed timestamp=&amp;#34;2025-08-13T12:45:30Z&amp;#34; sequence=&amp;#34;1523&amp;#34;&amp;gt;1250&amp;lt;/SpindleSpeed&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Can be called every few seconds if you want near-real-time monitoring, or on demand.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;/sample&lt;/strong&gt; – Time-Series Data&lt;/p&gt;
&lt;p&gt;Returns a sequence of observations for each DataItem between a from and to sequence number.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Usage&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Historical analysis.&lt;/li&gt;
&lt;li&gt;Incremental polling: clients track the last sequence number and request new data since then.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Query Parameters&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;from – starting sequence number.&lt;/li&gt;
&lt;li&gt;count – max number of samples to return.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Output&lt;/strong&gt;: XML or JSON with a timestamp for each DataItem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;GET http://&amp;lt;agent&amp;gt;/sample?from=1523&amp;amp;count=100&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;SpindleSpeed timestamp=&amp;#34;2025-08-13T12:45:30Z&amp;#34; sequence=&amp;#34;1523&amp;#34;&amp;gt;1250&amp;lt;/SpindleSpeed&amp;gt;
&amp;lt;SpindleSpeed timestamp=&amp;#34;2025-08-13T12:45:31Z&amp;#34; sequence=&amp;#34;1524&amp;#34;&amp;gt;1260&amp;lt;/SpindleSpeed&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Can be called continuously in a loop for streaming-like updates.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;/assets&lt;/strong&gt; – Non-Time-Series Data&lt;/p&gt;
&lt;p&gt;Provides static or semi-static information such as tool data, part programs, or maintenance logs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Usage&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Retrieve tool offsets, part program code, or serialized asset metadata.&lt;/li&gt;
&lt;li&gt;Often updated manually on the device or on events (e.g., tool change).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Query Parameters&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;type – filter assets by type (e.g., CuttingTool).&lt;/li&gt;
&lt;li&gt;count – limit results.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Output&lt;/strong&gt;: XML or JSON with a timestamp for each DataItem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;GET http://&amp;lt;agent&amp;gt;/assets?type=CuttingTool&amp;amp;count=10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;CuttingTool assetId=&amp;#34;T123&amp;#34;&amp;gt;
  &amp;lt;Description&amp;gt;1/2 inch End Mill&amp;lt;/Description&amp;gt;
  &amp;lt;Measurements&amp;gt;
    &amp;lt;Length value=&amp;#34;50.0&amp;#34; units=&amp;#34;MILLIMETER&amp;#34;/&amp;gt;
  &amp;lt;/Measurements&amp;gt;
&amp;lt;/CuttingTool&amp;gt;
&amp;lt;CuttingTool assetId=&amp;#34;T456&amp;#34;&amp;gt;
  &amp;lt;Description&amp;gt;1/2 inch End Mill&amp;lt;/Description&amp;gt;
  &amp;lt;Measurements&amp;gt;
    &amp;lt;Length value=&amp;#34;55.0&amp;#34; units=&amp;#34;MILLIMETER&amp;#34;/&amp;gt;
  &amp;lt;/Measurements&amp;gt;
&amp;lt;/CuttingTool&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;/asset/&lt;id&gt;&lt;/strong&gt; – Specific Asset&lt;/p&gt;
&lt;p&gt;Retrieves a single asset by ID.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Usage&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fetch the latest details about a specific tool or part.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Output&lt;/strong&gt;: XML with a timestamp for each DataItem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;GET http://&amp;lt;agent&amp;gt;/asset/T123&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;CuttingTool assetId=&amp;#34;T123&amp;#34;&amp;gt;
  &amp;lt;Description&amp;gt;1/2 inch End Mill&amp;lt;/Description&amp;gt;
  &amp;lt;Measurements&amp;gt;
    &amp;lt;Length value=&amp;#34;50.0&amp;#34; units=&amp;#34;MILLIMETER&amp;#34;/&amp;gt;
  &amp;lt;/Measurements&amp;gt;
&amp;lt;/CuttingTool&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Building a Connect IoT Driver
&lt;div id="building-a-connect-iot-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-connect-iot-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the first decisions in any driver is what &lt;strong&gt;tools do you require to build driver&lt;/strong&gt; support. For most drivers the starting point is made easy by the availability of &lt;strong&gt;SDKs&lt;/strong&gt; or other tools made &lt;strong&gt;available by vendors or foundations&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;For this particular example, we could simply use nodejs packages in order to perform http requests and implement all the xml parsers and validations. Fortunately, there are already a set of available and open source tools that do that work for us.&lt;/p&gt;
&lt;p&gt;After some state of the art evaluation of the SDKs and tools for MTConnect we noticed that the .Net SDK &lt;a
href="https://github.com/TrakHound/MTConnect.NET"
target="_blank"
&gt;MTConnect.NET&lt;/a&gt; had much more support and activity than other in .Net and in nodeJs. Also, very conveniently &lt;a
href="https://www.trakhound.com/site/"
target="_blank"
&gt;TrackHound&lt;/a&gt; provides a simple &lt;a
href="https://github.com/TrakHound/MTConnect.NET/tree/master/agent/MTConnect.NET-Agent"
target="_blank"
&gt;agent&lt;/a&gt; that we can use to start testing our driver against.&lt;/p&gt;
&lt;p&gt;Even if &lt;strong&gt;Connect IoT is a nodeJs application, this is not an issue as it has full support for using .Net&lt;/strong&gt;. Let&amp;rsquo;s see how.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Building a Connect IoT Driver
&lt;div id="building-a-connect-iot-driver-1" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-connect-iot-driver-1" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The full process is described in our &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_driver_dotnet/"
target="_blank"
&gt;developer portal&lt;/a&gt;. We will use the &lt;strong&gt;CM CLI&lt;/strong&gt; to create our driver project.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Scaffolding a .Net Driver
&lt;div id="scaffolding-a-net-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scaffolding-a-net-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Connect IoT Driver is a component of a customization package for Connect IoT. In a CM customization project workspace, let&amp;rsquo;s create a &lt;strong&gt;new iot package&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Scaffolding"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/driverscaffolding.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Right away the scaffolding helps us a lot, by giving us a .Net solution where we can create our interface with MTConnect and a nodeJs workspace for the rest of the driver.&lt;/p&gt;
&lt;p&gt;The scaffolding already provides a full on buildable and packable solution. In order to interact with the driver we can use the same commands that will be used by the pipeline to create a package with &lt;code&gt;cmf build&lt;/code&gt; and &lt;code&gt;cmf pack&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="CMF Build"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/drivercmfbuild.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can also decompose them into their subcommands.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;npm i&lt;/code&gt; to install the npm packages, &lt;code&gt;npm run build&lt;/code&gt; to build the code, &lt;code&gt;npm run test&lt;/code&gt; to test the code and &lt;code&gt;npm run packagePacker&lt;/code&gt; to create a &lt;strong&gt;.tgz&lt;/strong&gt; file with the driver bundled. Additionally, we also have support for the use of watchers that continuously build the code on changes, like &lt;code&gt;npm run watchPackage&lt;/code&gt;, with this after every change to your source code, the code will automatically compile.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="NPM Build"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/drivernpmrunbuild.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Defining our Settings for the UI
&lt;div id="defining-our-settings-for-the-ui" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#defining-our-settings-for-the-ui" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A good place to start the driver development is to start defining what are the &lt;strong&gt;settings the user will need to interact&lt;/strong&gt; with.&lt;/p&gt;
&lt;p&gt;For the driver, we will need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;address&lt;/strong&gt; - ip or hostname of the MTConnect Agent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;port&lt;/strong&gt; - port of the Agent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;device&lt;/strong&gt; - we can hook our whole implementation to a particular device and then have this as a wrapper for all requests&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Device is very helpful as it can help us tie in with MES Resources, or MES durable materials, etc. If the MTConnect agent is servicing a lot of resources a filter by device may make it easier to interact with the data. Our client will only connect to one MTConnect Adapter.&lt;/p&gt;
&lt;p&gt;In order for these settings to be available in the UI, we will need them to be part of our driver &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;parameters&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;netCoreSdkVersion&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;.Net Core SDK Version&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Set the .net core SDK version to use, when multiple are installed in the system where the driver is running. Leave empty to ignore this setting.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;address&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;MTConnect Agent Address&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Ip or Hostname of the MTConnect Agent&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;port&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;MTConnect Agent port&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Port of the MTConnect Agent (leave as -1 for it to not be added)&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;integer&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: -1
  },
  {
    &amp;#34;name&amp;#34;: &amp;#34;device&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;MTConnect Device&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;When specifying a device, all MTConnect interactions will filter by device.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  }
],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From analyzing the Protocol, we already know that we have a predefined set of possible endpoints, we can map those as available command types.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;command&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;mtConnectCommandType&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Command Type&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Types of MTConnect commands that we are able to use.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;enum&amp;#34;,
    &amp;#34;values&amp;#34;: [
      &amp;#34;Probe&amp;#34;,
      &amp;#34;Current&amp;#34;,
      &amp;#34;Sample&amp;#34;,
      &amp;#34;Assets&amp;#34;,
      &amp;#34;Asset&amp;#34;
    ],
    &amp;#34;defaultValue&amp;#34;: &amp;#34;Probe&amp;#34;
  }
],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The MTConnect.NET library that we are using &lt;strong&gt;on start&lt;/strong&gt;, already &lt;strong&gt;emits information about the probe data, current data and starts a sampling process&lt;/strong&gt;. We will see this in detail later, but for now we can create the metadata for our events.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;event&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;mtConnectEventType&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Event Type&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;Types of MTConnect events that we are able to use.&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;enum&amp;#34;,
    &amp;#34;values&amp;#34;: [
      &amp;#34;Probe&amp;#34;,
      &amp;#34;Current&amp;#34;,
      &amp;#34;Sample&amp;#34;,
      &amp;#34;Assets&amp;#34;
    ],
    &amp;#34;defaultValue&amp;#34;: &amp;#34;Probe&amp;#34;
  }
],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lastly, the MTConnect responses have two big fields, one which has &lt;strong&gt;Header information&lt;/strong&gt; and another that has &lt;strong&gt;Device information&lt;/strong&gt;, but they also have a lot of other fields inside the Device and the Header. So, let&amp;rsquo;s add a &lt;strong&gt;notion of expression&lt;/strong&gt; to our event properties. With expressions our users can create their own JSONata expression, to retrieve the information from the JSON, that they wish. In order to achieve this parsing, we will later on use &lt;strong&gt;JSONata&lt;/strong&gt;, for now, bear in mind this concept of expressions.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; The driver should try as much as possible to simplify it&amp;rsquo;s own usage and abstract complexity. This will make the workflows simpler and create cleaner implementations.&lt;/span&gt;
&lt;/div&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;#34;property&amp;#34;: [
  {
    &amp;#34;name&amp;#34;: &amp;#34;expression&amp;#34;,
    &amp;#34;label&amp;#34;: &amp;#34;Expression&amp;#34;,
    &amp;#34;description&amp;#34;: &amp;#34;JSONata Expression to extract property from the JSON structure&amp;#34;,
    &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;,
    &amp;#34;defaultValue&amp;#34;: &amp;#34;&amp;#34;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Package json"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/package.json.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Important:&lt;/strong&gt; The driver in order to use .Net, requires the binaries for EdgeJs and EdgeJsBootstrap. Replace the folder name with the your edge-js version and copy the binaries there. The process of generating the binaries is described &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/customization_generating_node_binaries/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;Right now &lt;strong&gt;we can already create a package&lt;/strong&gt; with this skeleton and uploaded it to the MES UI.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Deploy"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/driverdeploy.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the UI we are able to create an &lt;code&gt;Automation Protocol&lt;/code&gt; with all our communication settings.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Note that this is a development flow, for a productive flow, the cmf pack will create an installable package that will deploy the MTConnect driver..&lt;/span&gt;
&lt;/div&gt;
&lt;h3 class="relative group"&gt;Defining our Settings for the Driver
&lt;div id="defining-our-settings-for-the-driver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#defining-our-settings-for-the-driver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;package.json&lt;/strong&gt; will describe the visualization settings for the UI, we can now define them for our driver.&lt;/p&gt;
&lt;p&gt;In the &lt;strong&gt;communicationSettings.ts&lt;/strong&gt; we can now declare our settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;export interface MTConnectCommunicationSettings {
    // Add driver specific settings here
    /** Set the .net core SDK version to use, when multiple are installed in the system where the driver is running. Leave empty to ignore this setting. */
    netCoreSdkVersion: string;
// MTConnect Address
    address: string;
    // MTConnect Address Port
    port: number;
    // MTConnect Device
    device: string;
// Common/driver WS settings
    heartbeatInterval: number;
    setupTimeout: number;
    intervalBeforeReconnect: number;
    connectingTimeout: number;
};
/** Default Communication Settings */
export const mTConnectDefaultCommunicationSettings: MTConnectCommunicationSettings = {
    // Add driver specific default settings here
    /** Set the .net core SDK version to use, when multiple are installed in the system where the driver is running. Leave empty to ignore this setting. */
    netCoreSdkVersion: &amp;#34;&amp;#34;,
// Default MTConnect Address
    address: &amp;#34;localhost&amp;#34;,
    // Default MTConnect Address Port
    port: 5000,
    // Default MTConnect Device
    device: &amp;#34;&amp;#34;,
// Common/driver WS settings
    heartbeatInterval: 30000,
    setupTimeout: 10000,
    intervalBeforeReconnect: 5000,
    connectingTimeout: 30000,
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the &lt;code&gt;setCommunicationConfiguration&lt;/code&gt; is invoked in the lifecycle of the driver it will reconcile what was defined in the task &lt;code&gt;Equipment Configuration&lt;/code&gt;, the package.json and the communication settings.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/**
 * Notification regarding the communication parameters being available.
 * Validate the integrity of the values
 * Note: Called by the driverBase
 * @param communication Communication settings object
 */
public async setCommunicationConfiguration(communication: any): Promise&amp;lt;void&amp;gt; {
    this._communicationSettings = Object.assign({}, mTConnectDefaultCommunicationSettings, communication);
// eslint-disable-next-line
    const pJson = require(&amp;#34;../package.json&amp;#34;);
    validateCommunicationParameters(pJson, this._communicationSettings);
// Prepare the extended data
    validateProperties(pJson, this.configuration.properties);
    validateEvents(pJson, this.configuration.events);
    validateEventProperties(pJson, this.configuration.events);
    validateCommands(pJson, this.configuration.commands);
    validateCommandParameters(pJson, this.configuration.commands);
// Initialize assembly
    this._mTConnectHandler.setPackageJson(pJson);
    this._mTConnectHandler.setConfiguration(this.configuration, this._communicationSettings);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;MTConnect .Net Logic
&lt;div id="mtconnect-net-logic" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mtconnect-net-logic" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the driver dotnet project, we can now import our dependency to &lt;code&gt;MTConnect.NET-HTTP&lt;/code&gt;, to communicate with the MTConnect Agent and to &lt;code&gt;MTConnect.NET-XML&lt;/code&gt; to parse the messages.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Costura.Fody
&lt;div id="costurafody" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#costurafody" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the visual studio project our scaffolding creates there&amp;rsquo;s a file called &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-mtconnect/netCore/Cmf.Connect.IoT.Driver.MTConnect/FodyWeavers.xml"
target="_blank"
&gt;&lt;code&gt;FodyWeavers.xml&lt;/code&gt;&lt;/a&gt;, Costura.Fody allows us to merge all our dependencies into a single &lt;code&gt;.dll&lt;/code&gt; file. This is not mandatory, but it allows us to simplify and better manage our bundles. There are some downsides, particularly if the there&amp;rsquo;s some of these dlls that is trying to load by reflection, example &lt;a
href="https://github.com/jrk94/cm-demo-repos/blob/main/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-mtconnect/netCore/Cmf.Connect.IoT.Driver.MTConnect/Common/AssemblyHelper.cs"
target="_blank"
&gt;here&lt;/a&gt; was due to this issue.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;Weavers xmlns:xsi=&amp;#34;http://www.w3.org/2001/XMLSchema-instance&amp;#34; xsi:noNamespaceSchemaLocation=&amp;#34;FodyWeavers.xsd&amp;#34;&amp;gt;
  &amp;lt;Costura IncludeAssemblies=&amp;#34;Newtonsoft.Json | MTConnect.NET-HTTP | MTConnect.NET-XML | MTConnect.NET-Common&amp;#34; /&amp;gt;
&amp;lt;/Weavers&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Costura Fody file, to include our dependencies.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Communication Settings
&lt;div id="communication-settings" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#communication-settings" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the dotnet layer we will also need to have a match to the communication settings. The template generates the &lt;strong&gt;CommunicationSettings&lt;/strong&gt; class at &lt;code&gt;/DriverObjects/CommunicationSettings.cs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;We will add the settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;/// &amp;lt;summary&amp;gt;IP address of the MTConnect Agent&amp;lt;/summary&amp;gt;
public string Address { get; set; } = &amp;#34;&amp;#34;;
/// &amp;lt;summary&amp;gt;Port of the MTConnect Agent&amp;lt;/summary&amp;gt;
public int Port { get; set; } = 5000;
/// &amp;lt;summary&amp;gt;Device&amp;lt;/summary&amp;gt;
public string Device { get; set; } = null;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the loaders from the settings received from the nodejs layer:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;public void Load(IDictionary&amp;lt;string, object&amp;gt; settings)
{
    NetCoreSdkVersion = settings.Get(&amp;#34;netCoreSdkVersion&amp;#34;, NetCoreSdkVersion);
    Address = settings.Get(&amp;#34;address&amp;#34;, Address);
    Port = settings.Get(&amp;#34;port&amp;#34;, Port);
    Device = settings.Get(&amp;#34;device&amp;#34;, Device);
    ConnectingTimeout = settings.Get(&amp;#34;connectingTimeout&amp;#34;, ConnectingTimeout);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;MTConnectHandler .NET
&lt;div id="mtconnecthandler-net" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mtconnecthandler-net" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the &lt;code&gt;MTConnectHandler.cs&lt;/code&gt; is where we will have our main logic, interacting with all the hooks provided by the &lt;code&gt;mTConnectHandler.ts&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;There are 2 main entrypoints related with the driver lifecycle:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Connect&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disconnect&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are 3 entrypoints related with the driver events:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RegisterEvent&lt;/strong&gt; - Register event to register in the Server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UnregisterEvent&lt;/strong&gt; - Unregister event from the Server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RegisterEventHandler&lt;/strong&gt; - Register a handler to receive the event occurrence, mostly used for log, connect, disconnect&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are 3 additional entrypoints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ExecuteCommand&lt;/strong&gt; - Register event to register in the Server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GetValues&lt;/strong&gt; - Perform a get of property values&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SetValues&lt;/strong&gt; - Perform a set of property values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For this use case we will have events and commands and we will not need get property values or set property values.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Connect
&lt;div id="connect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Here is where we will handle all that is needed to connect to the MTConnect Agent.&lt;/p&gt;
&lt;p&gt;The first action is loading the settings and destroying any previous connections.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;public async Task&amp;lt;object&amp;gt; Connect(dynamic inputValues)
{
    IDictionary&amp;lt;string, object&amp;gt; input = (IDictionary&amp;lt;string, object&amp;gt;)inputValues;
Shared.Settings.Load(input);
    // Dump configuration for debug purposes
    Shared.Log.Debug(&amp;#34;Communication parameters: {0}&amp;#34;, Shared.Settings.Dump());
// Destroy all previous connections
    this.DestroyConnection();
#region Connect
var address = Shared.Settings.Address ?? &amp;#34;127.0.0.1&amp;#34;;
    var port = Shared.Settings.Port;
    var device = Shared.Settings.Device;
this.client = port != -1
        ? new MTConnectHttpClient(address, port, device, DocumentFormat.XML)
        : new MTConnectHttpClient(address, device, DocumentFormat.XML);
var isClientStarted = false;
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that here we are already instantiating the &lt;code&gt;MTConnectHttpClient&lt;/code&gt; with the provided settings.&lt;/p&gt;
&lt;p&gt;The second action is to create the &lt;strong&gt;handlers for all the events&lt;/strong&gt; being surfaced by the MTConnect library.&lt;/p&gt;
&lt;p&gt;We have &lt;strong&gt;lifecycle&lt;/strong&gt; events.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;#region lifecycle events
this.client.ClientStarted &amp;#43;= (s, response) =&amp;gt;
{
    isClientStarted = true;
    Shared.Log.Info(&amp;#34;MTConnect Client has Started&amp;#34;);
};
this.client.ClientStopped &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Warning(&amp;#34;Client has stopped&amp;#34;);
    this.Disconnect(null);
};
#endregion&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;strong&gt;error handling&lt;/strong&gt; events:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;#region error handling events
this.client.MTConnectError &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Warning($&amp;#34;MTConnect error {response.Errors.FirstOrDefault()}&amp;#34;);
};
this.client.ConnectionError &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Warning(&amp;#34;Client connection error&amp;#34;);
    this.Disconnect(null);
};
this.client.InternalError &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Warning($&amp;#34;Internal Error: {response.Message}&amp;#34;);
};
this.client.FormatError &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Warning($&amp;#34;Format Error: {response.Messages?.FirstOrDefault()}&amp;#34;);
};
#endregion&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we have events related with the start of the MTConnect client. First, we need to understand what is happening when we do a Start. In the MTConnect library that we are using when we do a start we will perform a set of commands, that will surface as events.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at the &lt;strong&gt;start cycle&lt;/strong&gt;:&lt;/p&gt;
&lt;div style="background-color:white; padding: 20px"&gt;
&lt;style&gt;
.mermaid-wrap { display: flex; justify-content: center; }
.mermaid-wrap .mermaid { width: 100%; max-width: 960px; cursor: zoom-in; }
.mermaid-wrap .mermaid svg { width: 100%; height: auto; }
.mermaid-zoom-overlay {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
cursor: grab;
opacity: 0; transition: opacity 0.3s ease;
overflow: hidden;
}
.mermaid-zoom-overlay.active { opacity: 1; }
.mermaid-zoom-overlay .mermaid-zoom-container {
transform-origin: 0 0;
}
.mermaid-zoom-overlay .mermaid-zoom-container svg {
max-width: 95vw; max-height: 95vh;
width: auto; height: auto;
background: var(--color-neutral-100);
border-radius: 8px; padding: 1rem;
}
.mermaid-zoom-overlay .mermaid-zoom-controls {
position: absolute; top: 12px; right: 12px;
display: flex; gap: 6px; z-index: 1000;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button {
width: 36px; height: 36px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.9); color: #333;
font-size: 20px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover {
background: #fff;
}
@media (prefers-color-scheme: dark) {
.mermaid-zoom-overlay .mermaid-zoom-container svg { background: var(--color-neutral-800); }
.mermaid-zoom-overlay .mermaid-zoom-controls button {
background: rgba(50,50,50,0.9); color: #ddd;
}
.mermaid-zoom-overlay .mermaid-zoom-controls button:hover { background: #444; }
}
&lt;/style&gt;
&lt;div class="mermaid-wrap"&gt;
&lt;pre class="not-prose mermaid" style="margin:0 auto;font-size:16px;"&gt;
flowchart TD
A[Start Worker] --&gt; B[Probe Request: GetProbeAsync]
B --&gt;|Probe == null| Z[Delay &amp; Retry]
B --&gt;|Probe != null| C[Process Probe Endpoint]
C --&gt; D{Assets Available?}
D -- Yes --&gt; E[Assets Request: GetAssetsAsync]
E --&gt; F[AssetsReceived Event]
D -- No --&gt; G[Continue]
F --&gt; G
G --&gt; H[Current Request: GetCurrentAsync]
H --&gt;|Current != null| I[Process Current Endpoint]
I --&gt; J[Start Sample Stream]
J --&gt; K[Process Sample Endpoint Continuously]
&lt;/pre&gt;
&lt;/div&gt;
&lt;script&gt;
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".mermaid-wrap .mermaid").forEach(function (el) {
if (el.dataset.zoomBound) return;
el.dataset.zoomBound = "true";
el.addEventListener("click", function () {
var svg = el.querySelector("svg");
if (!svg) return;
var overlay = document.createElement("div");
overlay.className = "mermaid-zoom-overlay";
var controls = document.createElement("div");
controls.className = "mermaid-zoom-controls";
controls.innerHTML =
'&lt;button data-action="in" title="Zoom in"&gt;+&lt;/button&gt;' +
'&lt;button data-action="out" title="Zoom out"&gt;\u2212&lt;/button&gt;' +
'&lt;button data-action="reset" title="Reset"&gt;&amp;#8634;&lt;/button&gt;' +
'&lt;button data-action="close" title="Close"&gt;\u2715&lt;/button&gt;';
overlay.appendChild(controls);
var container = document.createElement("div");
container.className = "mermaid-zoom-container";
container.innerHTML = svg.outerHTML;
var clonedSvg = container.querySelector("svg");
if (clonedSvg) {
var vb = clonedSvg.getAttribute("viewBox");
if (vb) {
var parts = vb.split(/[\s,]+/);
var vbW = parseFloat(parts[2]);
var vbH = parseFloat(parts[3]);
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = vbW + "px";
clonedSvg.style.height = vbH + "px";
} else {
var rect = svg.getBoundingClientRect();
clonedSvg.removeAttribute("width");
clonedSvg.removeAttribute("height");
clonedSvg.style.width = rect.width + "px";
clonedSvg.style.height = rect.height + "px";
}
clonedSvg.style.maxWidth = "95vw";
clonedSvg.style.maxHeight = "95vh";
}
overlay.appendChild(container);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
requestAnimationFrame(function () { overlay.classList.add("active"); });
var scale = 1, panX = 0, panY = 0;
var isDragging = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
var lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
function applyTransform() {
container.style.transform =
"translate(" + panX + "px, " + panY + "px) scale(" + scale + ")";
}
function closeOverlay() {
overlay.classList.remove("active");
document.body.style.overflow = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKey);
setTimeout(function () { overlay.remove(); }, 300);
}
overlay.addEventListener("wheel", function (e) {
e.preventDefault();
e.stopPropagation();
var delta = e.deltaY &gt; 0 ? 0.9 : 1.1;
var newScale = Math.min(Math.max(scale * delta, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
}, { passive: false });
overlay.addEventListener("touchstart", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
} else if (e.touches.length === 1) {
isDragging = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
startPanX = panX; startPanY = panY;
}
}, { passive: false });
overlay.addEventListener("touchmove", function (e) {
e.preventDefault();
if (e.touches.length === 2) {
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
var midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var factor = dist / lastTouchDist;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
var rect = overlay.getBoundingClientRect();
var cx = midX - rect.left, cy = midY - rect.top;
panX = cx - (cx - panX) * (newScale / scale) + (midX - lastTouchMidX);
panY = cy - (cy - panY) * (newScale / scale) + (midY - lastTouchMidY);
scale = newScale;
lastTouchDist = dist;
lastTouchMidX = midX; lastTouchMidY = midY;
applyTransform();
} else if (e.touches.length === 1 &amp;&amp; isDragging) {
panX = startPanX + (e.touches[0].clientX - startX);
panY = startPanY + (e.touches[0].clientY - startY);
applyTransform();
}
}, { passive: false });
overlay.addEventListener("touchend", function (e) {
isDragging = false;
lastTouchDist = 0;
});
overlay.addEventListener("mousedown", function (e) {
if (e.target.closest(".mermaid-zoom-controls")) return;
isDragging = true;
startX = e.clientX; startY = e.clientY;
startPanX = panX; startPanY = panY;
overlay.style.cursor = "grabbing";
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
panX = startPanX + (e.clientX - startX);
panY = startPanY + (e.clientY - startY);
applyTransform();
}
function onMouseUp() {
if (!isDragging) return;
isDragging = false;
overlay.style.cursor = "grab";
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
controls.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (!btn) return;
e.stopPropagation();
var action = btn.dataset.action;
if (action === "close") { closeOverlay(); return; }
if (action === "reset") { scale = 1; panX = 0; panY = 0; applyTransform(); return; }
var factor = action === "in" ? 1.3 : 1 / 1.3;
var rect = overlay.getBoundingClientRect();
var cx = rect.width / 2, cy = rect.height / 2;
var newScale = Math.min(Math.max(scale * factor, 0.2), 10);
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
});
function onKey(e) {
if (e.key === "Escape") closeOverlay();
}
window.addEventListener("keydown", onKey);
overlay.addEventListener("dblclick", function (e) {
if (!e.target.closest(".mermaid-zoom-controls")) closeOverlay();
});
});
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;Now let&amp;rsquo;s look at the &lt;strong&gt;event handlers&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;#region events
this.client.ProbeReceived &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Debug($&amp;#34;Probe Received Event {response.Header.InstanceId}&amp;#34;);
    Shared.EventDispatcher.TriggerEvent(new EventOccurrence(EventType.Probe, response.Header, response.Devices));
};
this.client.CurrentReceived &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Debug($&amp;#34;Current Received Event {response.Header.InstanceId}&amp;#34;);
    Shared.EventDispatcher.TriggerEvent(new EventOccurrence(EventType.Current, response.Header, response.Streams, response.GetObservations()));
};
this.client.SampleReceived &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Debug($&amp;#34;Sample Received Event {response.Header.InstanceId}&amp;#34;);
    Shared.EventDispatcher.TriggerEvent(new EventOccurrence(EventType.Sample, response.Header, response.Streams, response.GetObservations()));
};
this.client.AssetsReceived &amp;#43;= (s, response) =&amp;gt;
{
    Shared.Log.Debug($&amp;#34;Assets Received Event {response.Header.InstanceId}&amp;#34;);
    Shared.EventDispatcher.TriggerEvent(new EventOccurrence(EventType.Assets, response.Header, response.Assets));
};
#endregion events&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can see a match between the event handlers and the requests done on the start cycle. The &lt;strong&gt;Sample Received&lt;/strong&gt;, will be the only one being &lt;strong&gt;continually streamed&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;EventOccurrence&lt;/strong&gt; class will be responsible for transforming the driver information and types into the structure the nodejs driver is able to parse and understand. Note that this is the first translation between the machine and Connect IoT, so we need to create a structure that avoids data loss, but has all the helpful information and format for our Connect IoT Equipment Events. Sometimes this is not a simple decision as we may not know what our user will require.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;public EventOccurrence(EventType eventId, dynamic header, dynamic body, dynamic observations = null)
{
    this.EventId = eventId;
    this.Header = header;
    this.Devices = body;
    this.Observations = observations;
    this.OccurrenceTimeStamp = DateTime.UtcNow;
}
public dynamic ToJson()
{
    Newtonsoft.Json.Formatting format = Newtonsoft.Json.Formatting.None;
    var settings = new Newtonsoft.Json.JsonSerializerSettings();
    settings.TypeNameHandling = Newtonsoft.Json.TypeNameHandling.None;
    settings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
return (new
    {
        messageId = this.MessageId,
        eventId = this.EventId.ToString(),
        values = new
        {
            values = new
            {
                header = Newtonsoft.Json.JsonConvert.SerializeObject(this.Header, format, settings),
                devices = Newtonsoft.Json.JsonConvert.SerializeObject(this.Devices, format, settings),
                observations = Newtonsoft.Json.JsonConvert.SerializeObject(this.Observations, format, settings),
            }
        },
        occurrenceTimeStamp = this.OccurrenceTimeStamp,
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lastly, we have the &lt;strong&gt;start&lt;/strong&gt; of the &lt;strong&gt;MTConnect Client&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;Shared.Log.Debug(&amp;#34;Starting MTConnect Client&amp;#34;);
try
{
    this.client.Start();
    Utilities.WaitFor(Shared.Settings.ConnectingTimeout, &amp;#34;MTConnect Client didn&amp;#39;t start&amp;#34;, () =&amp;gt; isClientStarted);
}
catch (Exception ex)
{
    this.Disconnect(null);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the MTConnect driver only these boot up events and the sampling event, will be handled as events, everything else will be commands.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Disconnect
&lt;div id="disconnect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#disconnect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The disconnect will make sure to clean all the elements of the driver that were instantiated in th start cycle.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;/// &amp;lt;summary&amp;gt;Disconnect from the equipment&amp;lt;/summary&amp;gt;
/// &amp;lt;param name=&amp;#34;inputValues&amp;#34;&amp;gt;Not used&amp;lt;/param&amp;gt;
/// &amp;lt;returns&amp;gt;Boolean indicating result&amp;lt;/returns&amp;gt;
public async Task&amp;lt;object&amp;gt; Disconnect(dynamic inputValues)
{
    IDictionary&amp;lt;string, object&amp;gt; input = (IDictionary&amp;lt;string, object&amp;gt;)inputValues;
this.DestroyConnection();
m_DisconnectedHandler?.Invoke(new { });
    return true;
}
private void DestroyConnection()
{
    if (this.client != null)
    {
        Shared.Log.Info(&amp;#34;Destroying (possible) previously connection and unsubscribing all events...&amp;#34;);
try
        {
            Shared.EventDispatcher.DestroyEventHandlers();
            this.client.Stop();
        }
        catch (Exception e)
        {
            Shared.Log.Error($&amp;#34;Exception while disconnecting previous MTConnect client: {e.Message}&amp;#34;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each driver may have different connect and disconnect routines. For MTConnect, we will destroy all the event listeners and stop the client.&lt;/p&gt;
&lt;h3 class="relative group"&gt;ExecuteCommand
&lt;div id="executecommand" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#executecommand" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Commands&lt;/strong&gt; are the fundamental piece of MTConnect. They will be how the user through Connect IoT is able to perform HTTP Requests to the Agent.&lt;/p&gt;
&lt;p&gt;We will support, the following commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Probe&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assets&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Asset&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Current&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;public async Task&amp;lt;object&amp;gt; ExecuteCommand(dynamic inputValues)
{
  IDictionary&amp;lt;string, object&amp;gt; input = (IDictionary&amp;lt;string, object&amp;gt;)inputValues;
Newtonsoft.Json.Formatting format = Newtonsoft.Json.Formatting.None;
  var settings = new Newtonsoft.Json.JsonSerializerSettings
  {
      TypeNameHandling = Newtonsoft.Json.TypeNameHandling.None,
      ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore
  };
return Newtonsoft.Json.JsonConvert.SerializeObject(mtConnectCommand(input), format, settings);
object mtConnectCommand(IDictionary&amp;lt;string, object&amp;gt; input)
  {
      CommandReceived commandReceived = new(input);
switch (commandReceived.CommandType)
      {
          case CommandType.Probe:
              {
                  if (commandReceived.Device != null &amp;amp;&amp;amp; Shared.Settings.Device != commandReceived.Device)
                  {
                      var probeClient = new MTConnectHttpProbeClient(this.client.Authority, commandReceived.Device)
                      {
                          Timeout = this.client.Timeout,
                          ContentEncodings = this.client.ContentEncodings,
                          ContentType = this.client.ContentType
                      };
                      return probeClient.GetAsync(CancellationToken.None).Result;
                  }
                  return this.client.GetProbeAsync().Result;
              }
          case CommandType.Assets:
              {
                  return this.client.GetAssetsAsync().Result;
              }
          case CommandType.Asset:
              {
                  return this.client.GetAssetAsync(commandReceived.Asset).Result;
              }
          case CommandType.Current:
              {
                  if (commandReceived.Device != null &amp;amp;&amp;amp; Shared.Settings.Device != commandReceived.Device)
                  {
                      var currentClient = new MTConnectHttpCurrentClient(this.client.Authority, commandReceived.Device, path: commandReceived.Path, at: commandReceived.At)
                      {
                          Timeout = this.client.Timeout,
                          ContentEncodings = this.client.ContentEncodings,
                          ContentType = this.client.ContentType
                      };
                      return currentClient.GetAsync(CancellationToken.None).Result;
                  }
                  return this.client.GetCurrentAsync(commandReceived.At, commandReceived.Path).Result;
              }
            case CommandType.Sample:
                {
                    if (commandReceived.Device != null &amp;amp;&amp;amp; Shared.Settings.Device != commandReceived.Device)
                    {
                        var sampleClient = new MTConnectHttpSampleClient(this.client.Authority, commandReceived.Device, path: commandReceived.Path, from: commandReceived.From, to: commandReceived.To, count: commandReceived.Count)
                        {
                            Timeout = this.client.Timeout,
                            ContentEncodings = this.client.ContentEncodings,
                            ContentType = this.client.ContentType
                        };
                        return sampleClient.GetAsync(CancellationToken.None).Result;
                    }
                    return this.client.GetSampleAsync(from: commandReceived.From, to: commandReceived.To, count: commandReceived.Count, path: commandReceived.Path).Result;
                }
          default: throw new Exception(&amp;#34;Unknown Command Type&amp;#34;);
      }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that our code is split into three parts, serializing our inputs, executing a command and serializing the command output.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;CommandReceived&lt;/strong&gt; class was a class created to parse the command into a known structure. The output of a command in Connect IoT is not really contract bound, so we don&amp;rsquo;t need to translate into a common structure.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Driver Implementation
&lt;div id="driver-implementation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#driver-implementation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The driver implementation is a key part of our driver nodejs layer. In the driver implementation we will focus on some key methods.&lt;/p&gt;
&lt;h3 class="relative group"&gt;initializeDriver
&lt;div id="initializedriver" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#initializedriver" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;One of the features that we will implement, due to the complex structures the MTConnect is able to provide, is a way to be able create and define properties that are subsets of this data.&lt;/p&gt;
&lt;p&gt;In order to do that we will use &lt;a
href="https://jsonata.org/"
target="_blank"
&gt;JSONata&lt;/a&gt; expressions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;JSONata&lt;/strong&gt; is a lightweight query and transformation language designed specifically for JSON data. It allows you to filter, map, and reshape JSON with a simple yet expressive syntax, similar to how XPath works for XML. With JSONata, you can extract values, perform calculations, apply conditional logic, and restructure data without writing verbose code. Because it is declarative, JSONata expressions are concise, readable, and reusable, making them a powerful tool to handle complex JSON transformations efficiently.&lt;/p&gt;
&lt;p&gt;In our initialize method we will import our jsonata dependency.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async initializeDriver(): Promise&amp;lt;void&amp;gt; {
  // JSONata is an ESM package so we need to loaded it
  this._jsonata = await import(&amp;#34;jsonata&amp;#34;);
  (...)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;onEventOccurrence
&lt;div id="oneventoccurrence" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#oneventoccurrence" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the onEventOccurrence method we will &lt;strong&gt;handle our event and emit it to the controller&lt;/strong&gt; if there are any matching event registrations. The controller will then provide the event to all the subscribed tasks.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;private async onEventOccurrence(
    eventId: string,
    eventOccurrence: {
        values: { values: object };
        occurrenceTimeStamp: Date
    }): Promise&amp;lt;void&amp;gt; {
let evtOccurValue = new Map&amp;lt;string, any&amp;gt;(Object.entries(eventOccurrence.values.values));
const registeredEvents = Array.from(this._customEvents.values()).filter((evt: EquipmentEvent) =&amp;gt; evt.deviceId === eventId &amp;amp;&amp;amp; evt.isEnabled === true);
    if (registeredEvents.length &amp;gt; 0) {
        if (evtOccurValue) {
            for (const evt of registeredEvents) {
                this._extensionHandler.handleEventOccurrence(
                    evt.name,
                    eventOccurrence.occurrenceTimeStamp,
                    await this.parseEventProperties(evt, evtOccurValue));
            }
        }
    }
const event = this.configuration.events.find(e =&amp;gt; e.systemId === eventId);
    if (event &amp;amp;&amp;amp; event.isEnabled) {
        const results: PropertyValue[] = [];
        evtOccurValue = await this.parseEventProperties(event, evtOccurValue);
// Fill results and check if the trigger properties have been the cause of the event occurrence
        if (evtOccurValue) {
            for (const eventProperty of event.properties) {
                if (evtOccurValue.has(eventProperty.deviceId)) {
                    const value: any = evtOccurValue.get(eventProperty.deviceId);
const propertyValue: PropertyValue = {
                        propertyName: eventProperty.name,
                        originalValue: value,
                        value: this.convertValueFromDevice(value, eventProperty.deviceType, eventProperty.dataType),
                    };
results.push(propertyValue);
                } else {
                    throw new Error(`Value for property &amp;#39;${eventProperty.name}&amp;#39; was not received in the event data`);
                }
            }
        }
// Raise event to controller
        const occurrence: EventOccurrence = {
            timestamp: new Date(),
            eventDeviceId: event.deviceId,
            eventName: event.name,
            eventSystemId: event.systemId,
            propertyValues: results
        };
this.emit(&amp;#34;eventOccurrence&amp;#34;, occurrence);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are two major branches in the code, the first handles &lt;strong&gt;template events&lt;/strong&gt; and the second handles &lt;strong&gt;driver definition events&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Template Events&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Are events that are defined by the driver. In other words they are events that the user does not need to define but the driver itself will come with them defined out of the box.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;extensions layer&lt;/strong&gt; will hold all that is required for the driver to be able to have &lt;strong&gt;template events&lt;/strong&gt;. In the driver implementation we must route the events to the templates, the driver definition or both.&lt;/p&gt;
&lt;p&gt;The templates folder will hold all the template commands, events and properties.&lt;/p&gt;
&lt;p&gt;In this driver example, we will retrieve the values of the event occurrence, check if there is any template event and call the extension handler to emit the event occurrence:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;let evtOccurValue = new Map&amp;lt;string, any&amp;gt;(Object.entries(eventOccurrence.values.values));
const registeredEvents = Array.from(this._customEvents.values()).filter((evt: EquipmentEvent) =&amp;gt; evt.deviceId === eventId &amp;amp;&amp;amp; evt.isEnabled === true);
if (registeredEvents.length &amp;gt; 0) {
    if (evtOccurValue) {
        for (const evt of registeredEvents) {
            this._extensionHandler.handleEventOccurrence(
                evt.name,
                eventOccurrence.occurrenceTimeStamp,
                await this.parseEventProperties(evt, evtOccurValue));
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Driver Definition&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Are events that are defined by the user. These are events that are not provided by the driver itself but are declared and constructed by the user using the MES entity &lt;code&gt;Automation Driver Definition&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In this driver example, we will retrieve the values of the event occurrence, check if there is any driver definition event and emit the event occurrence:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;const event = this.configuration.events.find(e =&amp;gt; e.systemId === eventId);
if (event &amp;amp;&amp;amp; event.isEnabled) {
    const results: PropertyValue[] = [];
    evtOccurValue = await this.parseEventProperties(event, evtOccurValue);
// Fill results and check if the trigger properties have been the cause of the event occurrence
    if (evtOccurValue) {
        for (const eventProperty of event.properties) {
            if (evtOccurValue.has(eventProperty.deviceId)) {
                const value: any = evtOccurValue.get(eventProperty.deviceId);
const propertyValue: PropertyValue = {
                    propertyName: eventProperty.name,
                    originalValue: value,
                    value: this.convertValueFromDevice(value, eventProperty.deviceType, eventProperty.dataType),
                };
results.push(propertyValue);
            } else {
                throw new Error(`Value for property &amp;#39;${eventProperty.name}&amp;#39; was not received in the event data`);
            }
        }
    }
// Raise event to controller
    const occurrence: EventOccurrence = {
        timestamp: new Date(),
        eventDeviceId: event.deviceId,
        eventName: event.name,
        eventSystemId: event.systemId,
        propertyValues: results
    };
this.emit(&amp;#34;eventOccurrence&amp;#34;, occurrence);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that both code paths call a method &lt;code&gt;parseEventProperties&lt;/code&gt;. This method is responsible for calculating the &lt;strong&gt;JSONata&lt;/strong&gt; expression from each property and extracting the value.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;/**
 * Using jsonata expression construct the event occurrence 
 * @param evt Event definition
 * @param evtOccurValue Event Occurrence    
 * @param values 
 * @returns 
 */
private async parseEventProperties(evt: EquipmentEvent, evtOccurValue: Map&amp;lt;string, any&amp;gt;): Promise&amp;lt;Map&amp;lt;string, any&amp;gt;&amp;gt; {
  const values = new Map&amp;lt;string, any&amp;gt;();
  for (const prop of evt.properties) {
      if (prop.extendedData.expression != null &amp;amp;&amp;amp; prop.extendedData.expression != &amp;#34;&amp;#34;) {
function mapToParsedObject(map: Map&amp;lt;string, string&amp;gt;): Record&amp;lt;string, any&amp;gt; {
              return Object.fromEntries(
                  Array.from(map.entries()).map(([key, value]) =&amp;gt; {
                      try {
                          return [key, JSON.parse(value)];
                      } catch {
                          return [key, value]; // fallback if it&amp;#39;s not valid JSON
                      }
                  })
              );
          }
await (this._jsonata(prop.extendedData.expression) as Expression).evaluate(mapToParsedObject(evtOccurValue), undefined, (error, result) =&amp;gt; {
              if (error != null) {
                  this.logger.error(`JSONata evaluation failed for property &amp;#39;${prop.name}&amp;#39; on event &amp;#39;${evt.name}&amp;#39;: ${error.message}`);
              } else {
                  values.set(prop.deviceId, result);
              }
          });
      }
  }
  return values;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Templates
&lt;div id="templates" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#templates" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The final part of our driver is to create template events, so our &lt;strong&gt;users start already with a set of events and commands&lt;/strong&gt; they can use.&lt;/p&gt;
&lt;p&gt;First, let&amp;rsquo;s create our &lt;strong&gt;properties&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;property&amp;#34;: [
    {
      &amp;#34;Name&amp;#34;: &amp;#34;header&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Header Data&amp;#34;,
      &amp;#34;DevicePropertyId&amp;#34;: &amp;#34;header&amp;#34;,
      &amp;#34;DataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;IsWritable&amp;#34;: true,
      &amp;#34;IsReadable&amp;#34;: true,
      &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;expression&amp;#34;: &amp;#34;header&amp;#34;
      }
    },
    {
      &amp;#34;Name&amp;#34;: &amp;#34;devices&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Devices Data&amp;#34;,
      &amp;#34;DevicePropertyId&amp;#34;: &amp;#34;devices&amp;#34;,
      &amp;#34;DataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;IsWritable&amp;#34;: true,
      &amp;#34;IsReadable&amp;#34;: true,
      &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;expression&amp;#34;: &amp;#34;devices&amp;#34;
      }
    },
    {
      &amp;#34;Name&amp;#34;: &amp;#34;assets&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Assets Data&amp;#34;,
      &amp;#34;DevicePropertyId&amp;#34;: &amp;#34;devices&amp;#34;,
      &amp;#34;DataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;IsWritable&amp;#34;: true,
      &amp;#34;IsReadable&amp;#34;: true,
      &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;expression&amp;#34;: &amp;#34;assets&amp;#34;
      }
    },
    {
      &amp;#34;Name&amp;#34;: &amp;#34;observations&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Observations Data&amp;#34;,
      &amp;#34;DevicePropertyId&amp;#34;: &amp;#34;observations&amp;#34;,
      &amp;#34;DataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;IsWritable&amp;#34;: true,
      &amp;#34;IsReadable&amp;#34;: true,
      &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;expression&amp;#34;: &amp;#34;observations&amp;#34;
      }
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;strong&gt;property&lt;/strong&gt; will have particular &lt;strong&gt;names&lt;/strong&gt; and &lt;strong&gt;datatypes&lt;/strong&gt;. One characteristic that we added in this driver is the &lt;strong&gt;extended data for the expression&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;We can now also add our events, let&amp;rsquo;s provide one as an example:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;event&amp;#34;: [
    {
      &amp;#34;Name&amp;#34;: &amp;#34;SampleReceived&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;SampleReceived will start producing sample results&amp;#34;,
      &amp;#34;DeviceEventId&amp;#34;: &amp;#34;Sample&amp;#34;,
      &amp;#34;IsEnabled&amp;#34;: true,
      &amp;#34;ExtendedData&amp;#34;: {},
      &amp;#34;EventProperties&amp;#34;: [
        {
          &amp;#34;Property&amp;#34;: &amp;#34;header&amp;#34;,
          &amp;#34;Order&amp;#34;: 1,
          &amp;#34;ExtendedData&amp;#34;: {}
        },
        {
          &amp;#34;Property&amp;#34;: &amp;#34;devices&amp;#34;,
          &amp;#34;Order&amp;#34;: 2,
          &amp;#34;ExtendedData&amp;#34;: {}
        },
        {
          &amp;#34;Property&amp;#34;: &amp;#34;observations&amp;#34;,
          &amp;#34;Order&amp;#34;: 3,
          &amp;#34;ExtendedData&amp;#34;: {}
        }
      ]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we can also provide the &lt;strong&gt;commands&lt;/strong&gt;. Let&amp;rsquo;s see a probe command as an example.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;command&amp;#34;: [
    {
      &amp;#34;Name&amp;#34;: &amp;#34;Probe Command&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Probe Command&amp;#34;,
      &amp;#34;DeviceCommandId&amp;#34;: &amp;#34;ProbeCommand&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;commandType&amp;#34;: &amp;#34;Probe&amp;#34;
      },
      &amp;#34;CommandParameters&amp;#34;: [
        {
          &amp;#34;Name&amp;#34;: &amp;#34;device&amp;#34;,
          &amp;#34;DataType&amp;#34;: &amp;#34;String&amp;#34;,
          &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;String&amp;#34;
        }
      ]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that for commands, the command type is very important as it will be what is matched in the &lt;code&gt;MTConnectHandler.cs&lt;/code&gt; class in the &lt;strong&gt;ExecuteCommand&lt;/strong&gt; method.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Running It
&lt;div id="running-it" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-it" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Now that we finished our MTConnect driver we can see it working.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Download and Run
&lt;div id="download-and-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#download-and-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;First, let&amp;rsquo;s generate a new package driver and create a driver definition and an automation controller.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Driver Deploy Controller"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/driverdeploycontroller.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Here we can see how we are able to override packages of the same version, creating a very fast development lifecycle. Also, another important detail is that without providing any driver definition I am able to drag and drop an &lt;code&gt;On Equipment Event&lt;/code&gt; task and have right away events available. These events come from the &lt;strong&gt;templates defined in the driver&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Now let&amp;rsquo;s connect the controller to an Automation Manager.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connect Controller"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/connectcontroller.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can &lt;strong&gt;download and start the manager&lt;/strong&gt;. Notice that the manager in the first run will download the packages from the repository and store them in the local cache.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Download and Start Manager"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/downloadmanager.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Debugging
&lt;div id="debugging" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debugging" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We have now done a start with the automation manager and downloaded the packages. In order to debug we can find more information &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_driver_dotnet/#debugging"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;first step is creating a link&lt;/strong&gt; between the manager cache and your local package.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;mklink /j connect-iot-driver-mtconnect@0.0.0 C:/cmf/cm-demo-repos/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-mtconnect&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now all the typings will be available for the runtime. We can &lt;strong&gt;launch the manager through the driver vscode&lt;/strong&gt;, simply by providing the manager location. This way we are able to debug the nodeJs part of the driver.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Manager Debug"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/startmanagerdebug.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In drivers that have a .Net component we need to try a different approach.&lt;/p&gt;
&lt;p&gt;We can &lt;strong&gt;start all the components manually&lt;/strong&gt; and then &lt;strong&gt;start the driver through the visual studio IDE&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In the src folder of the manager we can start the monitor.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;node monitor.js --dev --config=&amp;#39;../config.downloaded.json&amp;#39; --mp=88&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the cache controller folder (i.e C:\Users\jroque\Downloads\DemoMTConnectManager\ConnectIoT\Cache\connect-iot-controller@11.1.7-dev) we can start the controller. The driver instance is highlighted by the monitor application.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;node ./src/index.js --dev --id=AutomationControllerInstance/2508141611180010001 --monitorPort=88  --config=C:/Users/jroque/Downloads/DemoMTConnectManager/config.downloaded.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note, that if you have access to the &lt;code&gt;cmf dev&lt;/code&gt; command you can do both the link and the start using the &lt;code&gt;cmf dev iot startComponents&lt;/code&gt; command.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;cmf dev iot startComponents C:/Users/jroque/Downloads/DemoMTConnectManager --linkDir C:/cmf/cm-demo-repos/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Components"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/startComponents.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can stop the terminal for the driver and launch it in the visual studio code to have debug. Then in the visual studio code we can launch the nodeJs part of the driver by &lt;strong&gt;launching the &amp;lsquo;Start Driver&amp;rsquo;&lt;/strong&gt;. We must provide the manager location, monitor port and the driver instance.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Driver VS Code"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/startDriverVSCode.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can now see how we can &lt;strong&gt;start the debugger for the .Net part of our driver&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;We can create a new launch configuration where we will have&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Executable&lt;/strong&gt; - node location (i.e. C:\Program Files\nodejs\node.exe)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Command Line Arguments&lt;/strong&gt; - As provided from the monitor (i.e. ./src/index.js &amp;ndash;dev &amp;ndash;id=AutomationDriverInstance/2508141611180010001 &amp;ndash;monitorPort=84 &amp;ndash;config=C:/Users/jroque/Downloads/DemoMTConnectManager/config.downloaded.json)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Working Directory&lt;/strong&gt; - Location of the nodeJs driver (i.e. C:/cmf/cm-demo-repos/MTConnect/Cmf.Custom.IoT/Cmf.Custom.IoT.Packages/src/driver-mtconnect)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Driver VStudio"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/startDriverVisualStudio.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Connecting to an MTConnect Agent
&lt;div id="connecting-to-an-mtconnect-agent" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#connecting-to-an-mtconnect-agent" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;First, let&amp;rsquo;s start the &lt;strong&gt;test agent&lt;/strong&gt; provided by TrackHound &lt;a
href="https://github.com/TrakHound/MTConnect.NET/tree/master/agent/MTConnect.NET-Agent"
target="_blank"
&gt;agent&lt;/a&gt; and see if we are able to see data.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Agent Simulator"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/agentsimulator.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now that we have an agent running let&amp;rsquo;s start our driver.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s drag an &lt;code&gt;On Equipment Event&lt;/code&gt; task in our Automation Controller for an event &lt;code&gt;Probe&lt;/code&gt; and log when we receive it.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Probe Event"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/probeEvent.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s try and add now a command for the current endpoint.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Current Command"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/currentCommand.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can see that we are receiving big payloads.&lt;/p&gt;
&lt;p&gt;This of course always depends on the data and device that you are querying. Let&amp;rsquo;s imagine that in our probe event we are only &lt;strong&gt;interested in the components names and types&lt;/strong&gt;, we can add to our probe event a new property with a &lt;strong&gt;JSONata&lt;/strong&gt; expression to retrieve this information.&lt;/p&gt;
&lt;p&gt;We can add the task &lt;code&gt;Custom Templates&lt;/code&gt; and add a new property to our event &lt;code&gt;ProbeReceived&lt;/code&gt;, the property &lt;code&gt;components&lt;/code&gt;. This property will be a JSONata transformation on the event result. We will apply the &lt;strong&gt;JSONata&lt;/strong&gt; expression &lt;code&gt;devices.Components.{\&amp;quot;name\&amp;quot;: Name, \&amp;quot;type\&amp;quot;: Type}&lt;/code&gt;, this will retrieve all the component names and types of the probe command.&lt;/p&gt;
&lt;p&gt;Note that this is an example, in your particular use case you may want to retrieve other types of information, that is the beauty of using the JSONata, the user is free to create and collect whichever information he wishes from the event.&lt;/p&gt;
&lt;p&gt;We updated an existing event, but we could create a new event with the same DeviceEventId, either in the task Custom Templates or by using the Automation Driver Definition.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;property&amp;#34;: [
    {
      &amp;#34;Name&amp;#34;: &amp;#34;components&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;Components Data&amp;#34;,
      &amp;#34;DevicePropertyId&amp;#34;: &amp;#34;components&amp;#34;,
      &amp;#34;DataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;IsWritable&amp;#34;: true,
      &amp;#34;IsReadable&amp;#34;: true,
      &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Object&amp;#34;,
      &amp;#34;ExtendedData&amp;#34;: {
        &amp;#34;expression&amp;#34;: &amp;#34;devices.Components.{\&amp;#34;name\&amp;#34;: Name, \&amp;#34;type\&amp;#34;: Type}&amp;#34;
      },
      &amp;#34;isCustom&amp;#34;: true
    }
  ],
  &amp;#34;event&amp;#34;: [
    {
      &amp;#34;Name&amp;#34;: &amp;#34;ProbeReceived&amp;#34;,
      &amp;#34;Description&amp;#34;: &amp;#34;ProbeReceived should receive one after start&amp;#34;,
      &amp;#34;DeviceEventId&amp;#34;: &amp;#34;Probe&amp;#34;,
      &amp;#34;IsEnabled&amp;#34;: true,
      &amp;#34;ExtendedData&amp;#34;: {},
      &amp;#34;EventProperties&amp;#34;: [
        {
          &amp;#34;Property&amp;#34;: &amp;#34;header&amp;#34;,
          &amp;#34;Order&amp;#34;: 1,
          &amp;#34;ExtendedData&amp;#34;: {}
        },
        {
          &amp;#34;Property&amp;#34;: &amp;#34;devices&amp;#34;,
          &amp;#34;Order&amp;#34;: 2,
          &amp;#34;ExtendedData&amp;#34;: {}
        },
        {
          &amp;#34;Property&amp;#34;: &amp;#34;components&amp;#34;,
          &amp;#34;Order&amp;#34;: 3,
          &amp;#34;ExtendedData&amp;#34;: {}
        }
      ],
      &amp;#34;isCustom&amp;#34;: true
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now restart and see our components property populated with names and types.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="JSONata Event"
src="https://image.j-roque.com/posts/20250813-iot-mtconnectdriver-i/jsonataevent.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we have created a platform for the user to be able to fully integrate with an MTConnect Agent.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This was a use case of building a Connect IoT driver from the ground up, hopefully this helps you build your own solutions.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250813-iot-mtconnectdriver-i/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part IV - Extendable Controllers</title>
<link>https://j-roque.com/posts/20250730-iot-extensibility-iv/</link>
<pubDate>Wed, 30 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250730-iot-extensibility-iv/</guid>
<description>Extendable Controllers - a path to reusability and extendability</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250730-iot-extensibility-iv/featured.png" alt="Part IV - Extendable Controllers" /&gt;&lt;/p&gt;&lt;p&gt;An integrated solution for extensibility.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://j-roque.com/posts/20250725-iot-extensibility-i/"
target="_blank"
&gt;Part I&lt;/a&gt;&lt;br&gt;
&lt;a
href="https://j-roque.com/posts/20250728-iot-extensibility-ii/"
target="_blank"
&gt;Part II&lt;/a&gt;&lt;br&gt;
&lt;a
href="https://j-roque.com/posts/20250729-iot-extensibility-iii/"
target="_blank"
&gt;Part III&lt;/a&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Part IV&lt;/strong&gt;, of the &lt;strong&gt;IoT Extensibility series&lt;/strong&gt;, is dedicated to controller extensibility. Controller extensibility is a brand new feature of &lt;strong&gt;MES version 11.2&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;As projects grow ever larger and more complex, particularly when we see projects across segments or with multi site strategies there are two mega trends.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Templatization
&lt;div id="templatization" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#templatization" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;first&lt;/strong&gt; is a trend for &lt;strong&gt;templatization&lt;/strong&gt; of a segment feature set.&lt;/p&gt;
&lt;p&gt;In a scenario where shopfloors across multiple sites always have injection molding machines with similar processes. We can create a &lt;strong&gt;template with a feature set&lt;/strong&gt; for injection molding areas. It can have features like custom user interfaces, custom logic and connect iot integrations. With templatization the user can structure a modular approach, where then each site can opt in and use that module or not. They can decide to include the template in whatever phase of their product they want.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Templatization"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/templatization.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In this example, we can see that we have a pool of feature packages. The feature packages in this case are oriented to a particular process.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine that &lt;strong&gt;Site A&lt;/strong&gt; has an &lt;strong&gt;Injection Molding Area&lt;/strong&gt; that will be addressed in the &lt;strong&gt;first phase&lt;/strong&gt; of the project, the Site A will pull the feature package for Injection Molding Machines and have those features available.&lt;/p&gt;
&lt;p&gt;If we look at the &lt;strong&gt;SMT feature package&lt;/strong&gt;, we can see that it is actually being used for &lt;strong&gt;Site A&lt;/strong&gt; and was installed &lt;strong&gt;phase II&lt;/strong&gt; and by &lt;strong&gt;Site B&lt;/strong&gt; at &lt;strong&gt;phase I&lt;/strong&gt;. This is a more flexible approach.&lt;/p&gt;
&lt;p&gt;These packages are themselves versioned and can suffer updates and development of new features. So may depend on the SMT package at version 1.0.0 and later on update to version 2.0.0.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;template solution&lt;/strong&gt; normally is a more &lt;strong&gt;distributed approach&lt;/strong&gt; where we may have multiple sites with different teams all contributing to these modules and expanding them as they need.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Baselining
&lt;div id="baselining" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#baselining" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;second&lt;/strong&gt; trend is &lt;strong&gt;baselining&lt;/strong&gt;, or golden model.&lt;/p&gt;
&lt;p&gt;This is very typical in a &lt;strong&gt;multi-site&lt;/strong&gt; structure where all the sites are very similar between them. The user can then define a &lt;strong&gt;golden baseline of how the factory should behave&lt;/strong&gt; with all the particular business logic and customization he may wish to add.&lt;/p&gt;
&lt;p&gt;The MES for all the sites will contain not just the CM MES, but also this additional layer of customization.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Baseline"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/baseline.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The baseline may have their own particular team and architects and then &lt;strong&gt;each site will depend on the baseline&lt;/strong&gt;, and as the baseline is updated they will also move to the latest versions of the baseline.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;As you can see as MES implementations grow in scope and ambition, we now think globally and it generates new opportunities for abstraction and simplification.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extendable Controller&lt;/strong&gt; is a feature that comes as a &lt;strong&gt;solution in IoT&lt;/strong&gt; to try and address the problems caused by &lt;strong&gt;templating&lt;/strong&gt; or &lt;strong&gt;baselining&lt;/strong&gt; for a controller integration.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Trade-Offs on Abstracting
&lt;div id="trade-offs-on-abstracting" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#trade-offs-on-abstracting" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In both the the templatization and baseline there is a &lt;strong&gt;common problem&lt;/strong&gt; that emerges, which is the need to &lt;strong&gt;customize or extend the customization&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s use the injection molding machine as an example.&lt;/p&gt;
&lt;p&gt;We can have a package with a feature set for injection molding machines with out of the box integration for particular machine types or vendors. It is often the case that in a &lt;strong&gt;particular site&lt;/strong&gt;, there is the need to use the &lt;strong&gt;template implementation&lt;/strong&gt; for the injection molding machine, but then require additional features. This without losing the relation to the template and an update path for the template.&lt;/p&gt;
&lt;p&gt;The site specific customization could clone whatever implementation the template provides, but cloning implies that the &lt;strong&gt;cloning entity loses all the relationship to the template&lt;/strong&gt;. After cloning your entity has a completely different lifecycle. What this means is that when the site updated the template and wanted access to new features, they would have to clone again and merge all their particular feature set.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Template Clone"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/templateclone.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Template Clone Update"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/templatecloneupdate.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also another downside, the user could then change the workflows he cloned from the original controller and would have &lt;strong&gt;no way of going back to the original workflows&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Extendable Controllers
&lt;div id="extendable-controllers" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#extendable-controllers" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The new feature introduced in version 11.2, solves this problem. It creates a parent child relationship between controllers.&lt;/p&gt;
&lt;p&gt;The child controller will then extends the parent controller workflows and is able to override them or add specific workflows.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Baseline &amp;amp; Template Change
&lt;div id="baseline--template-change" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#baseline--template-change" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With Extendable Controllers, the user is able to have a &lt;strong&gt;controller&lt;/strong&gt; that has a &lt;strong&gt;parent-child relation&lt;/strong&gt; with the provided controller. This means that &lt;strong&gt;any version change to the parent controller&lt;/strong&gt;, in non overridden workflows, &lt;strong&gt;will automatically affect the child controller&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Parent Child"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/parentchild.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Parent Child Update"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/parentchildupdate.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This allows a &lt;strong&gt;separation of concerns&lt;/strong&gt; between &lt;strong&gt;who manages the baseline&lt;/strong&gt; and the template and &lt;strong&gt;who manages particular site specific customization&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In this example, we can see that the &lt;strong&gt;Site Controller&lt;/strong&gt; added the &lt;strong&gt;Workflow C&lt;/strong&gt;, when the parent controller is updated to a &lt;strong&gt;new effective version&lt;/strong&gt; with a &lt;strong&gt;new Workflow D&lt;/strong&gt;, automatically that change will be seen in the child controller, but its &lt;strong&gt;Workflow C will remain unaffected&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine a new use case where the Site specific implementation has &lt;strong&gt;overridden Workflow A&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Parent Child Overridden"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/parentchildoverridden.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Parent Child Overridden Update"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/parentchildoverriddenupdate.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;By being &lt;strong&gt;overridden the Workflow A&lt;/strong&gt; of the &lt;strong&gt;child controller takes precedence&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The update will &lt;strong&gt;add the new Workflow D&lt;/strong&gt;, but &lt;strong&gt;Workflow A will remain the same&lt;/strong&gt; with the Site specific changes.&lt;/p&gt;
&lt;p&gt;With this feature we have an integrated way to Extend Controllers without having to copy/clone them and creating a clear update path. Allowing for the teams that are building the feature sets and the teams that are building Site specific logic, to not diverge and create two very disparate sets of solutions, for very similar problems.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Compounding
&lt;div id="compounding" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#compounding" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The parent child relationship are always &lt;strong&gt;1-N&lt;/strong&gt;, one parent can have multiple children, but &lt;strong&gt;a child controller can only have one parent&lt;/strong&gt;. But an interesting feature is that &lt;strong&gt;a child can be a parent&lt;/strong&gt;. So we have an open door to controller compounding.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine a cumulative example, where we have a controller for a Station.&lt;/p&gt;
&lt;p&gt;This station is managing the operator material actions. For particular stations, they have also some inspection results it needs to collect. Additionally, for some stations it also has integration with instrument equipment like scales or thermometers.&lt;/p&gt;
&lt;p&gt;Typically, this would be very hard to abstract, you would need to have one controller that would have the driver to connect to the station, then a driver for the inspection machine and finally an additional one for instruments.&lt;/p&gt;
&lt;p&gt;It is often the case that with complex multi layer integrations the owners of each layer are not even the same. With the current solution you would be forced to have all of them working in the same implementation, with a controller that would control all the drivers required to model the process of the station with instruments and inspection. Another approach could be splitting into several controllers each only controlling a subset of the process, but then you would lose the big advantage of having a holistic approach to the process and the information and logic sharing between these integrations. You would also have no way to reuse these integrations in a modular approach, having to always start from scratch and map these processes.&lt;/p&gt;
&lt;p&gt;Extendable Controllers implements a simple and intuitive solution for that problem. It allows the &lt;strong&gt;compounding of parent child relations to map these variations&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;With this approach you could have a set of instances that use the controller for the station, another set that uses the controller with station plus inspection and even another set that uses station plus inspection plus instruments.&lt;/p&gt;
&lt;p&gt;We can have the maximalist integration:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Extendable Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/extendablecontroller_rev.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Or a subset with only the Station integration and Inspection:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Extendable Controller Subset"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/extendablecontrollersubset_rev.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This way we can have a &lt;strong&gt;baseline or template&lt;/strong&gt; that delivers a &lt;strong&gt;Station integration&lt;/strong&gt; and then the &lt;strong&gt;site can add their own controller&lt;/strong&gt;, like the Inspection controller with additional features. This preserves the link to the Station controller and its update path, without sacrificing the ability to add new features.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Baseline Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/baselinestationcontroller.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Use Case
&lt;div id="use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s map this as a use case. We will use the compounding example and add workflows to our controllers.&lt;/p&gt;
&lt;p&gt;In our use case, the structure is that we have an Automation Controller child that has two workflows, then two more workflows that come from his parent the Inspection Controller and finally two more that come from the grandparent controller, the Station Controller.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Use Case Extendable Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/usecaseextendable.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine that now, in your child controller, you wish to &lt;strong&gt;change the Material Tracking workflow&lt;/strong&gt; of your parent. For example, you want to add logic if any measure is out of bounds the Station TrackOut must also put the material on Hold.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Use Case Extendable Modify Workflow"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/usecaseextendablemodifyworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We will &lt;strong&gt;override the workflow&lt;/strong&gt; to add this logic. By overriding a workflow from a parent or grandparent you are effectively &lt;strong&gt;disabling the workflow of the parent&lt;/strong&gt; and &lt;strong&gt;pulling it into your child controller&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Use Case Extendable Override Workflow"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/usecaseextendableoverrideworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Then you can &lt;strong&gt;add your own logic to the workflow&lt;/strong&gt;. If you wish &lt;strong&gt;you can later on revert it&lt;/strong&gt;, by reverting you will cease to have that workflow in the child controller, but will have it as a reference to a parent controller.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Use Case - Using the MES
&lt;div id="use-case---using-the-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---using-the-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s see it in the MES.&lt;/p&gt;
&lt;p&gt;After creating a &lt;strong&gt;Station Controller&lt;/strong&gt; and setting it as &lt;strong&gt;effective&lt;/strong&gt; we can now create our &lt;strong&gt;inspection controller that extends the station&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When we create our Inspection Controller we can create our first parent child relation.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Inspection Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/controllerextendedinspection.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the Inspection controller we can choose to add or not its own driver definition. In our use case we wish to add a driver definition responsible to collect information from files generated by the inspection machine. The &lt;strong&gt;workflow view will show all the workflows that are active&lt;/strong&gt; and will be used in the integration.&lt;/p&gt;
&lt;p&gt;We will have two workflows (Setup, Material Tracking) that are inherited and two that are from our child implementation (Inspection_Setup, Inspection Result).&lt;/p&gt;
&lt;p&gt;We can now add our final layer, we can add a Controller focused on our Instruments integration.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Instruments Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/controllerextendedIInstruments.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This controller will have as parent our Inspection Controller. By &lt;strong&gt;adding the Inspection Controller as parent&lt;/strong&gt;, it will now have &lt;strong&gt;access to all the workflows&lt;/strong&gt; the Inspection Controller had, including the one&amp;rsquo;s that came from his parent.&lt;/p&gt;
&lt;p&gt;The Instruments controller is now a compounding of &lt;code&gt;Station-&amp;gt;Inspection-&amp;gt;Instruments&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Instruments Override Controller"
src="https://image.j-roque.com/posts/202507230-iot-extensibility-iv/controllerextendedIInstrumentsoverride.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the instruments controller, we are now free to use all the available workflows. We can even override workflows that were inherited. By &lt;strong&gt;overriding&lt;/strong&gt; we are &lt;strong&gt;creating an editable copy of the original workflow&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;We are always able to revert to the parent workflow. This is very helpful when we need to perform some specific logic that is later provided by our parent controller. We can just revert back to the new version of the parent workflow.&lt;/p&gt;
&lt;p&gt;Also, we have a native integration between child and all its genealogy. When in a workflow of a child controller, with a simple button press the user is directed to the view of that workflow in its original controller.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This 11.2 feature constitutes a milestone in providing a simple and effective way to scale and adapt to ever more complex working modes and solutions.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250730-iot-extensibility-iv/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250730-iot-extensibility-iv/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part III - Customization</title>
<link>https://j-roque.com/posts/20250729-iot-extensibility-iii/</link>
<pubDate>Tue, 29 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250729-iot-extensibility-iii/</guid>
<description>Customization - making it complex, in order to make it simple</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250729-iot-extensibility-iii/featured.png" alt="Part III - Customization" /&gt;&lt;/p&gt;&lt;p&gt;Making it complex, in order to make it simple.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://j-roque.com/posts/20250725-iot-extensibility-i/"
target="_blank"
&gt;Part I&lt;/a&gt;&lt;br&gt;
&lt;a
href="https://j-roque.com/posts/20250728-iot-extensibility-ii/"
target="_blank"
&gt;Part II&lt;/a&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In &lt;strong&gt;part III&lt;/strong&gt;, of the &lt;strong&gt;IoT Extensibility series&lt;/strong&gt;, is dedicated to customization.&lt;/p&gt;
&lt;p&gt;These are very advanced use cases, but that allow for very generic solutions.&lt;/p&gt;
&lt;p&gt;The use of customization can be a big leverage to create abstracted solutions that fit your solution and can have an exponential impact in simplifying your integration process.&lt;/p&gt;
&lt;p&gt;Customization is always a trade-off, in this post we will talk about about Connect IoT sandboxed customization, which mitigates some of the impact of customization as a choice, but nevertheless customization has an impact in maintainability.&lt;/p&gt;
&lt;p&gt;All of these interactions depend on particular protocols. Protocols may support events, commands and property actions, or can just support a subset of them.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Dynamically Perform Driver Actions
&lt;div id="dynamically-perform-driver-actions" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dynamically-perform-driver-actions" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have discussed, in previous blogs, the use of strategies to make your controller and driver definition more dynamic. These solutions are still very hooked to fixed structures, known before run time.&lt;/p&gt;
&lt;p&gt;There are use cases where we wish to have an even more dynamic approach, where what our driver should do depends on an external resolution.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see an example.&lt;/p&gt;
&lt;p&gt;Imagine we have a &lt;strong&gt;command that is executed when receiving a message bus message&lt;/strong&gt;, this is very simple to do with a message bus listener task and an equipment command task. But the equipment command task requires the user to know beforehand which command will be executed.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Message Bus Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/mbmessagecommand.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;What if we reach a very dynamic scenario where only the outside world knows which command will be executed?&lt;/p&gt;
&lt;p&gt;Where we want the message bus message to hold not just the input information of the command but the whole command definition.&lt;/p&gt;
&lt;p&gt;In order to achieve this we can either use the &lt;code&gt;Driver Actions&lt;/code&gt; tasks or the &lt;code&gt;Code Task&lt;/code&gt; which has full access to the Driver API. We have a set of possible actions, executing commands, get and set of properties and create events.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Use Case - Custom Command
&lt;div id="use-case---custom-command" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---custom-command" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In all the drivers that support custom extensibility, there is a subsection dedicated to the format of the message they are expecting and the topic.&lt;/p&gt;
&lt;p&gt;For File Raw, we can &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/automation-protocol/automation-protocol-protocols/driver_fileraw/#execute-command"
target="_blank"
&gt;see&lt;/a&gt; that in order to perform commands it is expecting a command in the topic &lt;code&gt;connect.iot.driver.fileBased.executeCommand&lt;/code&gt; and gives us the following example payload:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
    &amp;#34;command&amp;#34;: {
        &amp;#34;name&amp;#34;: &amp;#34;FileOrDirectoryExists&amp;#34;,
        &amp;#34;deviceId&amp;#34;: &amp;#34;Exists&amp;#34;,
        &amp;#34;extendedData&amp;#34;: { &amp;#34;commandType&amp;#34;: &amp;#34;Exists&amp;#34; },
        &amp;#34;parameters&amp;#34;: [
            { &amp;#34;name&amp;#34;: &amp;#34;path&amp;#34;, &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;, &amp;#34;deviceType&amp;#34;: &amp;#34;String&amp;#34; },
        ],
    },
    &amp;#34;parameters&amp;#34;: {
        &amp;#34;path&amp;#34;: &amp;#34;c:/temp/file.csv&amp;#34;,
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this strategy by using the &lt;code&gt;Send Notification to Driver&lt;/code&gt; task we have fully delegated the driver command execution to the outside world. As long as it receives a payload that respects the contract of what is a command it is able to execute.&lt;/p&gt;
&lt;p&gt;In this diagram we can see that on the left side, the command is being triggered by invoking the task &lt;code&gt;Execute Equipment Command&lt;/code&gt; with the command &lt;code&gt;CreateDirectory&lt;/code&gt;, which is known in the workflow statically. On the right side, we are using the task &lt;code&gt;Send Notification to Driver&lt;/code&gt; and the workflow is fully agnostic to the command. In the second use case the external invoker can pass whichever definition he wishes, but has to provide the full command definition.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Diagram Custom Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/diagramfilerawcustomcommand.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;If we wanted to execute other types of commands our workflow wouldn&amp;rsquo;t change as it would act as passthrough for the customization that is provided by the received message.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Diagram Other Custom Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/diagramfilerawothercustomcommand.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see it in the MES.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Custom Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/filerawcustomcommand.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Note that in this case it is a message bus listener task, but we could also have for example a table resolution that would hold the metadata and would then invoke the command.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Diagram Database Custom Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/diagramdatabase.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task Send Raw
&lt;div id="use-case---code-task-send-raw" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-send-raw" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The same can be achieved with a &lt;code&gt;Code&lt;/code&gt; Task. When dragging and dropping a task in the Connect IoT workflow, it will query the user if it will be used by a controller or by a driver. If the user selects driver, the driver api will be available in the code task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Send Raw Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetasksendrawworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Send Raw"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetasksendraw.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
this.framework.driver.sendRaw(&amp;#34;connect.iot.driver.fileBased.executeCommand&amp;#34;, inputs.command);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that by accessing the &lt;code&gt;this.framework.driver&lt;/code&gt; variable we have the full driver api. In this example, we are similarly as above, passing on the full on command definition.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task Execute Command
&lt;div id="use-case---code-task-execute-command" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-execute-command" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Another possibility is when the command exists as part of the driver definition, then we can simplify the work of invoking the command.&lt;/p&gt;
&lt;p&gt;We can leverage the driver definition and retrieve the command definition from the driver definition.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Execute Command Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetaskexecutecommandworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Task Execute Command"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetaskexecutecommand.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
// Search for our command in the driver definition
    const command = this.framework.driver.automationControllerDriverDefinition.AutomationDriverDefinition.Commands.find(tt =&amp;gt; tt.Name == inputs.commandName);
    let paramMap = new Map&amp;lt;string, any&amp;gt;();
if(command == null) {
        return {};
    }
for(const item of command.Parameters) {
        // Set Command Parameters
        paramMap.set(item.Name, inputs.parameters[item.Name]);
    }
await this.framework.driver.executeCommand(command, paramMap);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the responsibility is mixed and the external caller does not have to know the full command definition, he just needs to know the command name and the argument values for the command. We can leverage the driver definition contract already defined and just expose the invocation by name and arguments.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Use Case - Custom Event
&lt;div id="use-case---custom-event" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---custom-event" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The events are split between two different actions that may happen sequentially or differed in time.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;first action is registering the event&lt;/strong&gt;. This is responsible for notifying the driver that the event exists and it has a particular structure.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;second action is subscribing to the event&lt;/strong&gt;. Where the task notifies the driver that it wants to receive a particular event whenever it happens in the driver. Note that, an event listener may be created and destroyed throughout the workflow lifecycle and this may be an important element of your implementation.&lt;/p&gt;
&lt;p&gt;This would be analogous to having an &lt;code&gt;Equipment Event&lt;/code&gt; task, that has &lt;code&gt;Auto Enable&lt;/code&gt; as &lt;strong&gt;false&lt;/strong&gt; and only when a particular action occurs, will it toggle to true or false.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Toggle Event Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/toggleeqevent.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Send Raw
&lt;div id="use-case---send-raw" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---send-raw" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Therefore, in order to provide the same behavior we require two different tasks, one will be &lt;code&gt;Send Notification to Driver&lt;/code&gt; where we register our event and a &lt;code&gt;Subscribe in Driver&lt;/code&gt; where we create our subscription.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Custom Event Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/customevent.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In this example we are registering and immediately subscribing, this is not mandatory, subscribing may happen any time after registering.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Send Notification to Driver&lt;/code&gt; task will receive as type, the specific type for the File Raw Protocol &lt;code&gt;connect.iot.driver.fileBased.registerEvent&lt;/code&gt; and a payload with the event structure.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
    &amp;#34;event&amp;#34;: [
        {
            &amp;#34;Name&amp;#34;: &amp;#34;MyEvent&amp;#34;,
            &amp;#34;Description&amp;#34;: &amp;#34;Triggered when the watcher detects a file that was not previously identified appears&amp;#34;,
            &amp;#34;DeviceEventId&amp;#34;: &amp;#34;MyEvent&amp;#34;,
            &amp;#34;IsEnabled&amp;#34;: true,
            &amp;#34;ExtendedData&amp;#34;: {
                &amp;#34;eventTrigger&amp;#34;: &amp;#34;NewFile&amp;#34;
            },
            &amp;#34;EventProperties&amp;#34;: [
                {
                    &amp;#34;Property&amp;#34;: &amp;#34;FileName&amp;#34;,
                    &amp;#34;Order&amp;#34;: 1,
                    &amp;#34;ExtendedData&amp;#34;: {}
                },
                (...)
            ]
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The matching &lt;code&gt;Subscribe in driver&lt;/code&gt;, must receive a message type that is a concatenation of the subscribe topic with the event name &lt;code&gt;connect.iot.driver.fileBased.event.MyEvent&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;When there&amp;rsquo;s an event occurrence, the task will output and the message output will contain the full event payload.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task
&lt;div id="use-case---code-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;Code&lt;/code&gt; task cannot have live listeners. It is an ephemeral task with a defined time to live. As such, we cannot have event listeners in the code task. We can however handle all the parsing and construction of our events in order to handle the registration of the events.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Custom Event Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetaskexecuteregistereventsworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Custom Event"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetaskexecuteregisterevents.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
await this.framework.driver.notifyRaw(&amp;#34;connect.iot.driver.fileBased.registerEvent&amp;#34;, inputs.event);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that this approach may not seem immediately useful, but if we think we can frontload the event registration of all events and perform data transformation this becomes very useful. For example, with a simple loop we can register all the events.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Code Custom Event Loop"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/codetaskexecuteregistereventsloop.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
for(const event of inputs.event) {
        await this.framework.driver.notifyRaw(&amp;#34;connect.iot.driver.fileBased.registerEvent&amp;#34;, event);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Use Case - Get Property
&lt;div id="use-case---get-property" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---get-property" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For protocols, like &lt;strong&gt;OPC-UA&lt;/strong&gt; it is very common to want to retrieve a particular node id and not register a full on event. In these types of use cases it is very helpful to retrieve a specific property.&lt;/p&gt;
&lt;p&gt;In this example we can see that &lt;strong&gt;whenever we receive a message bus message, we will reply back with to the message bus with the result of the get property&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Property Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/getpropertyreplyworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Send Raw
&lt;div id="use-case---send-raw-1" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---send-raw-1" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The same can be achieved with the task &lt;code&gt;Send Notification to Driver&lt;/code&gt;. In this task we will specify our type &lt;code&gt;connect.iot.driver.opcua.getPropertiesValues&lt;/code&gt; and then our content payload. In the OPC-UA documentation we already see an &lt;a
href="https://help.criticalmanufacturing.com/userguide/automation/automation-protocol/automation-protocol-protocols/driver_opcua/?h=opc#example"
target="_blank"
&gt;example&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The content that has to be provided is an object with the property name.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;[
  {
    &amp;#34;name&amp;#34;: &amp;#34;status&amp;#34;,
    &amp;#34;deviceId&amp;#34;: &amp;#34;ns=2;s=Station01.production.status&amp;#34;,
    &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reply will be an object containing the information of the tag and the value retrieved.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;[
  {
    &amp;#34;propertyName&amp;#34;: &amp;#34;status&amp;#34;,
    &amp;#34;originalValue&amp;#34;: {
      &amp;#34;dataType&amp;#34;: &amp;#34;String&amp;#34;,
      &amp;#34;value&amp;#34;: &amp;#34;Productive&amp;#34;,
    },
    &amp;#34;value&amp;#34;: &amp;#34;Productive&amp;#34;,
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Property Send Notification Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/getpropertysendnotification.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;As you can see this is very versatile &lt;strong&gt;you can specify a full array of properties to retrieve&lt;/strong&gt;. Also, notice that we&amp;rsquo;ve used before the Send Notification to driver task, this task is quite generic what does the heavy lifting is the type and the content and all of this can be manipulated in the inputs.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task
&lt;div id="use-case---code-task-1" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-1" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Code Task can be very helpful for this use case. It allows us to perform the same action of the Send Notification to Driver task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Property Code Task Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/getpropertycodetask.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
    outputs.reply.emit(await this.framework.driver.sendRaw(&amp;#34;connect.iot.driver.opcua.getPropertiesValues&amp;#34;, inputs.properties));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But more importantly, it simplifies the process of the data preparation. For example, we can already prepare the data by trimming all the information that is not needed.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine for this case, that my final object to reply to the outside world is just a name and value pair.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Property Code Task Reply Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/getpropertiescodetaskreply.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
    type Subset = {
        propertyName: string;
        value: any;
    };
const values = await this.framework.driver.sendRaw(&amp;#34;connect.iot.driver.opcua.getPropertiesValues&amp;#34;, inputs.properties) as unknown as Subset[];
    outputs.reply.emit(values.map(value =&amp;gt; ({ name: value.propertyName, value: value.value })));
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task Get Properties
&lt;div id="use-case---code-task-get-properties" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-get-properties" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Also, similarly to what we did for events we can also leverage the driver definition to retrieve the properties that are available and execute the get for them.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Get Property Driver Definition Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/getpropertydriverdefinition.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
// Retrieve Property definition
    const propertyToGet = this.framework.driver.automationControllerDriverDefinition
        .AutomationDriverDefinition
        .Properties
        .find(propertyName =&amp;gt; propertyName.Name === inputs.property);
if (!propertyToGet) {
        throw Error(`No Property found`);
    }
outputs.reply.emit(await this.framework.driver.getProperties([propertyToGet]));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this approach the external caller can provide just a set of property names and will use all that is described in the driver definition.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Use Case - Set Property
&lt;div id="use-case---set-property" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---set-property" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The set property is the mirror of the get property. The set property allows the user to write a particular value to a property.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Property Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/setproperty.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Send Raw
&lt;div id="use-case---send-raw-2" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---send-raw-2" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the documentation for &lt;strong&gt;OPC-UA&lt;/strong&gt; we also have examples of how we can execute the set of properties. We will have to send a notification to type &lt;code&gt;connect.iot.driver.opcua.setPropertiesValues&lt;/code&gt; with a payload:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;[
  {
    &amp;#34;property&amp;#34;: {
      &amp;#34;name&amp;#34;: &amp;#34;status&amp;#34;,
      &amp;#34;deviceId&amp;#34;: &amp;#34;ns=2;s=ns=2;s=Station01.production.status&amp;#34;,
      &amp;#34;deviceType&amp;#34;: &amp;#34;String&amp;#34;
    },
    &amp;#34;value&amp;#34;: &amp;#34;Productive&amp;#34;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using the task &lt;strong&gt;Send Notification to Driver&lt;/strong&gt; we can achieve this.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Property Send Notification to Driver Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/sendnotificationsetprops.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task
&lt;div id="use-case---code-task-2" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-2" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;Send Notification to Driver&lt;/strong&gt; can also be achieved in the code task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Property Send Notification to Driver Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/setpropscode.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
await this.framework.driver.sendRaw(&amp;#34;connect.iot.driver.opcua.setPropertiesValues&amp;#34;, inputs.properties);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Use Case - Code Task Set Properties
&lt;div id="use-case---code-task-set-properties" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---code-task-set-properties" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;For the set properties we can also leverage the driver definition to retrieve our properties.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Property Driver Definition Workflow"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-iii/setpropsdriverdefinition.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public async main(inputs: any, outputs: any): Promise&amp;lt;any&amp;gt; {
    const propertyToSet = new Map&amp;lt;AutomationProperty, any&amp;gt;();
propertyToSet.set(this.framework.driver.automationControllerDriverDefinition
        .AutomationDriverDefinition
        .Properties
        .find(propertyName =&amp;gt; propertyName.Name === inputs.property.Name), inputs.property.value);
outputs.reply.emit(await this.framework.driver.setProperties(propertyToSet));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this approach the external caller can provide just a set of property names and will use all that is described in the driver definition.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Understanding these extension points is a big step in moving from a basic user capable of solving localized issues to being an advanced user capable of leveraging all that the system provides.&lt;/p&gt;
&lt;p&gt;These of course are tools that may be extremely helpful in some scenarios but that also may hinder a lot if used without care. Remember that every time you step into abstractions or generalizations you are moving away of your problem.&lt;/p&gt;
&lt;p&gt;Beware of the pitfall of the amazing abstractions that abstracts everything but doesn&amp;rsquo;t solve or simplify the real life scenarios. Take care to not create magic solutions, as they have a habit of turning the magic against the wizard. Connect IoT brings a lot of transparency into the solution design, there is a trade off of losing transparency when you do these types of solutions.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250729-iot-extensibility-iii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250729-iot-extensibility-iii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part II - Driver Extensibility</title>
<link>https://j-roque.com/posts/20250728-iot-extensibility-ii/</link>
<pubDate>Mon, 28 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250728-iot-extensibility-ii/</guid>
<description>Driver Extensibility creating more dynamic driver definitions</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250728-iot-extensibility-ii/featured.png" alt="Part II - Driver Extensibility" /&gt;&lt;/p&gt;&lt;p&gt;Simple problems, have simple solutions, the trick is having simple solutions for very complex problems.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://j-roque.com/posts/20250725-iot-extensibility-i/"
target="_blank"
&gt;Part I&lt;/a&gt;&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In part II, of the IoT Extensibility series, we will discuss mechanisms that add more dynamism in our driver definition.&lt;/p&gt;
&lt;p&gt;The driver definition is where the user maps the third party system interface into Connect IoT. It&amp;rsquo;s where the user specifies what are the &lt;strong&gt;events&lt;/strong&gt;, &lt;strong&gt;commands&lt;/strong&gt; and &lt;strong&gt;properties&lt;/strong&gt; he is interested in using and mapping.&lt;/p&gt;
&lt;p&gt;We will see how the driver definition has different behaviors depending on the protocol it references. Some protocols are very strong typed, others require the user to map the machine specification into the MES.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Templates
&lt;div id="templates" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#templates" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For protocols like, TCP-IP, REST or SECS/GEM, they are very dynamic and inform how the transport is created, but don&amp;rsquo;t have a strict set of messages, they inform how the transport layer and structure of the message is created and delegate the message content format to the user, with some rules. These protocols force the user to have a very descriptive driver definition.&lt;/p&gt;
&lt;p&gt;If I describe my &lt;strong&gt;REST Client&lt;/strong&gt; to map a &lt;strong&gt;POST&lt;/strong&gt; Request with a certain &lt;strong&gt;payload&lt;/strong&gt; I will be forced to describe it in the driver definition. We can look back to our &lt;a
href="https://j-roque.com/posts/20250407-3dprinter/#material-handling"
target="_blank"
&gt;3d Printer blog post&lt;/a&gt; to see an example.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Command Definition"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-startjob.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Command Parameter Definition"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-startjobarguments.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;There are other types of protocols where the full protocol is a set of strong typed more or less unchanging messages. For example, IPC-CFX is a strong typed protocol where all the messages and message format are known, or the File Raw protocol where all the events and commands are known.&lt;/p&gt;
&lt;p&gt;These templates are provided in the driver level. When we &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_driver/"
target="_blank"
&gt;create a driver&lt;/a&gt; using the CM CLI with &lt;a
href="https://criticalmanufacturing.github.io/cli/03-explore/commands/new_iot_driver/"
target="_blank"
&gt;cmf new iot driver&lt;/a&gt;. The driver will be generated with a &lt;strong&gt;templates&lt;/strong&gt; folder.&lt;/p&gt;
&lt;p&gt;These are json files that describe, events, commands properties. Here the user can create predefined driver definitions, that will automatically be imported to the system.&lt;/p&gt;
&lt;p&gt;Here we can see an example of the &lt;code&gt;File Raw&lt;/code&gt; command &lt;code&gt;Exists&lt;/code&gt; as defined as a template command.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;command&amp;#34;: [
      {
          &amp;#34;Name&amp;#34;: &amp;#34;Exists&amp;#34;,
          &amp;#34;Description&amp;#34;: &amp;#34;Checks if the path location exists.&amp;#34;,
          &amp;#34;DeviceCommandId&amp;#34;: &amp;#34;Exists&amp;#34;,
          &amp;#34;ExtendedData&amp;#34;: {
              &amp;#34;commandType&amp;#34;: &amp;#34;Exists&amp;#34;
          },
          &amp;#34;CommandParameters&amp;#34;: [
              {
                  &amp;#34;Name&amp;#34;: &amp;#34;path&amp;#34;,
                  &amp;#34;Description&amp;#34;: &amp;#34;Path of the file&amp;#34;,
                  &amp;#34;Order&amp;#34;: 1,
                  &amp;#34;DataType&amp;#34;: &amp;#34;String&amp;#34;,
                  &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;String&amp;#34;,
                  &amp;#34;DefaultValue&amp;#34;: &amp;#34;&amp;#34;,
                  &amp;#34;IsMandatory&amp;#34;: true,
                  &amp;#34;ExtendedData&amp;#34;: {}
              },
              {
                  &amp;#34;Name&amp;#34;: &amp;#34;attempts&amp;#34;,
                  &amp;#34;Description&amp;#34;: &amp;#34;Number of retry times&amp;#34;,
                  &amp;#34;Order&amp;#34;: 2,
                  &amp;#34;DataType&amp;#34;: &amp;#34;Integer&amp;#34;,
                  &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Integer&amp;#34;,
                  &amp;#34;DefaultValue&amp;#34;: 1,
                  &amp;#34;IsMandatory&amp;#34;: true,
                  &amp;#34;ExtendedData&amp;#34;: {}
              },
              {
                  &amp;#34;Name&amp;#34;: &amp;#34;sleepBetweenAttempts&amp;#34;,
                  &amp;#34;Description&amp;#34;: &amp;#34;Waiting time between retries&amp;#34;,
                  &amp;#34;Order&amp;#34;: 3,
                  &amp;#34;DataType&amp;#34;: &amp;#34;Integer&amp;#34;,
                  &amp;#34;AutomationProtocolDataType&amp;#34;: &amp;#34;Integer&amp;#34;,
                  &amp;#34;DefaultValue&amp;#34;: 1000,
                  &amp;#34;IsMandatory&amp;#34;: true,
                  &amp;#34;ExtendedData&amp;#34;: {}
              }
          ]
      }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now have a mix between driver definitions with user added fields and fields that are provided by the driver. We can also, have simply blank driver definitions if we just use the definition provided by the driver.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Exists Example"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-ii/filerawexiststemplate.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Custom Templates
&lt;div id="custom-templates" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#custom-templates" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One interesting feature that is also supported is overriding the driver definition template via the controller.&lt;/p&gt;
&lt;p&gt;This can be quite helpful in order to override or add templates provided by the driver. The user can add his own events and commands and update existing one&amp;rsquo;s.&lt;/p&gt;
&lt;p&gt;The user can then expand the existing driver definition template with his own. For example, for IPC-CFX there is an extensive library of commands and events and not all of them are declared out of the box by the driver.&lt;/p&gt;
&lt;p&gt;The user can add them by using the &lt;code&gt;Custom Template&lt;/code&gt; task, and specifying in a json format the changes he wishes to do to the template driver definition.&lt;/p&gt;
&lt;p&gt;In this example, I am creating my own event based of the File Raw protocol.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Custom Templates Example"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-ii/custom_templates_example.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Tokenization
&lt;div id="tokenization" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tokenization" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A common pattern that we see in the need for dynamism when defining equipment interfaces is when there are a set of equipment that are virtually the same, but have a different identifier.&lt;/p&gt;
&lt;p&gt;Connect IoT provides a simple mechanism to address this use case, by using tokens. The system allows the user to access a set of default tokens, and also information about the appended iot entity.&lt;/p&gt;
&lt;p&gt;We can access and use the appended iot entity attributes and properties to allow us to construct our property device ids. Tokens are defined by being enclosed in ${&lt;value&gt;} or $(&lt;value&gt;).&lt;/p&gt;
&lt;h2 class="relative group"&gt;Tokenization - Use Case
&lt;div id="tokenization---use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tokenization---use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A common use case is an OPC-UA server, that maps the tags of the machines with the same routing, only varies by adding the machine name in the routing node structure.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine this use case, where we have &lt;code&gt;Station 01&lt;/code&gt; and &lt;code&gt;Station 02&lt;/code&gt;, the node for the production status is defined &lt;code&gt;ns=4;s=Station01.production.status&lt;/code&gt; and &lt;code&gt;ns=4;s=Station02.production.status&lt;/code&gt; respectively. Notice that both these node ids are very similar, they only change by adding the specific machine identifier in the node structure.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Token Diagram"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-ii/tokendiagram.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can leverage our IoT Architecture where we have the &lt;code&gt;Resource&lt;/code&gt; as the appended iot entity and use the &lt;code&gt;Resource&lt;/code&gt; Name as a key for our token replacement. Now we can reuse the same driver definition for all machines, by just replacing where there was the machine name by the corresponding token &lt;code&gt;${Name}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Using tokens can be a very powerful abstraction tool, that easily saves a lot of time and maintainability by saving the amount of driver definitions the user needs to maintain. A common drawback is when the user &amp;ldquo;abuses&amp;rdquo; the use of tokens. A token is by definition a dynamic resolution of a value, if you have several tokens or combination of tokens it can become very hard to troubleshoot if there is a problem in the final value resolution.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Token Example"
src="https://image.j-roque.com/posts/20250728-iot-extensibility-ii/token_example_drivedef.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We are continuing our journey through IoT Extensibility, in part II we focused on driver extensibility. How we could make the user life easier by supplying out of the box driver definitions, how we can later override and expand them and finally the use of tokens to make our driver definition much more dynamic. In part III, we will focus on customization, how we can leverage customization entrypoints to build very dynamic use cases.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250728-iot-extensibility-ii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250728-iot-extensibility-ii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part I - IoT Extensibility</title>
<link>https://j-roque.com/posts/20250725-iot-extensibility-i/</link>
<pubDate>Thu, 24 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250725-iot-extensibility-i/</guid>
<description>IoT Extensibility, how we can leverage IoT extensibility to simplify complex scenarios</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250725-iot-extensibility-i/featured.png" alt="Part I - IoT Extensibility" /&gt;&lt;/p&gt;&lt;p&gt;Simple problems, have simple solutions, the trick is having simple solutions for very complex problems.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this blog post we will go into some detail in understanding how we can leverage all that is built in into Critical Manufacturing Connect IoT platform in order to solve complex scenarios.&lt;/p&gt;
&lt;p&gt;There are typically two axis of complexity, scalability and variability.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scalability vs Variability"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/scalability_vs_variability_chart.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Scalability&lt;/code&gt; can be defined in a scenario as a need to replicate or reuse with minimal effort and change. If for example, I have a hundred machines in my shopfloor with a similar process and of a similar type, a scalable solution is one that minimizes the effort of solving the same problem a hundred times.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Variability&lt;/code&gt; is different dimension, how does your system model high change scenarios. One can think of a scenario where we have machines, that work partially in the same way but that require specific change. This is a common use case where we have a set of machines that have the same process, but are from different vendors or have a different model with different features.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Complexity&lt;/code&gt; grows as your variability and scalability grow and it grows exponentially.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Architecture
&lt;div id="architecture" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#architecture" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;It all starts with a flexible architecture. In a previous post &lt;a
href="https://j-roque.com/posts/20250325-connectiotstructure/"
target="_blank"
&gt;here&lt;/a&gt; we&amp;rsquo;ve already gone into detail about how the Connect IoT structure works and how we can leverage it.&lt;/p&gt;
&lt;p&gt;In Critical Manufacturing Connect IoT we already have a &lt;strong&gt;big split&lt;/strong&gt; between what are the &lt;strong&gt;definition&lt;/strong&gt;&amp;rsquo;s of our integrations and what are the &lt;strong&gt;instances&lt;/strong&gt; that are running. This allows us to leverage the same definitions across &lt;code&gt;N&lt;/code&gt; instances.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Multiple Instances"
src="https://image.j-roque.com/posts/20250325-observability/img/multiplesameinstances.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is very important, defining the &lt;strong&gt;architecture&lt;/strong&gt; of your solution is often the &lt;strong&gt;deciding factor in your project&amp;rsquo;s success&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;MES Entity
&lt;div id="mes-entity" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mes-entity" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;An instance is essentially the coming together of two sets of definitions. The integration definition given by the &lt;code&gt;Automation Controller&lt;/code&gt; and an &lt;code&gt;MES Entity&lt;/code&gt;. The entity that is appended to your controller is the premium hook your integration will have into the MES. It will serve to disambiguate between different controller instances and will allow you to provide context to your integration.&lt;/p&gt;
&lt;p&gt;The simplest and most common use case is you have an MES Resource and have a controller instance that will control and interact with that Resource. If you have one hundred resources that work in the same way for the same process, you will have a hundred controller instances which leverage the same Automation Controller and will only change the MES Resource that they will hook themselves to.&lt;/p&gt;
&lt;p&gt;Notice how powerful this is, we can have direct access to all the properties and attributes of the MES Entity that is attached to the instance, inside that same instance. We can also, have a different MES Entity per Automation Controller and per Driver. Allowing to even in the same controller have an MES entity responsible for the controller and different entities responsible for each driver.&lt;/p&gt;
&lt;p&gt;Imagine the scenario where we have a machine with load ports, one parent with multiple child resources. The load ports can be modeled as MES Resources of type &lt;code&gt;Load Port&lt;/code&gt; and the main Resource is a &lt;code&gt;Process&lt;/code&gt; Resource.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Load Port Scenario"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/loadports.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can have the Controller hooked at the main resource and then each driver hooked at their matching load port. This way your integration will have the full context of the specific Resource they are controlling. This means the configuration and specific characteristics of the integration are not on the integration level, but on the Resource level.&lt;/p&gt;
&lt;p&gt;This is the proper level, as the &lt;strong&gt;Resource&lt;/strong&gt; is the &lt;strong&gt;domain/functional level&lt;/strong&gt;, the &lt;strong&gt;integration&lt;/strong&gt; should only be interested on &lt;strong&gt;interfacing&lt;/strong&gt; and the dynamically responding to functional changes.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Load Port Instance"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/loadPortsMESRelations.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can extrapolate to &lt;code&gt;N&lt;/code&gt; instances.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Load Port N Instances"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/loadPortsMESRelationsN.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Another pattern is also very common, which is an &lt;strong&gt;integration level component&lt;/strong&gt; that &lt;strong&gt;interacts with several resources&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;We have a very common industry case, the OPC-UA server that acts as a line/area controller. In this use case we can also leverage the MES modeling to contextualize our integration. We can have an &lt;strong&gt;MES Resource of type Component&lt;/strong&gt;, that is a &lt;strong&gt;sub resource of all the resources it controls&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Component Resource"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/componentresource.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;With this relationship, we can have a dynamic discovery of all the resources that are controlled by the OPC-UA and create an integration that is dynamic and also that is more efficient. In this case, we won&amp;rsquo;t need one OPC-UA Connection per Resource, but we will leverage one connection for all our Resources.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Component Resource N"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/componentresourceinstance.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can already see how the &lt;strong&gt;MES modelling can be huge boon for our integration&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Architecture not only simplifies our life as integrators, but can have a multiplying effect on efficiency and simplicity.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Standalone Workflows
&lt;div id="standalone-workflows" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#standalone-workflows" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Standalone workflows are very powerful abstraction points in the controller logic. They act as &lt;strong&gt;touch-points that the user can reuse across multiple controllers&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine a scenario where we have a workflow that perform a sequence of tasks, task &lt;strong&gt;A&lt;/strong&gt;, &lt;strong&gt;B&lt;/strong&gt;, &lt;strong&gt;C&lt;/strong&gt; and &lt;strong&gt;D&lt;/strong&gt;. In our next integration we notice that in fact he has exactly the same use case as before but that instead of &lt;strong&gt;A&lt;/strong&gt; and &lt;strong&gt;B&lt;/strong&gt; he has only &lt;strong&gt;E&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="A B C D E"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/abcde.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can extract the feature set that is the grouping of tasks C and D, into a standalone workflow.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Workflow A"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/workflowA.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This standalone workflow &lt;strong&gt;can then be used across all your controllers&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Workflow A use"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/workflowAuse.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;These workflows are ephemeral, similar to sub-workflows, they aren&amp;rsquo;t meant to have long lived listeners. They also do not support driver actions as they are dependant on each controller&amp;rsquo;s driver definition.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Standalone Workflows - Use Case
&lt;div id="standalone-workflows---use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#standalone-workflows---use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s see a use case using the MES.&lt;/p&gt;
&lt;p&gt;We will create a workflow that always saves a set of data in the persistency layer and broadcasts it to the Message Bus. This is a common scenario for abstraction, where you are able to &lt;strong&gt;abstract either calls to the MES, interfaces to third party systems or data transformations&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In our use case we have two controllers with completely different drivers, one is for file raw and the other is for tcp-ip.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="File Raw example"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/filerawexample.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="TCP-IP example"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/tcpipexample.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Both have completely different entrypoints and different end conditions but have similar subsets of logic. We can abstract this logic into a standalone workflow.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see how it would look. We can create our standalone workflow:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Standalone Workflow example"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/standaloneworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our standalone workflow we are able to define a set of inputs and outputs for our workflow using the start and end tasks. This is then what the user will be able to set from outside the workflow.&lt;/p&gt;
&lt;p&gt;Now it is available and we can remake our controllers to leverage this shared workflow.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="File Raw Standalone Workflow example"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/fileraw_example_standalone.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="TCP-IP Standalone Workflow example"
src="https://image.j-roque.com/posts/20250725-iot-extensibility-i/tcpip_example_standalone.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Even in such a simple integration it can heavily improve the &lt;strong&gt;readability&lt;/strong&gt; and &lt;strong&gt;simplicity&lt;/strong&gt; of the workflow.&lt;/p&gt;
&lt;p&gt;All changes now performed to the standalone workflow will impact the controller that reference it.&lt;/p&gt;
&lt;p&gt;This can be a two way street, on one side this allows us to abstract and centralize this complexity and simplify our workflows, the flip side of this is that a change on this workflow can have impact on several controllers.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have started our journey in IoT Extensibility for part I we focused on leveraging the architecture and how we can use standalone workflows to simplify our controller logic. For part II we will focus on driver extensibility.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250725-iot-extensibility-i/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250725-iot-extensibility-i/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Edge Deploy</title>
<link>https://j-roque.com/posts/20250721-edge-deploy/</link>
<pubDate>Mon, 21 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250721-edge-deploy/</guid>
<description>On Edge one click deploy</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250721-edge-deploy/featured.png" alt="Edge Deploy" /&gt;&lt;/p&gt;&lt;p&gt;One of the biggest challenges with moving the MES system into the cloud is how can we interface with external systems that must exist near the shopfloor. Today we will take a look at how Critical Manufacturing has solved this issue.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s dive into one of simplest to understand use cases of the need for an edge application.&lt;/p&gt;
&lt;p&gt;It is common for the MES to control machine recipes or machine setups. The question is then, with your MES in the cloud does this work?&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cloud MES with Recipe"
src="https://image.j-roque.com/posts/20250721-edge-deploy/cloudrecipe.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;And what about this?&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cloud MES with Recipe World map"
src="https://image.j-roque.com/posts/20250721-edge-deploy/cloudrecipe1.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Most people would say yes to the first image and no to the second.&lt;/p&gt;
&lt;p&gt;We have an intuitive awareness that if we are trying to access an application or server that is in the other side of the world we will pay a latency cost. In actual fact both images may be the same, it all depends on the type of service that is hired. Most providers offer regional or multi regional hosting, that try to mitigate this issue.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;An anecdotal instance that I have seen, was a customer complaining that his system suddenly had got very slow. We did a full investigation on, from the database to the application, and everything seemed ok. When we started checking the network times to response, we noticed that they had worsen in the 10x range. When the customer asked his cloud provider, he was informed that that the nearest datacenter was down for maintenance and they had relocated the service to the nearest datacenter. Well, the original datacenter was already with high latency, when they moved it even further away, it was the difference between an acceptable response time and a response time that is now impacting the operator speed and system usability.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Nevertheless, even when we look at use cases where the datacenter distance is not impacting performance, we have use cases where the machine has particular constraints.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cloud MES with Recipe Time to Response"
src="https://image.j-roque.com/posts/20250721-edge-deploy/cloudrecipettr.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is also a fairly common scenario. Machines with high speed require process information, validation and acknowledgement to be in the sub second range. Adding the business logic to actually perform control with latency, this can be very hard to achieve with cloud deployments.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Trade-Offs
&lt;div id="trade-offs" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#trade-offs" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We can have systems that have &lt;strong&gt;high data volume&lt;/strong&gt;, &lt;strong&gt;high frequency&lt;/strong&gt; or &lt;strong&gt;tight time constraints&lt;/strong&gt;. We can also have systems that due to &lt;strong&gt;security&lt;/strong&gt; reasons must only operate with a local connection.&lt;/p&gt;
&lt;p&gt;Systems with large payloads, or that have a high message frequency are going to be more affected by latency, these systems are prime candidates for a decentralized approach.&lt;/p&gt;
&lt;p&gt;For CM Connect IoT, which is an application focused on interfacing with third party systems like machines, the ability to be closer to the system we are interfacing with is crucial.&lt;/p&gt;
&lt;p&gt;The questions now becomes, how can we have an &lt;strong&gt;application that is near but that also keeps the advantages of the Cloud&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Edge Control
&lt;div id="edge-control" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#edge-control" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Connect IoT main application, the Automation Manager is very flexible. It can run on Edge or on Cloud, it can be installed in Linux or Windows. It can be explicitly or implicitly installed, let&amp;rsquo;s try and understand what that means.&lt;/p&gt;
&lt;p&gt;It can be explicitly deployed via the &lt;code&gt;CM Devops Center&lt;/code&gt;, or it can be deployed with a &lt;code&gt;One Click Deploy&lt;/code&gt;. The One Click Deploy is an implicit installation via a middleware component, solely focused on Automation Manager deployment, the Automation Manager Controller (AMC).&lt;/p&gt;
&lt;p&gt;The AMC will query the MES for what managers does it need to deploy and deploy them. It can run in the MES cluster or in an edge cluster.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC Deploy"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcdeploy.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The Automation Manager can then spun up all the controllers and drivers it requires.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC Deploy Managers"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcdeploymanagers.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;You can see that we can have a very distributed setup where we have several Automation Managers Controllers, deployed in different clusters and then each AMC with several Automation Managers.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Edge Control - Red Hat&amp;rsquo;s Microshift
&lt;div id="edge-control---red-hats-microshift" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#edge-control---red-hats-microshift" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the questions that customers have regarding containers, particularly edge clusters is about the effort of maintaining not just one cluster with the MES, but the effort of also having clusters on edge and how to maintain both.&lt;/p&gt;
&lt;p&gt;This also connects with the need to have a low footprint solution for edge. It must have a low footprint both for running and deploying but also for maintaining.&lt;/p&gt;
&lt;p&gt;In order to achieve this, CM provides a virtual machine with &lt;code&gt;Red Hat's Microshift&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Microshift is&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Lightweight&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Kubernetes Compatible&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Easy to Maintain &amp;amp; Update&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Secure &amp;amp; Reliable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Part of the Red Hat Ecosystem&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Of course, the &lt;strong&gt;customer can always deploy in his own kubernetes managed cluster&lt;/strong&gt;, this is a more user friendly and low effort approach. Another interesting alternative can be the use of stretched clusters, where a single Kubernetes cluster is deployed across multiple geographically separated data centers (or availability zones) but function as one logical cluster. There are also proprietary variations on this like &lt;a
href="https://docs.aws.amazon.com/eks/latest/userguide/hybrid-nodes-overview.html"
target="_blank"
&gt;Amazon EKS Hybrid Nodes&lt;/a&gt;. One competing approach to Red Hat&amp;rsquo;s Microshift is the &lt;a
href="https://learn.microsoft.com/en-us/azure/aks/aksarc/aks-edge-overview"
target="_blank"
&gt;AKS Edge Essentials&lt;/a&gt;.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; The Microshift virtual machine is currently licensed via Red Hat.&lt;/span&gt;
&lt;/div&gt;
&lt;h3 class="relative group"&gt;Use Case - Microshift with Automation Manager Controller
&lt;div id="use-case---microshift-with-automation-manager-controller" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---microshift-with-automation-manager-controller" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The process all starts with CM creating an ISO image, and making it available with all that you need to start and install. This image will contain Microshift and a set of custom tools that CM provides to make the user life easier.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Iso deploy"
src="https://image.j-roque.com/posts/20250721-edge-deploy/iso.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The user can then install and deploy the virtual machine.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploy Virtual Machine"
src="https://image.j-roque.com/posts/20250721-edge-deploy/deployvm.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is where the interesting part starts, the user is then prompted to connect and authenticate with CM.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="CM Connect"
src="https://image.j-roque.com/posts/20250721-edge-deploy/cmconnect.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;He will then register and enroll the cluster in a CM infrastructure. This means CM is now aware of this cluster.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Cluster Enrollment"
src="https://image.j-roque.com/posts/20250721-edge-deploy/enrollcluster.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It will then deploy an &lt;code&gt;infrastructure agent&lt;/code&gt;. This agent will allow the &lt;code&gt;CM Devops Center&lt;/code&gt; to not only be aware that the cluster exists, but also what is it&amp;rsquo;s current state and to allow, by user action to deploy additional components.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploy Agent"
src="https://image.j-roque.com/posts/20250721-edge-deploy/deployagent.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s see an example of an actual deployment on my machine.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250721-edge-deploy/deployvm.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;We have now an installed remote cluster. Let&amp;rsquo;s now deploy our AMC.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploy AMC"
src="https://image.j-roque.com/posts/20250721-edge-deploy/deployautomationmanagercontroller.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250721-edge-deploy/deployamc.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;h3 class="relative group"&gt;Use Case - Automation Manager Controller Deploy Automation Manager
&lt;div id="use-case---automation-manager-controller-deploy-automation-manager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---automation-manager-controller-deploy-automation-manager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The AMC will continually query the MES system for &lt;strong&gt;changes in the list of Automation Managers&lt;/strong&gt; to deploy.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC Query"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcquery.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;When the user checks the button to change a manager to ready to deploy. The AMC will detect that change and &lt;strong&gt;spin up a new Automation Manager&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC New Manager"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcnewmanager.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The AMC is now ready to deploy new managers or undeploy existing managers. The Automation Managers also have in &lt;strong&gt;built controls where they can have stop and start controls for all their internal components&lt;/strong&gt;, like controllers and drivers.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC New Manager Deployed"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amcmanagerdeployed.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Let&amp;rsquo;s see an example of this. I have two clusters, my &lt;code&gt;mes-summit-edge cluster&lt;/code&gt; in the cloud with the &lt;strong&gt;MES&lt;/strong&gt; installation and a &lt;code&gt;cluster on my machine&lt;/code&gt; with the &lt;strong&gt;AMC&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In my MES system I have an Automation Manager with a single controller with a single driver. What we can see is that we have deployed the AMC in the remote cluster and using just our MES we are able to &lt;code&gt;One Click Deploy&lt;/code&gt; our Automation Manager, with a TCP-IP driver and a controller.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250721-edge-deploy/AMC-DeploymentManager.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;We can also perform other actions like undeploy or mass deploy of several Automation Managers.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250721-edge-deploy/AMC-MassDeploy.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;h2 class="relative group"&gt;Update Process
&lt;div id="update-process" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#update-process" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This architecture makes updates very &lt;strong&gt;simple&lt;/strong&gt; and &lt;strong&gt;intuitive&lt;/strong&gt;. We can update the Automation Manager Controller and the Automation Managers in &lt;strong&gt;completely independent cycles&lt;/strong&gt; and at the pace we are comfortable with.&lt;/p&gt;
&lt;p&gt;In order to update the Automation Manager Controller we will only have to trigger a new deployment using the CM Devops Center.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Use Case - Update Process Automation Manager Controller
&lt;div id="use-case---update-process-automation-manager-controller" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---update-process-automation-manager-controller" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s see an example, we have an AMC installed for version &lt;strong&gt;11.1.3&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC v 11.1.3"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amc1113.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we want to perform an update to version &lt;strong&gt;11.1.5&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AMC v 11.1.5"
src="https://image.j-roque.com/posts/20250721-edge-deploy/amc1115.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250721-edge-deploy/updateamc.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;h2 class="relative group"&gt;Use Case - Update Process Automation Manager
&lt;div id="use-case---update-process-automation-manager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#use-case---update-process-automation-manager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For the Automation Manager we can undeploy, edit the manager version and trigger a new deploy. This will make the automation manager controller automatically deploy a new manager version.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="AM v 11.1.5"
src="https://image.j-roque.com/posts/20250721-edge-deploy/am1115.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We are now able to have most of the cloud benefits, also keeping our solution close to the shopfloor and staying easy to maintain and update.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250721-edge-deploy/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250721-edge-deploy/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part II - Scenario - Machine Learning for Defect Detection</title>
<link>https://j-roque.com/posts/20250709-ml-iot-ii/</link>
<pubDate>Wed, 09 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250709-ml-iot-ii/</guid>
<description>Showcasing a prediction scenario</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250709-ml-iot-ii/featured.png" alt="Part II - Scenario - Machine Learning for Defect Detection" /&gt;&lt;/p&gt;&lt;p&gt;For this use case we will have a two part blog post. The goal will be to showcase how we can use the machine learning module from Critical Manufacturing to predict material defects.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The full use case will be to train a machine learning module with data from a machine and correlate it with an MES material defect that happens in an inspection machine.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scenario"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/scenario.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We want to be able to predict defects and notify the employees that something was wrong.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scenario Final"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/scenario_final.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For this second part we will focus on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating and Training a Machine Learning model&lt;/li&gt;
&lt;li&gt;Simulator tool&lt;/li&gt;
&lt;li&gt;Making Predictions&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Creating and Training a Machine Learning Model
&lt;div id="creating-and-training-a-machine-learning-model" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-and-training-a-machine-learning-model" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have already a prepared dataset from the first blog post.&lt;/p&gt;
&lt;p&gt;In the MES UI we can now simply create a machine learning model, feed it the dataset and specify the type of model we want.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Creating an ML Model"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/creatingmlmodel.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our use case we want the model to classify if a certain set of machine will generate a defect. So we are using classification and our classifier is the feature classification which will be marked as label.&lt;/p&gt;
&lt;p&gt;We can then transform/normalize our data and train our model, specifying what percentages of our dataset will be used to train, validate and test. We can also specify what should our model optimize for, each one will impact how the model behaves.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Training our model"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/trainingmodel.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now our model is ready to predict!&lt;/p&gt;
&lt;h2 class="relative group"&gt;Simulator Tool
&lt;div id="simulator-tool" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#simulator-tool" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In order to create this scenario we created a simulator tool based on actual machine data. This tool will be an IPC-CFX simulator and will mimic all the events a Reflow Oven does in an SMT shopfloor. This tool will also prepare the MES scenario and will be able to also record defects.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Simulation"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/simulation.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In detail what will happen is that we will have a live instance of the MES system, a live instance of a Connect IoT Automation Manager and then the simulator. The simulator will call the MES API and also interface with the Automation Manager as if it was an IPC-CFX machine. In order to perform this we leverage the IoT Test Orchestrator which is a testing tool provided by Critical Manufacturing (more in-depth explanation &lt;a
href="https://j-roque.com/posts/20250516-testinglowcode/"
target="_blank"
&gt;here&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Simulation Detail"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/simulation-detail.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For this scenario we know that there is a defect type &lt;code&gt;Solder Balling&lt;/code&gt; that happens if the temperature of the PreHeat zones of the oven is too high. All simulator will then simulate an SMT line and every X amount of times it will simulate that a panel has been exposed to high temperatures in the PreHeat. Then when it reaches the optical inspection &lt;code&gt;AOI&lt;/code&gt; it will record a defect.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Making Predictions
&lt;div id="making-predictions" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-predictions" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s now change our workflow so as to use the &lt;code&gt;ML Prediction&lt;/code&gt; task to predict if we will have defects.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ML Prediction Workflow"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/mlpredictioniot.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We send our post telemetry event and then call the ML Prediction task. If the prediction is true, we will create a notification.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="ML Prediction"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/mlmodelprediction.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can later check the material to see if it did create a material defect.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Defect View"
src="https://image.j-roque.com/posts/20250709-ml-iot-ii/materialdefectview.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;So we can actually validate that our model prediction was correct.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Currently, there is a limitation where the task ML Prediction can only be executed by a automation manager running in the same stack as the MES.&lt;/span&gt;
&lt;/div&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This is a very simple scenario which is very delimited. We area only looking at temperatures and not to a whole host of other parameters. In a real life scenario we can use the real events of a shopfloor to either train our model or run it in unsupervised mode to perform predictions. In this example we have passive control, by just creating a notification, but we can perform actions like putting the material on hold.&lt;/p&gt;
&lt;p&gt;Hopefully this two part blog post gives you the tools so you can try by yourself, adjusting it to your real life use case.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250709-ml-iot-ii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250709-ml-iot-ii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part I - Data Ingestion - Machine Learning for Defect Detection</title>
<link>https://j-roque.com/posts/20250708-ml-iot-i/</link>
<pubDate>Tue, 08 Jul 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250708-ml-iot-i/</guid>
<description>Ingesting machine data and crossing it against defects</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250708-ml-iot-i/featured.png" alt="Part I - Data Ingestion - Machine Learning for Defect Detection" /&gt;&lt;/p&gt;&lt;p&gt;For this use case we will have a two part blog post. The goal will be to showcase how we can use the machine learning module from Critical Manufacturing to predict material defects.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The full use case will be to train a machine learning module with data from a machine and correlate it with an MES material defect that happens in an inspection machine.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scenario"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/scenario.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We want to be able to predict defects and notify the employees that something was wrong.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Scenario Final"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/scenario_final.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The first part of this use case will cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ingesting Data (Equipment Integration)&lt;/li&gt;
&lt;li&gt;Understanding Data (Canonical Data Model)&lt;/li&gt;
&lt;li&gt;Combining System Data with Machine Data&lt;/li&gt;
&lt;li&gt;Creating a Dataset&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 class="relative group"&gt;Ingesting Data
&lt;div id="ingesting-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ingesting-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The first challenge is being able to collect structured and contextualized data from our shopfloor.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Creating Information
&lt;div id="creating-information" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-information" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The shopfloor is a data rich environment. From alarms, operator actions, quality reports, to machine interfaces and machine logging. There is a lot of data being generated by the day to day operations of a production environment.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Data Gathering"
src="https://image.j-roque.com/posts/20250708-ml-iot/DataGathering.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The biggest challenge is not just to collect that information, but to collect it in a way that is able to embed enough context to the data, so as to be usable. The Manufacturing Execution System has a key role in being the &lt;code&gt;contextualizer&lt;/code&gt; of the data. It will infuse the data with all the context of the shopfloor. It will be able to give key information like where the data should be set in the &lt;a
href="https://www.isa.org/standards-and-publications/isa-standards/isa-95-standard"
target="_blank"
&gt;ISA95&lt;/a&gt; structure. Is it data from a Resource, Area, Site, Facility and other important aspects that will allow the data to become information.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Contextualizer Interpreter"
src="https://image.j-roque.com/posts/20250708-ml-iot/ContextualizerInterpreter.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The Critical Manufacturing MES provides an integration application that will be able to bridge the gap between the MES and the shopfloor data producers. These &lt;code&gt;interpreters&lt;/code&gt; will handle all the interfacing with the outside world and then will feed the data into the MES system.&lt;/p&gt;
&lt;p&gt;Reality is messy. We need applications that are able to be near the real world and are able to translate reality and then we have a common system that is able to embed context to that reality.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Equipment Integration - Overview
&lt;div id="equipment-integration---overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#equipment-integration---overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We will use Critical Manufacturing Connect IoT module to connect to our equipment. For our use case we will use an &lt;code&gt;SMT Reflow Oven&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;SMT or surface mount technologies is a very interesting use case in the equipment integration world as it uses a strong typed AMQP based open standard IPC-CFX. It allows for both brokered communication and peer to peer communication for control actions.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="IPC-CFX"
src="https://image.j-roque.com/posts/20250708-ml-iot/usecaserabbit.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For our use case we will leverage the brokered communication to ingest IPC-CFX events generated throughout the Reflow Oven production lifecycle. The protocol itself already defines what are the events that highlight the process stage.&lt;/p&gt;
&lt;p&gt;For this use case we will use the &lt;code&gt;Work Started&lt;/code&gt; event to signal the MES to change the material to be in process, &lt;code&gt;Units Processed&lt;/code&gt; an event at the end of an oven run that reports the state of the oven throughout the material process cycle and finally the &lt;code&gt;Work Completed&lt;/code&gt; which will change the material to be processed and signal that it has finished the Reflow Oven process.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Equipment Integration - Work Started / Work Completed CFX
&lt;div id="equipment-integration---work-started--work-completed-cfx" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#equipment-integration---work-started--work-completed-cfx" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;For all the MES the start and end process are key aspects of any integration.&lt;/p&gt;
&lt;p&gt;They allow for the process to be tracked from beginning to end.&lt;/p&gt;
&lt;p&gt;The IPC-CFX foundation provides an in depth documentation for each of these events.&lt;/p&gt;
&lt;p&gt;For &lt;a
href="https://www.connectedfactoryexchange.com/CFXDemo/sdk/html/T_CFX_Production_WorkStarted.htm"
target="_blank"
&gt;Work Started&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Work Started"
src="https://image.j-roque.com/posts/20250708-ml-iot/WorkStarted.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In this event we will have a &lt;code&gt;TransactionID&lt;/code&gt; that will follow all the events emitted in the lifecycle of the material. It will also inform as to what is the material that is starting the process and other relevant information about the material.&lt;/p&gt;
&lt;p&gt;For &lt;a
href="https://www.connectedfactoryexchange.com/CFXDemo/sdk/html/T_CFX_Production_WorkCompleted.htm"
target="_blank"
&gt;Work Completed&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Work Completed"
src="https://image.j-roque.com/posts/20250708-ml-iot/WorkCompleted.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Work Complete&lt;/code&gt; will provide a process result and signal the end of the process.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Equipment Integration - Work Started / Work Completed Connect IoT
&lt;div id="equipment-integration---work-started--work-completed-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#equipment-integration---work-started--work-completed-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;For Connect IoT this is a very simple integration.&lt;/p&gt;
&lt;p&gt;For Work Started we will have an Equipment Event task, we will store the transaction id context information, retrieve the Resource linked with this integration and call the MES service &lt;code&gt;ComplexTrackInMaterial&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Work Started"
src="https://image.j-roque.com/posts/20250708-ml-iot/WorkStartedIoT.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For Work Completed it is very similar we just do some housekeeping by cleaning the transaction information and calling the MES service &lt;code&gt;TrackOuT&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Work Completed"
src="https://image.j-roque.com/posts/20250708-ml-iot/WorkCompletedIoT.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;As you can see with Connect IoT the user has a much simpler and ready to use interface to the protocol.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Equipment Integration - Units Processed CFX
&lt;div id="equipment-integration---units-processed-cfx" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#equipment-integration---units-processed-cfx" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The Units Processed event is the event that will provide all the context information from the material process that we want to store. We will store that information using CM IoT Data Platform. The Data Platform is a platform for large scale data ingestion.&lt;/p&gt;
&lt;p&gt;Out of the box for equipment integration, CM already provides two IoT event definitions tailored for machine integration: &lt;code&gt;PostTelemetry&lt;/code&gt; and &lt;code&gt;PostMeasurement&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PostTelemetry&lt;/code&gt;: Used for transmitting time-series and continuous data such as temperature, pressure, and operational status.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PostMeasurement&lt;/code&gt;: Used for transmitting data from tests, metrology processes, and inspections.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can create your own IoT Event Definitions as well.&lt;/p&gt;
&lt;p&gt;For our example, we will parse the data provided by the Units Processed event and Post it as a set of telemetry data.&lt;/p&gt;
&lt;p&gt;For &lt;a
href="https://www.connectedfactoryexchange.com/CFXDemo/sdk/html/T_CFX_Production_Processing_UnitsProcessed.htm"
target="_blank"
&gt;Units Processed&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Units Processed"
src="https://image.j-roque.com/posts/20250708-ml-iot/UnitsProcessed.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Equipment Integration - Units Processed Connect IoT
&lt;div id="equipment-integration---units-processed-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#equipment-integration---units-processed-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In Connect IoT we will have a hook for this event, retrieve some context information and call a Post Oven task. The system by default provides a &lt;code&gt;Post Event&lt;/code&gt; task, for my use case it was useful to create my custom version where I do all the message parsing and posting.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Units Processed"
src="https://image.j-roque.com/posts/20250708-ml-iot/UnitsProcessedIoT.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Understanding Data
&lt;div id="understanding-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#understanding-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With this setup we are able to run and perform material tracking and post telemetry events, but what about the rest of the MES system.&lt;/p&gt;
&lt;p&gt;By design the MES system is a transactional system. It guarantees that all the actions are successful before committing. This makes a system highly robust and consistent, but by nature of the design, it is slower than event based strategies as it has to wait for all actions before committing.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Event Based MES
&lt;div id="event-based-mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#event-based-mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In CM MES we have the best of both worlds. We have both the strict transactionality of what a real time control system must have, but we also have events being generated. Everything that happens in the shopfloor are a set of events that are occurring. From changes to a material &lt;code&gt;Material Movements&lt;/code&gt;, to changes to a particular resource &lt;code&gt;ResourceStateChange&lt;/code&gt;, actions in the MES will trigger a light weight contextualized event. These events can then be consumed, aggregated and turned into any kind of shopfloor analytics.&lt;/p&gt;
&lt;p&gt;The CDM, canonical data model, are a set of events that are being generated automatically from the MES. They are canonical in the sense that they are a known gold standard of all the data and metadata context that an event generates at each MES action (&lt;a
href="https://developer.criticalmanufacturing.com/11.1/reference/api-data-platform/index.html#interacting-with-cdm-kafka-topics"
target="_blank"
&gt;API Definition&lt;/a&gt;). These events already provide some enrichment to provide a more complete snapshot. They also come with context of the current and previous event, this allows easy historicity comparison, tracing and comparison. The events are then stored in a ClickHouse database. For more information on what are the strengths of ClickHouse you can read &lt;a
href="https://devblog.criticalmanufacturing.com/blog/20250522_clickhouse_migration_part1/"
target="_blank"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All of this allows us to simplify and make analytics a much faster endeavour as it mitigates the need for the user to have a deep understanding of the data model and perform table correlation for data gathering, as the relevant data has already been infused into the event.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Resource CDM"
src="https://image.j-roque.com/posts/20250708-ml-iot/resourcecdm.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The user can also generate his own events and then create his own hooks to populate those events. He can also run &lt;code&gt;Low Code&lt;/code&gt; logic, whenever an event occurs. This can be helpful to perform data enrichment, data replication or forwarding or event to trigger some MES logic.&lt;/p&gt;
&lt;p&gt;The best presentation done on this whole topic can be found here:&lt;/p&gt;
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/vO9LY5fcFS8?autoplay=0&amp;amp;controls=1&amp;amp;end=0&amp;amp;loop=0&amp;amp;mute=0&amp;amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;h2 class="relative group"&gt;Combining System Data with Machine Data
&lt;div id="combining-system-data-with-machine-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#combining-system-data-with-machine-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this scenario we are posting machine data through the Connect IoT integration and also generating the CDM events simply by performing the MES material tracking and recording a defect.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Events"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/events.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Our goal is then to create a dataset for the Machine Learning Model to be able to train. We will want to cross reference the machine data against the material defects created.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Post Telemetry
&lt;div id="post-telemetry" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#post-telemetry" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Our machine data is being posted as a telemetry event. The telemetry event is oriented as to have each post as a measurement of a sensor. Our Reflow Oven is a bit more complex. It is split in different zones and each zone has top/bottom/whole zone. It also is able to capture temperatures and other telemetry data.&lt;/p&gt;
&lt;p&gt;For the Machine Learning model training dataset we want to have columns with unique context and with results. So we will aggregate the data into a structure that will make sense for our model.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;-- Pivot Columns so as to have row per material with colum value structure
SELECT
    Material_Name,
    max(Enterprise_Name) as Enterprise_Name,
    max(Site_Name) as Site_Name,
    max(Facility_Name) as Facility_Name,
    max(Area_Name) as Area_Name,
    max(Resource_Name) as Resource_Name,
    max(_created_at) as created_at,
    maxIf(value, column_name = &amp;#39;PreHeat_Zone1_WholeZone_Temperature&amp;#39;) AS PreHeat_Zone1_WholeZone_Temperature,
    (...)
FROM (
    -- Group By Material and Measurement Column Name Post Telemetry Reading Values
    SELECT
        Material_Name,    
        max(Enterprise_Name) as Enterprise_Name,
        max(Site_Name) as Site_Name,
        max(Facility_Name) as Facility_Name,
        max(Area_Name) as Area_Name,
        max(Resource_Name) as Resource_Name,
        max(_created_at) as _created_at,
        concat(ReflowZoneType, &amp;#39;_&amp;#39;, StageName, &amp;#39;_&amp;#39;, Reading_SubZone, &amp;#39;_&amp;#39;, Reading_ReadingType) as column_name,
        max(ReadingValue) as value
    FROM (
        -- Aggregate Post Telemetry Reading Values
        SELECT
            Material_Name,
            Enterprise_Name,
            Site_Name,
            Facility_Name,
            Area_Name,
            Resource_Name,
            _created_at,
            Tags_Value[indexOf(Tags_Key, &amp;#39;ReflowZoneType&amp;#39;)] ReflowZoneType,
            Tags_Value[indexOf(Tags_Key, &amp;#39;StageName&amp;#39;)] StageName,
            Tags_Value[indexOf(Tags_Key, &amp;#39;StageType&amp;#39;)] StageType,
            Tags_Value[indexOf(Tags_Key, &amp;#39;Reading.SubZone&amp;#39;)] Reading_SubZone,
            Tags_Value[indexOf(Tags_Key, &amp;#39;Reading.ReadingType&amp;#39;)] Reading_ReadingType,
            if(Parameter_Name = &amp;#39;ReadingValue&amp;#39;, arrayReduce(&amp;#39;avg&amp;#39;, Parameter_NumericValues), null) as ReadingValue
        FROM  PostTelemetry
        ARRAY JOIN
            Parameters_Class AS Parameter_Class,
            Parameters_Name AS Parameter_Name,
            Parameters_NumericValues AS Parameter_NumericValues
        WHERE Parameter_Name = &amp;#39;ReadingValue&amp;#39; 
    )
    WHERE ReadingValue IS NOT NULL AND Material_Name is not null
    GROUP BY Material_Name, column_name
)
GROUP BY Material_Name&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Aggregation Post Telemetry"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/aggregationposttelemetry.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we have a row per material and the row has a column for each zone with the collected temperature value.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Material Defect
&lt;div id="material-defect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#material-defect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We can now cross the post telemetry data with our material defects.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;Select  
if(Defect_Reason is null or Defect_Reason = &amp;#39;&amp;#39;, false, true) as Classification,
Material_Name,
created_at,
PreHeat_Zone1_WholeZone_Temperature,
PreHeat_Zone1_Bottom_Temperature,
PreHeat_Zone1_Top_Temperature,
(...)
Enterprise_Name,
Site_Name,
Facility_Name,
Area_Name,
Resource_Name
from (
(...)
) 
 as Post
LEFT JOIN 
     Material_MaterialDefects as Defs ON Post.Material_Name = Defs.Material_Name AND Defs.Header_Operation = &amp;#39;Create&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we have a query that is able to return all the machine data aggregated by material name and pivoted in context columns.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating a Dataset
&lt;div id="creating-a-dataset" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-dataset" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In the MES, we can now create a dataset with this information.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Creating a Dataset"
src="https://image.j-roque.com/posts/20250708-ml-iot/dataset.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This dataset can now be used for reporting our dashboard creation, but fundamentally for us, it can be used to train our Machine Learning Model.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For this first post our goal was data ingestion and processing. We have collected data with Connect IoT and we have leveraged data that is generated by the MES to understand the relation between machine data. In the next post we will focus on creating our machine learning model and seeing it make predictions.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250708-ml-iot-i/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250708-ml-iot-i/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part III - SQLite Use Case</title>
<link>https://j-roque.com/posts/20250627-sqlite-iii/</link>
<pubDate>Fri, 27 Jun 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250627-sqlite-iii/</guid>
<description>Creating a full on implementation</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250627-sqlite-iii/featured.png" alt="Part III - SQLite Use Case" /&gt;&lt;/p&gt;&lt;p&gt;This blog post will be part of a series of blog posts where we show case an example of how we can use a third party dependency like &lt;code&gt;SQLite&lt;/code&gt; in our Connect IoT code. In this last post we will go through the whole implementation, so as to give an overview on a possible implementation of SQLite in Connect IoT.&lt;/p&gt;
&lt;p&gt;All code showed here can be found at &lt;a
href="https://github.com/jrk94/cm-demo-repos"
target="_blank"
&gt;demo repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this blog post we will show how you can store and retrieve data from sqlite and also do your own queries.&lt;/p&gt;
&lt;p&gt;I will be using the vscode extension &lt;a
href="https://marketplace.visualstudio.com/items?itemName=alexcvzz.vscode-sqlite"
target="_blank"
&gt;vscode-sqlite&lt;/a&gt; which allows me to have an extension in vscode where I can connect to the SQLite database and perform queries.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Ingesting Data
&lt;div id="ingesting-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ingesting-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at a specific use case. I have an assorted set of json files where I want to process and insert into my SQLite database.&lt;/p&gt;
&lt;p&gt;We will use the File Raw Protocol and will process all new files.&lt;/p&gt;
&lt;p&gt;Each json document will be inserted into my SQLite database. Right now I am not doing any kind of parsing I am just storing all the json files into a table called SMT.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Storing Data"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/storingdatabase.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Our Workflow is reacting to new files detected in a folder. For each of them it is reading the contents of the files and storing them using the &lt;code&gt;Store SQLite&lt;/code&gt; task.&lt;/p&gt;
&lt;p&gt;With this approach we can already perform queries but they tend to be very cumbersome as they will have to deal with schemaless json documents.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Looking at the Store SQLite Task
&lt;div id="looking-at-the-store-sqlite-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#looking-at-the-store-sqlite-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The store SQLite Task will receive json documents, extract information like the give time to live, the table name and the table id and finally store it in the SQLite database.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        // It is advised to reset the activate to allow being reactivated without the value being different
        this.activate = undefined;
        const stmtValues = new Map&amp;lt;string, any[]&amp;gt;();
try {
            this._logger.debug(&amp;#34;Starting Execution of Store SQLite task&amp;#34;);
            for (const storeElement of this.inputs) {
                // If there is an input with data to store
                if (this[storeElement.name] != null) {
                    // Try to retrieve the id for the row if there is an id defined for this store element
                    const documentId = JSON.parse(this[storeElement.name] ?? {})?.[storeElement.idElement];
                    const data = this[storeElement.name];
                    // Resolve the table name for the row
                    const tableName = storeElement.tableName != null &amp;amp;&amp;amp; storeElement.tableName !== &amp;#34;&amp;#34; ? storeElement.tableName : storeElement.name;
// Insert in SQLite database
                    const output = this._sqliteManager.insert(documentId, tableName, data, storeElement?.ttl);
// Collect values to provide feedback
                    stmtValues.set(output.id, output.result);
                }
            }
this.results.emit(stmtValues);
            this.success.emit(true);
            this._logger.info(&amp;#34;Finished Executing Store SQLite task&amp;#34;);
        } catch (e) {
            this.logAndEmitError(e.message);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see the code is quite simple, we check if there&amp;rsquo;s any input with data to store and if it has we resolve some important metadata. In the SQLiteManager class is where we will handle the actual insert into the SQLite database.&lt;/p&gt;
&lt;p&gt;As we saw in part II the SQLiteManager is being injected in the dependency injection container.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Looking at the Insert SQLiteManager
&lt;div id="looking-at-the-insert-sqlitemanager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#looking-at-the-insert-sqlitemanager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In SQLiteManager class, we will call the insert method. This method will be responsible for dealing with inserts.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public insert(id: string, documentType: string, data: object, ttlSeconds?: number) {
// Ensure that the SQLite has been instantiated
    const tableName = this.guardClause(documentType);
// If needed generate an Id
    if (id == null || id === &amp;#34;&amp;#34;) {
        id = this.newGuid();
    }
// Check if data already exists
    const checkStmt = this._db.prepare(`
        SELECT id FROM ${tableName} WHERE json(data) = json(?)
    `);
    const existingRow = checkStmt.get(JSON.stringify(data));
// if the row already exists, we are going to keep the data and update the Time To Live
    if (existingRow) {
        // Update existing row with new TTL
        const updateStmt = this._db.prepare(`
            UPDATE ${tableName} 
            SET updated_at = CURRENT_TIMESTAMP,
                expires_at = CASE 
                    WHEN ? IS NOT NULL THEN datetime(CURRENT_TIMESTAMP, &amp;#39;&amp;#43;&amp;#39; || ? || &amp;#39; seconds&amp;#39;)
                    ELSE NULL 
                END
            WHERE id = ?
        `);
updateStmt.run(ttlSeconds, ttlSeconds, existingRow.id);
return { id: existingRow.id, result: { changes: 1 }, existed: true };
    }
// Insert new row with calculated expires_at
    const stmt = this._db.prepare(`
        INSERT INTO ${tableName} (id, data, created_at, updated_at, expires_at) 
        VALUES (?, json(json_extract(?, &amp;#39;$&amp;#39;)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 
                CASE 
                    WHEN ? IS NOT NULL THEN datetime(CURRENT_TIMESTAMP, &amp;#39;&amp;#43;&amp;#39; || ? || &amp;#39; seconds&amp;#39;)
                    ELSE NULL 
                END)
    `);
return {
        id,
        result: stmt.run(id, JSON.stringify(data), ttlSeconds, ttlSeconds),
        existed: false
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The insert will validate that we don&amp;rsquo;t try to store data with the same ids and will manage helpers like the time to live of our stored data. The controller is always running so it&amp;rsquo;s helpful to implement mechanisms to handle stale data and to stop our database from growing forever. Notice that we can already see some functions that we use in SQLite to manipulate JSON columns like the &lt;code&gt;json_extract()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;With this we can already see the big advantage of separating the SQLite Manager from the tasks. We have a complete separation of concerns, where the task does not need to know the SQLite language. In fact if we were to support other databases, the task store SQLite would remain the same, we could just import the DI Container for any other database. We also are managing the query logic only one location in the code, which is much easier to maintain, troubleshoot and improve.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Store SQLite"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/storesqlite.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Ingesting Data with Schema Enforcement
&lt;div id="ingesting-data-with-schema-enforcement" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ingesting-data-with-schema-enforcement" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A better approach is to provide at least a partial schema and storing each document type in different tables.&lt;/p&gt;
&lt;p&gt;We will have the same approach of reading a set of json files. But now after reading we will provide some schema enforcement, each one will then be matched against an expected schema.&lt;/p&gt;
&lt;p&gt;For example, for &lt;code&gt;LineRecipeSetup&lt;/code&gt; we are expecting the schema:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;,
  &amp;#34;required&amp;#34;: [
    &amp;#34;LineFullPath&amp;#34;,
    &amp;#34;RecipeFullPath&amp;#34;,
    &amp;#34;SetupFullPath&amp;#34;
  ],
  &amp;#34;properties&amp;#34;: {
    &amp;#34;LineFullPath&amp;#34;: {
      &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;
    },
    &amp;#34;RecipeFullPath&amp;#34;: {
      &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;
    },
    &amp;#34;SetupFullPath&amp;#34;: {
      &amp;#34;type&amp;#34;: &amp;#34;string&amp;#34;
    },
    &amp;#34;RecipeDetails&amp;#34;: {
      &amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;
    },
    &amp;#34;SetupDetails&amp;#34;: {
      &amp;#34;type&amp;#34;: &amp;#34;object&amp;#34;
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When we now store we will store only json objects that obey the schema structure. We will store them in the table &lt;code&gt;LineRecipeSetup&lt;/code&gt;. We can also specify meaningful indexes, for example for this json document the recipe full path will be a table index.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Multiple Table Stores"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/storingdatabase.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we are storing data into multiple tables based on schema. I am also storing all the read files in a Raw Data table.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Looking at the JSON Schema Validator Task
&lt;div id="looking-at-the-json-schema-validator-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#looking-at-the-json-schema-validator-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;For the JSON schema task we will use &lt;a
href="https://github.com/ajv-validator/ajv"
target="_blank"
&gt;ajv&lt;/a&gt; as our dependency for schema enforcement.&lt;/p&gt;
&lt;p&gt;Ajv is a very simple but powerful tool, it allows us to provide it with a schema and then validate json objects.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        this.activate = undefined;
let hasErrors = false;
        try {
            // iterate defined schemas
            for (const schema of this.schemas) {
                // If there&amp;#39;s data for this schema
                const jsonData = this[schema.name];
                if (jsonData != null) {
                    // Validate against defined schema
                    const validate = this.ajv.compile(schema.schema);
                    if (validate(JSON.parse(jsonData))) {
                        (this[Utilities.propertyToOutput(schema.name)] as Task.Output&amp;lt;object&amp;gt;).emit(this[schema.name]);
                    } else 
                        // Check if we should throw an error when schema is not matched
                        if (this.throwOnSchemaValidationFailure) {
                        hasErrors = true;
                        this.logAndEmitError(`Error when validating schema for ${validate.errors}: `);
                    }
                }
            }
if (!hasErrors) {
                this.success.emit(true);
            }
        } catch (e) {
            this.logAndEmitError(e.message);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will iterate through our defined schemas and check if any of the schemas is validated by the data, for each one if the schema matches we will emit the correspondent output.&lt;/p&gt;
&lt;p&gt;If the setting &lt;code&gt;throwOnSchemaValidationFailure&lt;/code&gt; is set as true we will throw an exception otherwise, we will continue.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="JSON Schema Validator Success"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/jsonschema.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;If the json matches the provided schema for the input it will output the json data.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="JSON Schema Validator Error"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/jsonschemafailure.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;On the other hand if the match of the schema fails for the input. It won&amp;rsquo;t output the json data and can throw an error if the &lt;code&gt;throwOnSchemaValidationFailure&lt;/code&gt; is set as true.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="JSON Schema Validator Conditional"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/jsonschemaconditional.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;What we can do as well is use our JSON Validator as a schema matcher. If one of the schemas is validated we will emit that output. For our use case where we are processing a batch of json files for which we don&amp;rsquo;t know beforehand which schema they will match, we can leverage this feature.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Querying Data
&lt;div id="querying-data" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#querying-data" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s start with the simplest task. We will create a task that allows us to perform raw queries to our SQLite database.&lt;/p&gt;
&lt;p&gt;In this example I am doing a query to check if there is a record with a &lt;code&gt;RecipeFullPath&lt;/code&gt; of &amp;lsquo;Line7\80627_758_0005\81712_990_0009&amp;rsquo; and a &lt;code&gt;SetupFullPath&lt;/code&gt; of &amp;lsquo;Line7\80627_758_0005&amp;rsquo;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT *
FROM LineRecipeSetup
WHERE json_extract(data, &amp;#39;$.RecipeFullPath&amp;#39;) = &amp;#39;Line7\80627_758_0005\81712_990_0009&amp;#39;
  AND json_extract(data, &amp;#39;$.SetupFullPath&amp;#39;) = &amp;#39;Line7\80627_758_0005&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now try the query both on the SQLite Client and in the Connect IoT task. We will add the query in the Query SQLite task and log the outcome. The outcome is a large payload with the json document.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Raw Query"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/rawquery.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;I can have the values in the query, or use the &lt;code&gt;?&lt;/code&gt; and pass the values as params.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT *
FROM LineRecipeSetup
WHERE json_extract(data, &amp;#39;$.RecipeFullPath&amp;#39;) = &amp;#39;?&amp;#39;
  AND json_extract(data, &amp;#39;$.SetupFullPath&amp;#39;) = &amp;#39;?&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we just need to change our Connect IoT task to have parameters. When we execute the task we can see that it is able to execute and returns the corresponding JSON document.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Raw Query with Question Marks"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/rawquerywithquestionmarks.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        // It is advised to reset the activate to allow being reactivated without the value being different
        this.activate = undefined;
try {
this._logger.info(&amp;#34;Starting Execution of Query SQLite task&amp;#34;);
            const queryResult = this._sqliteManager.rawQuery(this.query, this.params);
            this.result.emit(queryResult);
            this.success.emit(true);
this._logger.info(&amp;#34;Finished Executing Query SQLite task&amp;#34;);
        } catch (e) {
            this.logAndEmitError(e.message);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice how by abstracting the SQLite layer, this makes our task very simple. The Query SQLite task is just an entrypoint to the SQLite Manager. It receives the query and params and will call the &lt;code&gt;rawQuery&lt;/code&gt; method.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public rawQuery(query: string, params: string[]) {
    if (this._db == null) {
        this.startSQLLite();
    }
const stmt = this._db.prepare(query, params);
    const rows = stmt.all(...params);
return rows;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is also quite simple in the SQLite Manager. We are just preparing a statement to be executed.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Raw Query Diagram"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/rawquery.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Simplifying Data Querying
&lt;div id="simplifying-data-querying" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#simplifying-data-querying" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;One of the advantages of using tasks and making them available in low code is the possibility of enabling and simplifying the user experience.&lt;/p&gt;
&lt;p&gt;We can now create a set of helpful tasks to allow for a simplified query mechanism.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Querying by JSON Path
&lt;div id="querying-by-json-path" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#querying-by-json-path" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s create a task that allows us to query by JSON path. We could use the query SQLite task and write, for example:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT *
FROM LineRecipeSetup
WHERE json_extract(data, &amp;#39;$.RecipeFullPath&amp;#39;) = &amp;#39;?&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This requires the user some knowledge of how to use SQL languages and the particularities of JSON querying in SQLite. An alternative is building a simple task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="QueryByPath"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/querysqlitebypath.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;With this task the user does not need to know the intricacies of SQL or the particularities of SQLite.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        // It is advised to reset the activate to allow being reactivated without the value being different
        this.activate = undefined;
try {
this._logger.debug(&amp;#34;Starting Execution of Query by Path SQLite task&amp;#34;);
            const queryResult = this._sqliteManager.queryByPath(this.tableName, this.jsonPath, this.value);
            this.result.emit(queryResult);
            this.success.emit(true);
this._logger.info(&amp;#34;Finished Executing Query by Path SQLite task&amp;#34;);
        } catch (e) {
            this.logAndEmitError(e.message);
        }
}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SQLite Manager is the one concerned with the SQL code execution.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public queryByPath(documentType: string, jsonPath: string, value: string) {
    try {
        const tableName = this.guardClause(documentType);
const query = `
            SELECT id, json_extract(data, &amp;#39;$&amp;#39;) as data, created_at, updated_at
            FROM ${tableName} 
            WHERE json_extract(data, ?) = ?
        `;
        const params = [&amp;#34;$.&amp;#34; &amp;#43; jsonPath, value];
const stmt = this._db.prepare(query);
        const rows = stmt.all(...params);
        return rows.map((row: { data: string; }) =&amp;gt; ({
            ...row,
            data: JSON.parse(row.data)
        }));
    } catch (error) {
        this._logger.error(&amp;#34;Error querying by path:&amp;#34;, error);
        return [];
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class="relative group"&gt;Creating a Smarter Querying Mechanism
&lt;div id="creating-a-smarter-querying-mechanism" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-smarter-querying-mechanism" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;We can create an even simpler approach to query building.&lt;/p&gt;
&lt;p&gt;For example, our original query:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;SELECT *
FROM LineRecipeSetup
WHERE json_extract(data, &amp;#39;$.RecipeFullPath&amp;#39;) = &amp;#39;Line7\80627_758_0005\81712_990_0009&amp;#39;
  AND json_extract(data, &amp;#39;$.SetupFullPath&amp;#39;) = &amp;#39;Line7\80627_758_0005&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Can know be constructed by configuration with the task flexible query SQLite.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Flexible Query"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/flexiblequery.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We created a simple structure that can be constructed as an input or as a setting.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;[
  {
    &amp;#34;operator&amp;#34;: &amp;#34;equals&amp;#34;,
    &amp;#34;path&amp;#34;: &amp;#34;RecipeFullPath&amp;#34;,
    &amp;#34;value&amp;#34;: &amp;#34;Line7\\80627_758_0005\\81712_990_0009&amp;#34;
  },
  {
    &amp;#34;operator&amp;#34;: &amp;#34;equals&amp;#34;,
    &amp;#34;path&amp;#34;: &amp;#34;SetupFullPath&amp;#34;,
    &amp;#34;value&amp;#34;: &amp;#34;Line7\\80627_758_0005&amp;#34;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We provide an operator &amp;rsquo;like&amp;rsquo;, &amp;rsquo;equals&amp;rsquo;, &amp;lsquo;greater_than&amp;rsquo;, &amp;rsquo;less_than&amp;rsquo;, &amp;lsquo;contains&amp;rsquo; a path in the json document and a value to match. We can create an array of operator and the in the SQLiteManager create a very simple query builder.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; {
    if (changes[&amp;#34;activate&amp;#34;]) {
        // It is advised to reset the activate to allow being reactivated without the value being different
        this.activate = undefined;
try {
this._logger.info(&amp;#34;Starting Execution of Flexible Query SQLite task&amp;#34;);
            const queryResult = this._sqliteManager.flexibleSearch(this.tableName, this.criteria);
            this.result.emit(queryResult);
            this.success.emit(true);
this._logger.info(&amp;#34;Finished Executing Flexible Query SQLite task&amp;#34;);
        } catch (e) {
            this.logAndEmitError(e.message);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the SQLite Manager class we can create our flexible search method.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;public flexibleSearch(documentType: string,
    criteria: {
        jsonFilters: { operator: string; path: string; value: any }[];
        limit: any;
    }) {
    try {
const tableName = this.guardClause(documentType);
let query = `
            SELECT id, json_extract(data, &amp;#39;$&amp;#39;) as data,
                created_at, updated_at
            FROM ${tableName} WHERE 1=1
        `;
        const params = [];
// Add JSON path filters
        if (criteria.jsonFilters) {
            criteria.jsonFilters.forEach(filter =&amp;gt; {
                switch (filter.operator) {
                    case &amp;#34;equals&amp;#34;:
                        query &amp;#43;= ` AND json_extract(data, ?) = ?`;
                        params.push(filter.path, filter.value);
                        break;
                    case &amp;#34;greater_than&amp;#34;:
                        query &amp;#43;= ` AND CAST(json_extract(data, ?) AS REAL) &amp;gt; ?`;
                        params.push(filter.path, filter.value);
                        break;
                    case &amp;#34;less_than&amp;#34;:
                        query &amp;#43;= ` AND CAST(json_extract(data, ?) AS REAL) &amp;lt; ?`;
                        params.push(filter.path, filter.value);
                        break;
                    case &amp;#34;contains&amp;#34;:
                        query &amp;#43;= ` AND json_extract(data, ?) LIKE ?`;
                        params.push(filter.path, `%${filter.value}%`);
                        break;
                }
            });
        }
// Add ordering and limit
        query &amp;#43;= ` ORDER BY created_at DESC`;
        if (criteria.limit) {
            query &amp;#43;= ` LIMIT ?`;
            params.push(criteria.limit);
        }
const stmt = this._db.prepare(query);
        const rows = stmt.all(...params);
        return rows.map(row =&amp;gt; ({
            ...row,
            data: JSON.parse(row.data)
        }));
    } catch (error) {
        this._logger.error(&amp;#34;Error in flexible search:&amp;#34;, error);
        return [];
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These types of helpers allow for the developer to create a very simple experience for all other consumers of his task. The user no longer needs to be an expert in creating SQL queries. He can leverage a simple task to create his implementations.&lt;/p&gt;
&lt;p&gt;Of course for more advanced users they can still use the raw query where they fully control what is being used.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Unstructured Querying
&lt;div id="unstructured-querying" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#unstructured-querying" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Notice that these tasks also work if you choose to go with a one table approach to hold all your json documents. Currently, we are storing all json documents in a rawdata table and we can use the same query we&amp;rsquo;ve been using to query that table.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Schemaless Querying"
src="https://image.j-roque.com/posts/20250625-sqlite-iii/schemalessquery.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This is also a valid approach, of course you will have the downsides of being slower, you can be storing inconsistent data and that table may grow larger in a hard to control way.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have reached the end of our use case. Hopefully this use case was an interesting showcase of what you can do with Connect IoT. How you can leverage NPM packages and how you can extend what Critical Manufacturing provides out of the box.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250627-sqlite-iii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250627-sqlite-iii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part II - Sharing Injected Components in Connect IoT</title>
<link>https://j-roque.com/posts/20250625-sqlite-ii/</link>
<pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250625-sqlite-ii/</guid>
<description>How to create code that is reused throughout the lifecycle</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250625-sqlite-ii/featured.png" alt="Part II - Sharing Injected Components in Connect IoT" /&gt;&lt;/p&gt;&lt;p&gt;This blog post will be part of a series of blog posts where we show case an example of how we can use a third party dependency like &lt;code&gt;SQLite&lt;/code&gt; in our Connect IoT code. For this second blog post the goal is to show how iot components are injected and how we can have components that are long lived throughout the controller lifecycle. This is important for our SQLite example as we want just one instance of sqlite to be instantiated and then reused by all tasks. The part II assumes you have read and understood &lt;a
href="https://j-roque.com/posts/20250617-sqlite-i/"
target="_blank"
&gt;part I&lt;/a&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In this blog post we will start by understanding how tasks and converters are injected and then we will follow up with how we can inject our own components.&lt;/p&gt;
&lt;p&gt;For implementations like in the case of SQLite, we want to avoid having each task creating it&amp;rsquo;s own class responsible for creating a connection the database and performing actions. What is ideal is to centralize the connection to the database and then reuse the same singleton to centralize all database actions. This makes working with the database much easier, efficient and predictable.&lt;/p&gt;
&lt;p&gt;This is even more important as tasks are somewhat ephemeral in nature (not completely as they can instantiate things like settings and other actions in the onInit() hook). It is a good practice to keep tasks ephemeral and for them to not have long lived behaviors, like for example keeping a database connection open or storing information that leaves beyond one activation. A task is an atomic action that should not need to know what happened before or after, for storing contextual information we can use things like the persistency singleton or even our new SQLite implementation. The task will then delegate the context to a persistency layer and only concern itself with applying logic.&lt;/p&gt;
&lt;p&gt;For some use cases like sub-workflows, standalone workflows or control flow tasks will be completely ephemeral and will only be created when they are activated or when the workflow plan is calculated.&lt;/p&gt;
&lt;p&gt;Therefore it is important to have a way to share objects across the whole controller context. In Connect IoT we support the injection of classes in the context of a task, a workflow plan or across the whole controller.&lt;/p&gt;
&lt;p&gt;Notice that this is not new, when you are handling things like the logger, the datastore (to handle the persistency layer) or the system, what you are actually doing is manipulating containers that were already made available in the dependency injection container by the default implementation. What we are going to show here is how we can create our own custom components to inject in the context.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Entrypoint
&lt;div id="entrypoint" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#entrypoint" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;When you perform the &lt;code&gt;cmf new iot task&lt;/code&gt; command one of the elements of the scaffolding generated is the &lt;code&gt;index.ts&lt;/code&gt; file. Each task or converter that is created with the scaffolding command &lt;code&gt;cmf new iot task&lt;/code&gt; / &lt;code&gt;cmf new iot converter&lt;/code&gt; will automatically add an entry into the index.ts file.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Index TS"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/indexts.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;When the controller process starts it will load all the tasks and converters declared in the index.ts file. It will look for the class with attribute &lt;code&gt;@Task.Task()&lt;/code&gt; or &lt;code&gt;@Converter.Converter()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Entrypoint"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/entrypoint.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In the controller start cycle it will import all the custom packages and their components.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Controller Start Cycle"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/controllerstartcycle_darkmode.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating Shareable Code
&lt;div id="creating-shareable-code" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-shareable-code" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In our SQLite implementation we will want to have a common implementation that is then available to all our tasks for our SQLite CRUD.&lt;/p&gt;
&lt;h3 class="relative group"&gt;SQLite Manager
&lt;div id="sqlite-manager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sqlite-manager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s create a new file &lt;code&gt;sqlite/sqliteManager.ts&lt;/code&gt;. This class &lt;code&gt;SQLiteManager&lt;/code&gt; will be an injectable in our dependency container. For handling, the dependency injection we are using &lt;a
href="https://github.com/inversify/InversifyJS"
target="_blank"
&gt;inversifyjs&lt;/a&gt;, but we also provide easy attributes you can use.&lt;/p&gt;
&lt;p&gt;In our class we can also access all the components that were injected in our container of dependency injection. This is very useful as we can have access to our logging system and other helpful components.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="SQLite Manager"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/sqlitemanager.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Looking at our class:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import Database from &amp;#34;better-sqlite3&amp;#34;; // SQLite Package
import * as path from &amp;#34;path&amp;#34;;
import * as fs from &amp;#34;node:fs&amp;#34;;
import {
    Dependencies,
    DI,
    System,
    TYPES
} from &amp;#34;@criticalmanufacturing/connect-iot-controller-engine&amp;#34;;
@DI.Injectable()
export class SQLiteManager {
private _db;
    private _dbLocation: string;
    private _tableSchemas: Set&amp;lt;string&amp;gt;;
@DI.Inject(TYPES.Dependencies.Logger)
    private _logger: Dependencies.Logger;
@DI.Inject(TYPES.System.PersistedDataStore)
    private _dataStore: System.DataStore;
public startSQLLite(dbPath = &amp;#34;iot.db&amp;#34;, cleanupTimerInterval = 60000) {
        // Retrieve persistency location from the configurations
        const persistencyLocation = path.join(this._dataStore[&amp;#34;_handler&amp;#34;][&amp;#34;_config&amp;#34;][&amp;#34;path&amp;#34;], &amp;#34;SQLite&amp;#34;, this._dataStore[&amp;#34;_controllerId&amp;#34;].replace(&amp;#34;/&amp;#34;, &amp;#34;_&amp;#34;));
fs.mkdirSync(persistencyLocation, { recursive: true });
        this._dbLocation = path.join(persistencyLocation, dbPath);
// Create SQLite database file
        this._db = new Database(this._dbLocation);
         // Track created tables
        this._tableSchemas = new Set();
        this._logger.debug(`Started SQLite DB at ${this._dbLocation}`);
// Start ttl timer
        this.scheduleCleanup(cleanupTimerInterval);
    }
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In our class we can see that a lot of things are happening, this is because we already have some code relevant to our SQLite implementation. For now, let&amp;rsquo;s focus on how we flag our class as able to be added to our dependency injection container, by using the &lt;code&gt;@DI.Injectable()&lt;/code&gt; attribute. Also, we can see that we can access other classes injected in our dependency injection container by using &lt;code&gt;@DI.Inject&lt;/code&gt; and then the name of our container. By using these attributes we have marked our class as injectable.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Task Injection
&lt;div id="task-injection" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#task-injection" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Now we need to add a provider for this class. We can do so by modifying a bit our tasks to be able to act as providers of this component.&lt;/p&gt;
&lt;p&gt;In the &lt;code&gt;StoreSQLiteTask&lt;/code&gt; we will now add an inject for our SQLiteManager and a provider to instantiate it.&lt;/p&gt;
&lt;p&gt;We will need to create new entrypoint for our task, that acts as a provider for this new component.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;@Task.TaskModule({
    task: StoreSQLiteTask, // Our Task
    providers: [
        {
            class: SQLiteManager, // Component that we are injecting
            isSingleton: true, // Should this component be a Singleton for the whole Controller
            symbol: &amp;#34;GlobalSQLiteManagerHandler&amp;#34;, // Name that will be used to Inject the container in the dependency injection
            scope: Task.ProviderScope.Controller, // Injection scope (Local; WorkflowPlan; Controller)
        }
    ]
})
export class StoreSQLiteModule { }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is where we can control the behavior of our component. We will say what class are we injecting and what is it name in the dependency injection container, if there is only one instance or if there can be multiple and finally if it&amp;rsquo;s going to be shared across all task activations (Local), across a workflow plan execution (WorkflowPlan) or to all tasks in the Controller.&lt;/p&gt;
&lt;p&gt;In the &lt;code&gt;index.ts&lt;/code&gt; file we will now modify it to have as an entrypoint, not the default &lt;code&gt;Task.Task()&lt;/code&gt; but the task module.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Module Entrypoint"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/moduleentrypoint.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can now inject and use our SQLiteManager component.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    /**
     * This is the representation of the SQLite manager
     */
    @DI.Inject(&amp;#34;GlobalSQLiteManagerHandler&amp;#34;)
    private _sqliteManager: SQLiteManager;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The name of the inject must match the one given in the symbol field of the module providers.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Shared Injectable"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/sharedinjectable.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now our &lt;code&gt;GlobalSQLiteManagerHandler&lt;/code&gt; is able to be used by any task and converter in any package in this controller.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Showing it Working
&lt;div id="showing-it-working" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#showing-it-working" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s change our code of our SQLiteManager a bit so we can see that it is called.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;import Database from &amp;#34;better-sqlite3&amp;#34;; // SQLite Package
import * as path from &amp;#34;path&amp;#34;;
import * as fs from &amp;#34;node:fs&amp;#34;;
import {
    Dependencies,
    DI,
    System,
    TYPES
} from &amp;#34;@criticalmanufacturing/connect-iot-controller-engine&amp;#34;;
@DI.Injectable()
export class SQLiteManager {
private _db;
    private _dbLocation: string;
    private _tableSchemas: Set&amp;lt;string&amp;gt;;
@DI.Inject(TYPES.Dependencies.Logger)
    private _logger: Dependencies.Logger;
@DI.Inject(TYPES.System.PersistedDataStore)
    private _dataStore: System.DataStore;
public startSQLLite(dbPath = &amp;#34;iot.db&amp;#34;, cleanupTimerInterval = 60000) {
        this._logger.warning(`Please implement the SQLiteManager`);
    }
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will then have a simple workflow with a timer and two sequential store SQLite tasks.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Shared Instance"
src="https://image.j-roque.com/posts/20250625-sqlite-ii/sharedinstance.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;What we are able to see is that our SQLiteManager at first is instantiated but without a &lt;code&gt;_db&lt;/code&gt;. Only when the first run calls the &lt;code&gt;startSQLLite&lt;/code&gt; method will it create a &lt;code&gt;_db&lt;/code&gt;. In the second call we are able to see that the &lt;code&gt;_db&lt;/code&gt; created in the first call is still the same and we can reuse it.&lt;/p&gt;
&lt;p&gt;With this we can now create a set of methods in the class &lt;code&gt;SQLiteManager&lt;/code&gt; that are available for any task that injects it. By using:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-ts"&gt;    /**
     * This is the representation of the SQLite manager
     */
    @DI.Inject(&amp;#34;GlobalSQLiteManagerHandler&amp;#34;)
    private _sqliteManager: SQLiteManager;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If for a given task you want to defer the inject to wait for example, for a setup task to start your class you can use the Optional attribute &lt;code&gt;@DI.Optional()&lt;/code&gt;.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;With this we have solved how to share code between all our tasks and converters. This is very helpful, to create reusable classes and utilities shared across different contexts. In the next and final part we will see a full implementation and with all of this working.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250625-sqlite-ii/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250625-sqlite-ii/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Part I - Third-Party Components in Connect IoT</title>
<link>https://j-roque.com/posts/20250617-sqlite-i/</link>
<pubDate>Tue, 17 Jun 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250617-sqlite-i/</guid>
<description>How to incorporate third-party component with Connect IoT</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250617-sqlite-i/featured.png" alt="Part I - Third-Party Components in Connect IoT" /&gt;&lt;/p&gt;&lt;p&gt;This blog post will be part of a series of blog posts where we show case an example of how we can use a third party dependency like &lt;code&gt;SQLite&lt;/code&gt; in our Connect IoT code. For this first blog post I will show how you can create customization and how you can prepare a development workspace.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Connect IoT is a nodejs application. NodeJs allows the use of third party packages available at public registries like &lt;a
href="https://www.npmjs.com/"
target="_blank"
&gt;NPM&lt;/a&gt;. In our use case we will use customization to create a &lt;a
href="https://sqlite.org/"
target="_blank"
&gt;SQLite&lt;/a&gt; integration.&lt;/p&gt;
&lt;p&gt;For this customization we will create new tasks that we can use to create workflows.&lt;/p&gt;
&lt;h3 class="relative group"&gt;SQLite
&lt;div id="sqlite" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sqlite" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;SQLite is a very interesting database. It is a lightweight, self-contained, serverless relational database engine. Unlike other database systems, SQLite does not require a separate server process — the database is stored in a single file on disk, making it easy to deploy and use.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Serverless&lt;/code&gt;: No need to install or manage a database server&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Zero Configuration&lt;/code&gt;: Ready to use out-of-the-box&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Cross-platform&lt;/code&gt;: Works on various operating systems&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fast and Lightweight&lt;/code&gt;: Minimal overhead, suitable for resource-constrained environments&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ACID Compliant&lt;/code&gt;: Ensures safe transactions even during crashes or power failures&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is used mostly in mobile, like android or iOS applications, for things like user settings, offline content, small local caches.&lt;/p&gt;
&lt;p&gt;It is not suitable for high-concurrency systems with many simultaneous writes or applications that need clustering or replication.&lt;/p&gt;
&lt;p&gt;Connect IoT is a nodejs application that forks other nodejs processes, but we don&amp;rsquo;t share persistency across different processes. This means that nodejs being a single threaded application and not sharing the persistency, makes SQLite an interesting approach to storing and querying data. Also the fact that it supports SQL as a query language makes it very user friendly.&lt;/p&gt;
&lt;p&gt;There are a set of problems, even though it has support for json documents and is quite performant, it has to perform some transformations to the data.&lt;/p&gt;
&lt;p&gt;The major problem is if you wish to use sqlite stored in a network drive. Beware this will cause you a lot of grief, there is an interesting article explaining this at &lt;a
href="https://www.sqlite.org/useovernet.html"
target="_blank"
&gt;Use over Net&lt;/a&gt;. Also, if you have SQLite stored in the automation manager container it will, be dropped if the container is restarted for some reason.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Persisted Data In Connect IoT
&lt;div id="persisted-data-in-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#persisted-data-in-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Persisted data is a very important aspect of Connect IoT it has a robust approach to store and retrieving data. It also has a clever mechanism to avoid data corruption, namely the use of file indexing and a read cache. One of the gaps is the ability to query persisted data. This turns into a lot of JSON manipulation on the Controller data. I believe this could be highly mitigated by some smart querying.&lt;/p&gt;
&lt;p&gt;Is this always a problem?&lt;/p&gt;
&lt;p&gt;No, most integrations don&amp;rsquo;t require complex sets of stored data outside the MES. For most integrations, Connect IoT can leverage the MES to be the source of truth for information. Using the MES brings more visibility to the data and also enables the possibility of other types of business logic and analytics.&lt;/p&gt;
&lt;p&gt;Nevertheless, there are several use cases where having a more complex persistency is helpful. It may be to avoid unnecessary load in the MES, could be due to the fact that having to make requests to the MES is too time consuming for your integration, it may be because the information is so tied to the integration that it doesn&amp;rsquo;t make sense to send it to the MES.&lt;/p&gt;
&lt;p&gt;One example that always comes to mind is machines that don&amp;rsquo;t know the name of the material, and assign a virtual id. It would be very cumbersome for the MES to store an attribute in the material to match to that specific machine, for information that is irrelevant to anyone that is not that specific machine. What we can do instead is store the relation between the virtual id and the material and then when sending information to the MES perform the conversion.&lt;/p&gt;
&lt;p&gt;Remember, one of the goals of Connect IoT is to be the first class interface to the MES and provide information in a way that is easy to digest by the MES.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Creating Customization
&lt;div id="creating-customization" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-customization" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In Connect IoT it is quite simple to build your own customization. From, converters and tasks to new drivers. The whole process is described in the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/customization-components/customization_tasklibrary/"
target="_blank"
&gt;developer portal&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s do it for our use case!&lt;/p&gt;
&lt;p&gt;First we will create our workspace:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Creating a Connect IoT Workspace"
src="https://image.j-roque.com/posts/20250617-SQLite/creatingcustomizationworkspace.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we can start creating our tasks and converters. For now let&amp;rsquo;s focus on the store task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Creating a Connect IoT Task"
src="https://image.j-roque.com/posts/20250617-SQLite/createstoresqltask.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We now have a task that is fully functional and scaffolded. We can now either use the CM CLI &lt;code&gt;cmf build&lt;/code&gt; commands or use specific commands like &lt;code&gt;npm i&lt;/code&gt;, &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The three main elements of task are the task.ts file which holds the node runtime code, then we have the template .json file which will hold the metadata for rendering the task and finally we have our test file.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Build Customization"
src="https://image.j-roque.com/posts/20250617-SQLite/buildingcustomization.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can know already build our whole customization code and even deploy it to our system.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Setting up our Development Workspace
&lt;div id="setting-up-our-development-workspace" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#setting-up-our-development-workspace" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s create a deployment of our task in the system and prepare everything so we can debug our code. We will create a customization package for our environment and deploy it in our local environment.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploy Customization"
src="https://image.j-roque.com/posts/20250617-SQLite/deploycustomization.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;With this we can already create a controller that uses our customization package.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; An alternative to this can be directly using the &lt;a
href="https://github.com/criticalmanufacturing/node-packagebundler"
target="_blank"
&gt;node package bundler&lt;/a&gt;, invoking the &lt;code&gt;npm run packagePacker&lt;/code&gt; in the root of the workspace.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Deploy in a Controller"
src="https://image.j-roque.com/posts/20250617-SQLite/controllercustomization.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;And we can run it!&lt;/p&gt;
&lt;p&gt;For now let&amp;rsquo;s add a simple timer that will call a task. We will connect to an Automation Manager, download and run the manager.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Running Customization"
src="https://image.j-roque.com/posts/20250617-SQLite/runningcustomization.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In order to be able to debug our customization task, we need to create symbolic link between the cache and our source code, then we just use the launch configuration already given by the scaffolding and provide our downloaded manager location.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Working on our Task
&lt;div id="working-on-our-task" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#working-on-our-task" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We can now do a small example where we will change our task and see the change happening.&lt;/p&gt;
&lt;p&gt;We will change the task to emit a valid result output. We could use the &lt;code&gt;cmf build&lt;/code&gt; command, but the scaffolding also offers a set of useful commands we can leverage for our development process. We could use &lt;code&gt;npm run build&lt;/code&gt; to build our application or we can use &lt;code&gt;npm run watchPackage&lt;/code&gt;. This command will continuously build our application whenever it detects a change.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Customization Change"
src="https://image.j-roque.com/posts/20250617-SQLite/debugcustomizationchange.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s now change our task to add a new input. We can leverage the visual studio code extension to give us intellisense on our json template. Then we can edit our Automation Task Library and have instant changes in our system.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Simple use case
&lt;div id="simple-use-case" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#simple-use-case" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;I will start with showing a use case of a dummy static input.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Dummy Input"
src="https://image.j-roque.com/posts/20250617-SQLite/simpleatl.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Task Input
&lt;div id="task-input" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#task-input" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In our store we will want a &lt;code&gt;dynamic input&lt;/code&gt; where the user can store multiple inputs in different tables, with a particular row id and with a time to live. Let&amp;rsquo;s already prepare our input. Dynamic inputs allow us to configure metadata for that input and they can also behave in a drag and drop mode.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Change ATL"
src="https://image.j-roque.com/posts/20250617-SQLite/changeatl.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can now change our workflow to see this working.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Running our Task"
src="https://image.j-roque.com/posts/20250617-SQLite/runwithinput.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Right now we are ready to start working and creating our customization code!!!&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250617-sqlite-i/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250617-sqlite-i/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Instant Customization Documentation</title>
<link>https://j-roque.com/posts/20250529-typedoc/</link>
<pubDate>Thu, 29 May 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250529-typedoc/</guid>
<description>Using Type Doc for instant documentation</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250529-typedoc/featured.png" alt="Instant Customization Documentation" /&gt;&lt;/p&gt;&lt;p&gt;Documentation is often dealt with as an afterthought of software development. It is very hard to convince developers to write documentation. But developers do write code and do like their code to be readable and maintanable. There are tools that use that skill of writing good code and transform it into documentation. Bring forth the often laughed concept of code being the best documentation.&lt;/p&gt;
&lt;p&gt;Connect IoT customization is built with typescript and &lt;a
href="https://typedoc.org/"
target="_blank"
&gt;typedoc&lt;/a&gt; is the premier tool for typescript documentation generation.&lt;/p&gt;
&lt;h2 class="relative group"&gt;What is TypeDoc?
&lt;div id="what-is-typedoc" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-is-typedoc" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;At its core, &lt;strong&gt;TypeDoc is a documentation generator for TypeScript projects&lt;/strong&gt;. It takes your TypeScript source code, leverages its rich type information, and parses your JSDoc-style (or &lt;a
href="https://tsdoc.org/"
target="_blank"
&gt;TSDoc&lt;/a&gt;-compliant) comments to create beautiful, browsable HTML API documentation. Think of it as your project&amp;rsquo;s personal archivist, meticulously cataloging every class, interface, function, and variable.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Why TypeDoc?
&lt;div id="why-typedoc" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-typedoc" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TypeScript-Native Understanding&lt;/strong&gt;: Unlike generic documentation tools, TypeDoc is built from the ground up for TypeScript. It inherently understands the nuances of TypeScript&amp;rsquo;s type system – interfaces, generics, union types, intersection types, access modifiers, and more. This means it accurately represents your API&amp;rsquo;s structure without requiring verbose, redundant comments.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Leverages Your Existing Comment&lt;/strong&gt;s: TypeDoc embraces the well-established JSDoc syntax (and the more modern TSDoc specification). If you&amp;rsquo;re already documenting your functions and classes with JSDoc comments, TypeDoc will readily consume them, enriching the generated output. This means less new syntax to learn and more leverage from your existing work.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rich, Navigable Output&lt;/strong&gt;: The generated documentation is typically in a clean, HTML format, complete with:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Search functionality: Quickly find what you&amp;rsquo;re looking for.&lt;/li&gt;
&lt;li&gt;Hierarchical navigation: Easily explore modules, classes, and members.&lt;/li&gt;
&lt;li&gt;Syntax highlighting: Code examples are readable and clear.&lt;/li&gt;
&lt;li&gt;Cross-referencing: Links to related types and members within your documentation.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start="4"&gt;
&lt;li&gt;&lt;strong&gt;Customization&lt;/strong&gt;: TypeDoc offers various themes and extensive configuration options, allowing you to tailor the look and feel of your documentation to match your project&amp;rsquo;s branding or integrate seamlessly into a larger documentation website.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;How TypeDoc Works (The Simplified Flow)
&lt;div id="how-typedoc-works-the-simplified-flow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-typedoc-works-the-simplified-flow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Installation&lt;/strong&gt;: You install TypeDoc as a development dependency in your project.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Configuration&lt;/strong&gt;: You tell TypeDoc where your source files are (src/index.ts for example) and where you want the output (&amp;ndash;out docs).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Comment Parsing&lt;/strong&gt;: TypeDoc scans your TypeScript files for declarations (classes, functions, etc.) and their associated JSDoc/TSDoc comments.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Type Information Integration&lt;/strong&gt;: It analyzes the TypeScript types (e.g., string, MyInterface, Array&lt;T&gt;) and incorporates this crucial information into the documentation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;HTML Generation&lt;/strong&gt;: TypeDoc renders all this information into a set of interlinked HTML files (and supporting CSS/JS), typically with an index.html as the entry point.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 class="relative group"&gt;Getting Started with TypeDoc
&lt;div id="getting-started-with-typedoc" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#getting-started-with-typedoc" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s look at a quick example to see how simple it is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install TypeDoc:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-Bash"&gt;npm install typedoc --save-dev&lt;/code&gt;&lt;/pre&gt;
&lt;ol start="2"&gt;
&lt;li&gt;Add JSDoc/TSDoc Comments to Your Code:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-TypeScript"&gt;/**
 *
 * This task transforms CFX Units Processed data into a set of IoT Post Events,
 * specifically for oven telemetry. It processes readings and setpoints from
 * oven zones and dispatches them to a telemetry system.
 *
 *
 * This task is activated by its `activate` input. Upon activation, it iterates
 * through provided oven data, constructs telemetry payloads, and posts them
 * using `SystemCalls.postTelemetry`. It handles success and error conditions,
 * emitting appropriate output signals.
 *
 * It&amp;#39;s designed to work with structured oven data, matching readings with
 * corresponding setpoints and enriching the data with relevant tags and ISA95
 * hierarchy information.
 *
 * ### Inputs
 * * `activate`: `any` - Triggers the execution of the task. Set to any value to activate.
 * * `instance`: `System.LBOS.Cmf.Foundation.BusinessObjects.Entity` - The system entity (e.g., a specific oven) to which the telemetry belongs.
 * * `material`: `string` - The material currently being processed in the oven.
 * * `values`: `OvenData[]` - An array of oven data, where each `OvenData` object contains readings and setpoints for a specific zone.
 * * `valuesTimestamp`: `Date` - The timestamp to associate with all processed telemetry values.
 *
 * ### Outputs
 * * `success`: `boolean` - Emits `true` when all telemetry events are successfully posted.
 * * `error`: `Error` - Emits an `Error` object if the task fails to post telemetry for any reason.
 *
 * ### Settings
 * See {@link PostOvenTelemetrySettings} for configurable properties like application name,
 * retry mechanisms, and more.
 */
@Task.Task()
export class PostOvenTelemetryTask extends TaskBase implements PostOvenTelemetrySettings {
/**
     * Called when one or more input values have changed.
     *
     * @param changes - An object representing the changed inputs.
     * @remarks
     * This method checks for activation of the task, processes oven telemetry data, 
     * and sends it to the system via `SystemCalls.postTelemetry`. It emits
     * success or error signals based on the result.
     */
    public override async onChanges(changes: Task.Changes): Promise&amp;lt;void&amp;gt; { ...
/**
     * Initializes the task.
     *
     * @remarks
     * Registers any required handlers and ensures the settings are sanitized
     * against default values before execution begins.
     */
public override async onInit(): Promise&amp;lt;void&amp;gt; { ...&lt;/code&gt;&lt;/pre&gt;
&lt;ol start="3"&gt;
&lt;li&gt;Add a Script to package.json:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-JSON"&gt;{
  &amp;#34;name&amp;#34;: &amp;#34;@criticalmanufacturing/connect-iot-controller-engine-custom-dataplatform-tasks&amp;#34;,
  &amp;#34;version&amp;#34;: &amp;#34;1.0.0&amp;#34;,
  ...
  &amp;#34;scripts&amp;#34;: {
    ...
    &amp;#34;doc:build&amp;#34;: &amp;#34;npx typedoc --entryPointStrategy Expand src&amp;#34;,
    &amp;#34;doc:start&amp;#34;: &amp;#34;open-cli docs/index.html&amp;#34;,
    &amp;#34;doc:build:start&amp;#34;: &amp;#34;npm run doc:build &amp;amp;&amp;amp; npm run doc:start&amp;#34;
  },
  &amp;#34;devDependencies&amp;#34;: {
    &amp;#34;typedoc&amp;#34;: &amp;#34;^0.25.0&amp;#34;,
    &amp;#34;open-cli&amp;#34;: &amp;#34;^8.0.0&amp;#34; // optional
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol start="4"&gt;
&lt;li&gt;Generate Your Docs:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-Bash"&gt;npm run doc:build&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After running this command, you&amp;rsquo;ll find a docs/ folder in your project root, containing the generated HTML documentation. Open docs/index.html in your browser, you can also run, if you install &lt;code&gt;open-cli&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-Bash"&gt;npm run doc:start&lt;/code&gt;&lt;/pre&gt;
&lt;h2 class="relative group"&gt;Using it with Connect IoT
&lt;div id="using-it-with-connect-iot" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#using-it-with-connect-iot" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Connect IoT scaffolding already creates methods and classes ready to use TypeDoc. In this example. I have a connect iot package called &lt;code&gt;@criticalmanufacturing/connect-iot-controller-engine-custom-dataplatform-tasks&lt;/code&gt;, with one task and a set of utilities.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250529-typedoc/TypeDocSingleTask.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;Notice how from the index we can see all the code tree and have an easy way to see the project. You can also use the search function to find any method.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For any serious TypeScript project, TypeDoc is an invaluable tool. It streamlines the documentation process, leverages the power of TypeScript&amp;rsquo;s type system, and produces high-quality, maintainable API reference documentation. By incorporating TypeDoc into your development workflow, you empower your fellow developers, simplify onboarding, and ultimately contribute to a more robust and understandable codebase. Give it a try – your future self (and your team) will thank you!&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250529-typedoc/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250529-typedoc/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>The Subtle Art of Jelling</title>
<link>https://j-roque.com/posts/20250514-subtleartofjelling/</link>
<pubDate>Wed, 21 May 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250514-subtleartofjelling/</guid>
<description>A framework for understanding teams</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250514-subtleartofjelling/featured.png" alt="The Subtle Art of Jelling" /&gt;&lt;/p&gt;&lt;p&gt;&lt;code&gt;The Subtle Art of Jelling&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;A simple framework for team assessment and some common use cases.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Motivation
&lt;div id="motivation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#motivation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;I was reading &lt;a
href="https://www.goodreads.com/book/show/45303387-an-elegant-puzzle"
target="_blank"
&gt;An Elegant Puzzle: Systems of Engineering Management&lt;/a&gt; by &lt;a
href="https://www.linkedin.com/in/will-larson-a44b543/"
target="_blank"
&gt;Will Larson&lt;/a&gt; and this created so many ideas and thoughts that I am going to do some blog posts on my thoughts on some concepts. Some are related to the book, others not so much. Hopefully some ideas will be useful.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Teams
&lt;div id="teams" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#teams" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Teams are the fundamental unit of innovation and disruption in an organization. This is the most profound axiom of any organization. An individual is an insufficient unit of capacity to actuate change, in order to generate change an individual must be a catalyst of a team or a chain of teams.&lt;/p&gt;
&lt;p&gt;Therefore, it is paramount to understand what is a team and how to qualify its health. In order to analyze a team I think of a framework of four different values: performance, potential, impact and cohesion.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Team&lt;/i&gt;&lt;/center&gt;
&lt;h3 class="relative group"&gt;Performance
&lt;div id="performance" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#performance" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Performance is a team&amp;rsquo;s ability to achieve or surpass what it proposes to achieve. In other words, if a team is using scrum and delivering sprints, a performant team is a team that is consistently able to deliver the sprint. This of course assumes that the product owner is happy with the scheduled work for the sprint.&lt;/p&gt;
&lt;p&gt;This is the most transparent value, any team is measured by what it delivers, by the amount and quality of the deliverables. All managers want to increase the team performance, in other words, deliver more, faster and with more quality. In essence to increase the team productivity.&lt;/p&gt;
&lt;p&gt;In itself performance is a snapshot metric and is insufficient to give a broad picture of the team state, it&amp;rsquo;s relationship with the organization and long term progression.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Potential
&lt;div id="potential" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#potential" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Potential can be understood as the team&amp;rsquo;s ability to be ever more performant. A team that only has very senior members is a team with low potential, but with a high performance, and a team of only junior developers may have a lot of potential but low performance.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Senior Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/senior-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Senior Team&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;This is another metric that often creates a lot of confusion and bewilderment to manager folks. It is common for managers to be frustrated at a team member with a lot of potential, but that makes some trivial errors or that goes into fools errands.&lt;/p&gt;
&lt;p&gt;It is common to increase the work payload/complexity of team members or teams that exhibit promise, but without due caution they may fail as the realization of potential into the ability to perform is often not a linear path.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Junior Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/junior-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Junior Team&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;It is also common for managers to be frustrated at senior teams for them not being able to increase their performance throughout time, forgetting that if they are already very senior, probably they don&amp;rsquo;t have big margins for performance gain.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Impact
&lt;div id="impact" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#impact" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;A different metric is organizational impact or how does the team influence the organization as a whole.&lt;/p&gt;
&lt;p&gt;Is the work of that team affecting a lot of other components inside the organization?&lt;/p&gt;
&lt;p&gt;For example, a specialty team focused on an application that has no great externalities is probably a team with a low organizational impact. A team that is working on migrating the database technology probably has a big impact as it has a lot of externalities. If the technology provides a performance improvement, that improvement will be felt by all other applications that interact with the database. One other key aspect is more commercial or business oriented, which is to think if that team has a big impact in the overall strategy or viability of the company and if it has an impact in revenue generation, future or present.&lt;/p&gt;
&lt;p&gt;Teams that deliver fast and quality work on areas that are not strategic for the organization or that do not have ROI are as a whole failing to have impact. These teams are always on the cusp of being shelved as they have low visibility for the wider organization and may seem like they are redundant.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Low Impact Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/lowimpact-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Low Impact Team&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;Teams that have a high impact and a low performance are one of the most hated teams in your organization. They are affecting the organization as a whole but are not delivering at a fast enough pace to mitigate issues that they are causing. This creates a lot of pushback against that team as they will be widely perceived as incompetent. Typically these teams will be quick to suffer upper management intervention as customers or other team leads will complain about the collateral damage.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Low Performance/ High Impact Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/highimpactlowperf-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Low Performance/ High Impact Team&lt;/i&gt;&lt;/center&gt;
&lt;h3 class="relative group"&gt;Cohesion
&lt;div id="cohesion" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cohesion" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Cohesion is a multi-dimensional perspective. Team cohesion is very important as a low cohesion can foster disengagement and harm the team viability. A different perspective is organizational cohesion, how does the team engage with the organization as whole.&lt;/p&gt;
&lt;p&gt;If a team has a very low organizational cohesion, it can generate silos and interpersonal conflicts between other parts of the organization. With a low organizational cohesion a team has a very low chance of having impact as they will have a low ability to generate a change to how the other parts of the organization work. If you build a test framework that saves a lot of time for your team and try to pitch that solution to other teams, if there&amp;rsquo;s little goodwill chances are that no one will listen. This is a very common problem for teams that are highly analytical and focused on technology, which often project an image of paternalism and arrogance, without human informal power you are highly unlikely to have impact outside your team.&lt;/p&gt;
&lt;p&gt;On the flip side if a team has a low internal cohesion, it will inevitably have a low performance and it will severely hamper their potential. A team is an opportunity for the whole to be greater than its parts, but actually a low moral team is not just the sum of its parts, it&amp;rsquo;s much worse than that and can create a lot of conflict and waste of time.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Solving Problems
&lt;div id="solving-problems" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#solving-problems" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Economy is the science that studies the allocation of scarce resources. Management is alike, in the sense that with infinite time and team capacity one does not require any kind of consideration on management.&lt;/p&gt;
&lt;p&gt;Therefore, I want to discuss typical problems and common strategies to solve those problems and what are the downsides of choosing one solution or other.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Lack of Time
&lt;div id="lack-of-time" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lack-of-time" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Delivering ever faster services and goods is always a force that acts upon the manager.&lt;/p&gt;
&lt;p&gt;Even in a perfect scenario where he has everything under a miraculous steady state, with a shift of some market constraint (like a new buzzword) or with some C level seeing a youtube video, his whole perfect master plan is disrupted.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s imagine the typical scenario of a team has to deliver a feature that was scaled for 6 months shifting the goal post to 3 months. How does the manager address this problem?&lt;/p&gt;
&lt;p&gt;First, he should do what any good manager should do, which is protect his teams allocation and push back against arbitrary decisions. Most of the times this is a windmill fighting scenario, typically the windmill wins.&lt;/p&gt;
&lt;p&gt;He will therefore try to talk with his team and understand if there are trade offs to be made, baroque features that can be trimmed out, MVPs that can be carved out and ways to reduce complexity and lower the time to delivery. Let&amp;rsquo;s say he decreases one month, he is still missing 2 months, what can he do?&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Lack of Time"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/lack-of-time-or-running-out-of-time-concept-vector.jpg"
&gt;&lt;/figure&gt;
&lt;i&gt;&lt;a
href="https://www.vecteezy.com/vector-art/2118256-lack-of-time-or-running-out-of-time-concept"
target="_blank"
&gt;https://www.vecteezy.com/vector-art/2118256-lack-of-time-or-running-out-of-time-concept&lt;/a&gt;&lt;/i&gt;&lt;/p&gt;
&lt;h4 class="relative group"&gt;Adding Team Capacity
&lt;div id="adding-team-capacity" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#adding-team-capacity" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;If one woman gives birth one baby in 9 months, it is obvious that nine women give birth to one baby in 1 month.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is the allegory of the first pitfall, this is the most basic pitfall of all.&lt;/p&gt;
&lt;p&gt;If you walk through life without understanding this, you are just waiting for the piano to fall down on your head and trust me, It will.&lt;/p&gt;
&lt;p&gt;If it&amp;rsquo;s so basic why do we constantly fall for this?&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s intuitive. Human beings have an innate difficulty with unintuitive thinking. In the land of objects and abstractions (like mathematics (also known to managers as MS Excel spreadsheets)) it is evident that if I have available resources on one side and I need more capacity on another, I will just keep adding resources.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Why is this problematic?&lt;/code&gt; Adding team members creates a lot of trade offs.&lt;/p&gt;
&lt;p&gt;In the short term it reduces overall team performance and cohesion, it is inevitable that even very senior personnel will need to gain context of the project of team and of how the proverbial sausage is made.&lt;/p&gt;
&lt;p&gt;Be wary of this initial performance impact, if you are facing a time crunch this may be suicidal. Also, bear in mind that the rest of the team when pressed for time, may be very adverse in investing time in training the new member, particularly if he is seen as someone that is just there for a limited amount of time.&lt;/p&gt;
&lt;p&gt;A functional team may look robust like a robust mast on your ship, but often than not it&amp;rsquo;s more similar to the last play of a Jenga game. Keep in mind that every time you cause team disruption you are removing and adding a wood plank in the Jenga tower.&lt;/p&gt;
&lt;p&gt;You will be reducing initial performance and severely impacting the team cohesion, in search of a possible future gain in performance.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Adding Teams
&lt;div id="adding-teams" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#adding-teams" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;Adding teams is an interesting approach.&lt;/p&gt;
&lt;p&gt;The first question you must ask is, is my feature set mature enough or prepared enough for it to be shared cross teams. For most of the teams and product owners I&amp;rsquo;ve seen, the answer is no.&lt;/p&gt;
&lt;p&gt;This means that in order for you to have a second team working in the project, either at full or partial capacity, you will have to have a really well prepared backlog and a lot communication between team leads. You will be always facing the concern of having one team waiting on another team, so you must minimize blocking points. I&amp;rsquo;ve often seen the value of having an additional daily session (even if not everyday), between the team leads and the product owner do the trick on having everything streamlined.&lt;/p&gt;
&lt;p&gt;A second question is, do these teams have a high organizational cohesion? If you join two teams with low organizational cohesion, you will have a big risk of interpersonal conflicts, bad communication and inefficiency. Remember that joint ventures, can be a very good in knowledge sharing and team growth, but at the same time if there are personal conflicts it can also push teams away of the organization and create high attrition.&lt;/p&gt;
&lt;h4 class="relative group"&gt;MVP Team
&lt;div id="mvp-team" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mvp-team" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;You need things to get done fast. Just get the people that do things fast together and Puff everything is solved. Oh boy if only life was that easy&amp;hellip;&lt;/p&gt;
&lt;p&gt;Let me first start with saying, sometimes this is an inevitability. Let&amp;rsquo;s face it, if you are a startup and you need to rush to market and you are living day by day, maybe for a period of time you have to be laser focused on what needs to be shown to your investors or small user base. If you are a company with a long term aspiration this can be a very bad idea.&lt;/p&gt;
&lt;p&gt;Having an MVP means removing people that were in other teams and pool those high performers into a team of high performers. These types of teams tend to have low potential, low cohesion and a good local impact but very bad overall trickle down organizational impact.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MVP Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/mvp-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;MVP Team&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;By pooling away a team MVP means, that you are removing someone from an ecosystem where he&amp;rsquo;s thriving and is excellent into a new environment. All teams have a normal distribution of talent and achievement, you may be skillful and ably build a team that has people that are all high achievers in different dimensions of what makes a team great. Nevertheless, If you pool a team where all are amazing technical people, how will they cope when they are no longer the technical expert? Will they thrive or are they going to lose moral.&lt;/p&gt;
&lt;p&gt;My experience is that most teams like this have a very low team cohesion and fall into the pitfall of trying to spend the time one upping themselves, instead of solving issues.&lt;/p&gt;
&lt;p&gt;What about the teams they left? All their stats will decrease, their performance will fall as they no longer have the highest achiever, their potential will fall, as they lose the team member that was teaching and helping the team as whole. As the team understands that their overall stats have decreased, cohesion also starts to deteriorate as people feel that they are regressing instead of improving.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MVP Loss Team"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/mvploss-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;MVP Loss Team&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;Finally, what does everyone else in the organization think about having a team hoard all the cool and innovative stuff? Yeah, they don&amp;rsquo;t like it. You are signaling to everyone else, that if there&amp;rsquo;s an A team and you&amp;rsquo;re not on it, means that you&amp;rsquo;re on the B team. This means that by creating an MVP team, you are not just reducing the performance of the teams affected, but you will heavily impact the global team performance and create a stratified corporate culture. This also means that as organizational coesion decreases, the organizational impact will also heavily decrease. In other words, If I&amp;rsquo;m not in the A team, the organization doesn&amp;rsquo;t value me, and if the organization doesn&amp;rsquo;t value me and my input I won&amp;rsquo;t work on things that value not just my team but the whole organization.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Other Teams"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/mvp-others-team.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Other Teams&lt;/i&gt;&lt;/center&gt;
&lt;h4 class="relative group"&gt;Do nothing
&lt;div id="do-nothing" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#do-nothing" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;Sometimes, no action is the best action.&lt;/p&gt;
&lt;p&gt;Trust your teams, explain what are the constraints and create reward incentives for them achieving this extra mile, but trust that your team can have periods of 120% if they know the outcome, they know that everyone is rowing to the same side and after a milestone is reached they will have breathing room and time for under 100% performance. Also, don&amp;rsquo;t change strategies, predictability is key, people are able to bear what they know. If you keep surprising the team with added effort and problems they will turn off and start ignoring your goals. Protect your team from noise, when you need peak performance you need to protect the team from external factors. If what the team is doing is the priority they need to feel that it&amp;rsquo;s really a priority. This will be a time where the impact of the team for other initiatives will be put in the back burner.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Team Improvement"
src="https://image.j-roque.com/posts/20250514-subtleartofjelling/improvementteam-chart.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;center&gt;&lt;i&gt;Team Improvement&lt;/i&gt;&lt;/center&gt;
&lt;p&gt;In this example, blue is the previous state and green is the new state, we see that we are able to raise the team overall performance and the cohesion as the team grows stronger together through controlled adversity. The potential decreases marginally as some of that potential is realized in performance (this is not actual the case as potential can also grow with performance) and as the team cohesion with the organization and knowledge base grows it also increases the organizational impact.&lt;/p&gt;
&lt;p&gt;The scope may be so extensive that this approach is not possible and you are forced to add more capacity, but a lot of times the best solution is trusting your team to handle it. This will increase team cohesion as in times of some stress people tend to get closer and it will also, help to realize some team potential as it will be a time of increased team focus.&lt;/p&gt;
&lt;p&gt;Note that your pushing a team for a certain period of time. Not all people are the same, not all teams are the same, it&amp;rsquo;s up to you as a manager to evaluate the team resiliency and monitor how the attrition of the team is evolving. Remember that attrition is not a function of workload, it is highly correlated to all these facets. For example, a team that has a high cohesion and perceives a high organizational impact will have a lower attrition rate.&lt;/p&gt;
&lt;h2 class="relative group"&gt;What about Jelling
&lt;div id="what-about-jelling" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-about-jelling" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Well, the undertone of all of this is jelling matters. People are people, we could wish they weren&amp;rsquo;t, we can complain, shout and squirm, but reality always wins.&lt;/p&gt;
&lt;p&gt;You know Bob and Tod that are aces together, they&amp;rsquo;ve worked together for 5 years and have a lot of respect for each other. Bob he&amp;rsquo;s a genius, he knows the architecture like the palm of his hand, but he only works well with Tod, cause they&amp;rsquo;ve been through hell and back and Tod is the human interface to Bob. Change Tod to another team and his technical weaknesses start to show. Change Bob to be a team lead and brace yourselves for the never ending soap opera, of a an elephant on the china store of human interactions.&lt;/p&gt;
&lt;p&gt;Managers are people and they see a problem and want to mitigate it. They have a big red button called reshuffle and damn how tempting is to press it. It&amp;rsquo;s like a Casino dealer reshuffling all the cards and suddenly you have a new game. Changing the cards has a steep cost. People that work together well are something to foster and cultivate.&lt;/p&gt;
&lt;p&gt;Does this mean you should never reshuffle? No, sometimes pressing the red button is the only way forward, particularly for situation of a global low team cohesion rate throughout the organization. But never be rash, most of the times, trusting your teams and trusting them to rise above the challenges is the best approach.&lt;/p&gt;
&lt;p&gt;Cohesion is one of the key elements of any team and is I think a severely forgotten aspect. A team&amp;rsquo;s ability to work together and work with the organization is a key indicator for long term success.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;How do your teams stack up? Try and build your own team charts. Do an introspection and try to give values for all these fields. This will give you of an overview of your knowledge about the teams you manage and of how balanced they are.&lt;/p&gt;
&lt;p&gt;Only when you understand your team landscape can you start optimizing your organization. Remember, teams are the fundamental unit of change and your goals a manager is to be a catalyst for positive change in your organization.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250514-subtleartofjelling/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250514-subtleartofjelling/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Testing Low Code</title>
<link>https://j-roque.com/posts/20250516-testinglowcode/</link>
<pubDate>Fri, 16 May 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250516-testinglowcode/</guid>
<description>Challenges of testing low code flows</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250516-testinglowcode/featured.png" alt="Testing Low Code" /&gt;&lt;/p&gt;&lt;p&gt;In this post I am going to introduce the use of a testing framework for IoT low code flow testing, using machine simulators. All detailed information about this tool can be found in the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/workflow_testing/"
target="_blank"
&gt;developer portal&lt;/a&gt;, the goal of this blog post is to present some use cases.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Low Code
&lt;div id="low-code" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#low-code" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Low code is great at bringing increased transparency to whatever it is you&amp;rsquo;re doing, it enables stakeholders that are not software engineers to intuitively understand customization logic without having to understand code. Low code is based on some form of composability, you add blocks of atomic actions to create complex flows. The sum of these flows will be your integration.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Motivation
&lt;div id="motivation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#motivation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We have extensive documentation on how to build tests for low code components like tasks or converters (&lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/automation/how-tos/unit_tests/"
target="_blank"
&gt;documentation on unit testing&lt;/a&gt;). These allow you to create your custom components and create test suites to guarantee that they behave as expected.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Unit Tests"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/devportalunittest.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The major challenge is moving from component level testing to flow testing or integration testing. We guarantee the behavior of our task and converter, but how can we guarantee the behavior of a chain of tasks and converters, and how can we guarantee that adding a driver for protocol X or Y will not break your implementation?&lt;/p&gt;
&lt;p&gt;This is a big concern, particularly in integrations that have complex flows. The challenge we gave ourselves was then to create a tool that would allow us to test full on integrations, but still be simple, extensible, fast and autonomous.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Simple
&lt;div id="simple" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#simple" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;It needs to be simple, we can&amp;rsquo;t create a framework for automation that works in low code and then have it be very complex to build tests. It also needs to be simple to incentivize an increase in testing as a way to reduce manual qualification time and to improve the overall quality of the deliverables. If tests are very cumbersome, people will avoid doing them and will default to manual testing.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Extensible
&lt;div id="extensible" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#extensible" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Extensibility is very important, it is not enough for the tool to support out of the box protocols and features. It needs to be able to work with custom protocols and also to develop your own tool plugins.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Fast &amp;amp; Autonomous
&lt;div id="fast--autonomous" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#fast--autonomous" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Fast and autonomous are very important features. One of the challenges of previous tools was that they were focused on having all components of the integration running. So you would need a full on MES environment with all of its components up and working and then you would need to have a test suite that would connect to the environment and perform actions and then eventually get to test the automation layer. This is of course a very valid scenario for full on functional tests, but it is very cumbersome for all other kinds of testing and also it is very slow. What we started seeing is that as projects grow, the time it takes for the tests to run grows exponentially. Also, if tests are slow, people will avoid running them or creating them as they will be seen as a time sink. Another aspect of this is that as tests become less isolated and try to test multiple running components at the same time, they tend to be flaky and are of course very hard to parallelize.&lt;/p&gt;
&lt;p&gt;We wanted to build a tool that was still able to perform functional tests with a full on live MES System, but also that was able to run completely without any MES interaction.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Development Process
&lt;div id="development-process" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#development-process" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;A bit of a side topic, but one that I think is pertinent is how to develop an integration. It is a common paradigm in everyday backend software development to write code and then write unit tests and eventually some integration tests. For equipment integration or any kind of interfacing software this is normally a very messy and error prone way to develop.&lt;/p&gt;
&lt;p&gt;A more interesting way to develop is to have a more test driven development way of coding, because you will test how your integration responds to different external inputs.&lt;/p&gt;
&lt;p&gt;In other words, you want to have the machine send an event with a set of data and see how your integration responds. In my experience it&amp;rsquo;s easier to create a test of a machine sending an event and validating what you expect to happen, than creating an integration and then build the test to send an event. It seems like it shouldn&amp;rsquo;t matter, but it does as if you do this later you may realize in the end that there are problems in the interfacing that influence the whole concept behind your integration.&lt;/p&gt;
&lt;p&gt;This is to say that an easy way to build tests is also a way to vastly increase not only the quality of development but also the implementation time.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Building a Test
&lt;div id="building-a-test" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-test" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;h3 class="relative group"&gt;Importing the dependencies
&lt;div id="importing-the-dependencies" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#importing-the-dependencies" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Critical Manufacturing already shares a couple of important .Net nugets in order to use this tool, in &lt;a
href="https://criticalmanufacturing.io"
target="_blank"
&gt;https://criticalmanufacturing.io&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The nugets that are required depend on what the user wishes to test. For each protocol that is supported by the tool a different nuget is required. For example, in this post I will show tests of an integration using for example MQTT, therefore I will have to import the nuget &lt;code&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.Simulator.MQTT&lt;/code&gt;. The testing tool will also start an automation manager, so we need to provide a startup plugin. For now we have available the &lt;code&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.StartMode.Local&lt;/code&gt;, finally we have some additional one&amp;rsquo;s for utilities &lt;code&gt;Cmf.ConnectIoT.TestOrchestrator.Core.ScenarioBuilder&lt;/code&gt; and &lt;code&gt;Cmf.ConnectIoT.TestOrchestrator.Core.Common&lt;/code&gt;. If you wish to use the tool in the standalone mode without a running MES system you will also need the &lt;code&gt;Cmf.ConnectIoT.TestOrchestrator.Plugin.System.MockSystem&lt;/code&gt; nuget.&lt;/p&gt;
&lt;p&gt;If you wish to also use CM&amp;rsquo;s framework you can also import the &lt;a
href="https://developer.criticalmanufacturing.com/explore/guides/customizations/business/lightbusinessobjects/"
target="_blank"
&gt;Light Business Objects&lt;/a&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Creating a Test Solution
&lt;div id="creating-a-test-solution" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-a-test-solution" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;I created a new solution called IoTTest and added all the required dependencies.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Test Solution"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/addingdependencies.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Example Autonomous Test MQTT Publish
&lt;div id="example-autonomous-test-mqtt-publish" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#example-autonomous-test-mqtt-publish" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s create an example of a test for the autonomous (without MES) mode.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Workflow
&lt;div id="workflow" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#workflow" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;I have created a very simple MQTT integration, for an example of this check out &lt;a
href="../20250311-cleanroommonitoring"&gt;Clean Room Monitoring&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Set Mqtt Props"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/setmqttpropsworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This workflow will receive a message from the message bus topic &lt;code&gt;Cmf.MQTT.SendMessage&lt;/code&gt; and will set the equipment property &lt;code&gt;message&lt;/code&gt; with a constant value &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s build our test&amp;hellip;&lt;/p&gt;
&lt;h4 class="relative group"&gt;Building a Scenario
&lt;div id="building-a-scenario" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#building-a-scenario" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;MSTest supports several different hooks like &lt;code&gt;TestClassInitialize&lt;/code&gt; or &lt;code&gt;TestInitialize&lt;/code&gt;. Let&amp;rsquo;s create our scenario configuration in our test initialize method.&lt;/p&gt;
&lt;p&gt;In the scenario configuration is where we will set up the behavior of our test orchestrator tool.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private const string PACKAGES_LOCATION = @&amp;#34;\\jroque\IoTRepository&amp;#34;;
private const string MANAGER = @&amp;#34;criticalmanufacturing-connect-iot-manager-11.1.0.tgz&amp;#34;;
[TestInitialize]
public void Initialize()
{
  Configuration = new ScenarioConfiguration()
    // Manager to start
    .ManagerId(&amp;#34;MQTTManager&amp;#34;)
    // Optional but nice for development where we can follow our test execution
    .WriteLogsTo(&amp;#34;C:/logs.txt&amp;#34;)
    // Manager Configuration Settings
    .Config(new Common.Objects.Config.ConfigBuilder()
        .CacheLocation(Path.GetTempPath())
        .Build())
    // Configurations for our Mock System  
    .SystemPlugin&amp;lt;MockSystem.PluginMain&amp;gt;(new MockSystem.Plugin.SettingsBuilder()
        .Repository(PACKAGES_LOCATION)
        .MasterdataPath(@&amp;#34;Cmf.Custom.IoT.Data/Masterdata/1.0.0/MQTTController.json&amp;#34;)
        .AddSite(&amp;#34;TestSite&amp;#34;)
        .AddManager(&amp;#34;MQTTManager&amp;#34;)
        .AddControllerInstance(&amp;#34;MQTT Controller&amp;#34;, &amp;#34;MQTTManager&amp;#34;, &amp;#34;TestSite&amp;#34;)
        .AddDriverInstance(&amp;#34;MQTT Controller&amp;#34;, &amp;#34;MQTT&amp;#34;, &amp;#34;MQTTManager&amp;#34;, &amp;#34;TestSite&amp;#34;)
        .Build())
    // Configurations for the equipment simulator
    .AddSimulatorPlugin&amp;lt;MQTT.PluginMain&amp;gt;(new MQTT.Plugin.SettingsBuilder()
        .Address(&amp;#34;localhost&amp;#34;, 1883)
        .StartBroker(true)
        .Build())
    // Settings for starting our manager
    .StartMode&amp;lt;LocalStartMode.PluginMain&amp;gt;(new LocalStartMode.Plugin.SettingsBuilder()
        .ManagerLocation(Path.Join(PACKAGES_LOCATION, MANAGER))
        .Build());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scenario configuration is a simple way to setup our test orchestrator. The manager id corresponds to the id of the manager we want to run. The master data path will correspond to the path where my workflow has been exported.&lt;/p&gt;
&lt;p&gt;In my test I added a link to my CM CLI IoT Data package where I have my integrations. Having a link to your data package enforces that every time there is a change in the integration, the tests will execute against it (make sure that the files are always being copied to the output directory).&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Master Data"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/masterdatatest.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The system plugin is where we will configure our mock System (MES). The system will use your master data location to create mock representations of your objects. In our test, we have a master data file with the export of the Automation Controller. So it has the Automation Protocol, Automation Driver Definition and Automation Controller entities, but it doesn&amp;rsquo;t have an Automation Manager, the Entity for our tests or the instances required to run automation. We could have them in our master data file, but it is common for these to be delivered outside of the customization deliverables, so we declared them explicitly. You can construct your MES entities by adding them in the scenario builder directly.&lt;/p&gt;
&lt;p&gt;The simulator plugin is where we define our equipment test simulator. For MQTT, the test will be responsible for starting an MQTT broker and for allowing subscribing to topics and publishing to topics. This is a simple protocol, so we just need to configure the address of the broker and if we should start the broker.&lt;/p&gt;
&lt;p&gt;The start mode is the plugin required for starting the Connect IoT Automation layer. In this example, I have a local repository with all the IoT Packages. The test will search for my manager tgz, unpack it and start the manager according to the specified configurations.&lt;/p&gt;
&lt;p&gt;Looking at the test itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[TestMethod]
public void MQTTPublish()
{
    new TestScenario(Configuration).Run((scenario, context) =&amp;gt;
    {
        #region Setup
MockSystem.PluginMain system = (context.System as MockSystem.PluginMain);
        MQTT.PluginMain mqttSimulator = (context.Simulators[&amp;#34;MQTT&amp;#34;] as MQTT.PluginMain);
// Will wait for IoT to connect to the equipment simulator
        system.WaitForAllComunicating(20);
#endregion Setup
var messageWasReceived = false;
// Will create a new message bus client and send a message to a topic
        var mbClient = system.NewMessageBusClient();
        mbClient.Publish(&amp;#34;Cmf.MQTT.SendMessage&amp;#34;, null);
// Will subscribe the mqtt equipment simulator to a topic
        mqttSimulator.WaitForMessage(40, &amp;#34;cleanroom/message&amp;#34;, (message, topic) =&amp;gt;
        {
            Assert.IsTrue(topic == &amp;#34;cleanroom/message&amp;#34;);
            Assert.IsTrue(message == &amp;#34;OK&amp;#34;);
            messageWasReceived = true;
            return true;
        });
scenario.Utilities.WaitFor(50, &amp;#34;No get request was called&amp;#34;, () =&amp;gt;
        {
            return messageWasReceived;
        });
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 class="relative group"&gt;Test Setup
&lt;div id="test-setup" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#test-setup" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;The first element of our test is&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;new TestScenario(Configuration).Run((scenario, context) =&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What this code does is enclose a test run in a scenario execution. It will give to the test orchestrator the responsibility in each run of this test to start a Manager from scratch, start the simulators and then make sure everything is shutdown in the end of the test.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; This approach is the simplest one, but can become a bit too slow as the test number increases. You can also choose to handle yourself the start and stop of the test orchestrator and have a Manager run through your class of tests, decreasing the Manager start time, from once per test to once per test class. In this scenario you would have to be careful to avoid test bleed over.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;The setup region is where we will wait for the Connect IoT Manager to boot up the controllers and drivers and for them to connect to the equipment simulators.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;#region Setup
MockSystem.PluginMain system = (context.System as MockSystem.PluginMain);
MQTT.PluginMain mqttSimulator = (context.Simulators[&amp;#34;MQTT&amp;#34;] as MQTT.PluginMain);
// Will wait for IoT to connect to the equipment simulator
system.WaitForAllComunicating(20);
#endregion Setup&lt;/code&gt;&lt;/pre&gt;
&lt;h4 class="relative group"&gt;Test Run
&lt;div id="test-run" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#test-run" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;The test itself is quite simple. The workflow is expecting to receive a message bus message to the topic &lt;code&gt;Cmf.MQTT.SendMessage&lt;/code&gt; and it will the reply with an &lt;code&gt;OK&lt;/code&gt;. So our test is sending a message to the topic and subscribe an MQTT client to the topic that expects the message and validate that the message received is as expected.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;// Will create a new message bus client and send a message to a topic
var mbClient = system.NewMessageBusClient();
mbClient.Publish(&amp;#34;Cmf.MQTT.SendMessage&amp;#34;, null);
// Will subscribe the mqtt equipment simulator to a topic
mqttSimulator.WaitForMessage(40, &amp;#34;cleanroom/message&amp;#34;, (message, topic) =&amp;gt;
{
    Assert.IsTrue(topic == &amp;#34;cleanroom/message&amp;#34;);
    Assert.IsTrue(message == &amp;#34;OK&amp;#34;);
    messageWasReceived = true;
    return true;
});
scenario.Utilities.WaitFor(50, &amp;#34;No message was received&amp;#34;, () =&amp;gt;
{
    return messageWasReceived;
});&lt;/code&gt;&lt;/pre&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; Note that the mqtt simulator happens in a different thread as the main thread so this code would not work without the WaitFor messagesWasReceived.&lt;/span&gt;
&lt;/div&gt;
&lt;h4 class="relative group"&gt;Test Execution
&lt;div id="test-execution" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#test-execution" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250516-testinglowcode/img/mqttpublish.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;h3 class="relative group"&gt;Example Autonomous Test MQTT Subscribe
&lt;div id="example-autonomous-test-mqtt-subscribe" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#example-autonomous-test-mqtt-subscribe" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at the mirror image of the previous test. I have the following workflow, where I am expecting an MQTT event and will then broadcast a message via message bus.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Event MQTT"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/eventmqttworkflow.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s do the same and build a test.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[TestMethod]
public void MQTTSubscribe()
{
    new TestScenario(Configuration).Run((scenario, context) =&amp;gt;
    {
        #region Setup
MockSystem.PluginMain system = (context.System as MockSystem.PluginMain);
        MQTT.PluginMain mqttSimulator = (context.Simulators[&amp;#34;MQTT&amp;#34;] as MQTT.PluginMain);
system.WaitForAllComunicating(20);
#endregion Setup
var messageWasReceived = false;
        var mbClient = system.NewMessageBusClient();
mbClient.Subscribe(&amp;#34;Cmf.MQTT.MSGReceived&amp;#34;, (evt) =&amp;gt;
        {
            var message = JsonConvert.DeserializeObject&amp;lt;Dictionary&amp;lt;string, object&amp;gt;&amp;gt;(evt);
Assert.IsTrue((string)message[&amp;#34;topic&amp;#34;] == &amp;#34;cleanroom/temperature/temp1&amp;#34;, &amp;#34;Received Invalid topic&amp;#34;);
            Assert.IsTrue(message[&amp;#34;temperature&amp;#34;].ToString() == &amp;#34;20&amp;#34;, &amp;#34;Received Invalid value&amp;#34;);
messageWasReceived = true;
        });
mqttSimulator.Publish(&amp;#34;cleanroom/temperature/temp1&amp;#34;, &amp;#34;20&amp;#34;);
scenario.Utilities.WaitFor(50, &amp;#34;No message received&amp;#34;, () =&amp;gt;
        {
            return messageWasReceived;
        });
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test setup is the same. The test will create a new message bus client and subscribe to the topic &lt;code&gt;Cmf.MQTT.MSGReceived&lt;/code&gt;, it will then send an MQTT publish to the topic &lt;code&gt;cleanroom/temperature/temp1&lt;/code&gt; with the value 20. The message bus subscription callback will be activated and validate that the message and topic are as expected.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Example Autonomous Test API Post Event
&lt;div id="example-autonomous-test-api-post-event" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#example-autonomous-test-api-post-event" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Let&amp;rsquo;s take a look at a test that requires MES interaction. The api post event task is a task that allows us to send data to IoT Data Platform. This workflow will wait for a message bus message to this controller and will then call an api post event.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Post Event"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/postevent.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now we will have to build a test that also mocks the MES API post event.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[TestMethod]
public void PostEvent()
{
    new TestScenario(Configuration).Run((scenario, context) =&amp;gt;
    {
      #region Setup
      MockSystem.PluginMain system = (testContext.System as MockSystem.PluginMain);
system.WaitForAllComunicating(20);
      var controllerInstance = testContext.Instances[&amp;#34;Controller&amp;#34;] as IControllerInstanceContext;
#endregion Setup
var hasBeenCalled = false;
// Register the mock for this particular API Call
      system.ApiHandlingManager.Register(&amp;#34;/api/DataPlatform/PostEvent&amp;#34;,
          (ISystemRequest request) =&amp;gt;
          {
              var input = system.ApiHandlingManager.DeserializeObject&amp;lt;PostEventInput&amp;gt;(request.Body);
              Assert.IsTrue(input.Data[&amp;#34;PropString&amp;#34;].ToString() == &amp;#34;123&amp;#34;, &amp;#34;Prop String is incorrect.&amp;#34;);
              Assert.IsTrue(input.Data[&amp;#34;PropBool&amp;#34;].ToString() == &amp;#34;False&amp;#34;, &amp;#34;Prop Bool is incorrect.&amp;#34;);
              Assert.IsTrue((input.Data[&amp;#34;PropDecimal&amp;#34;] as JValue).ToString(CultureInfo.InvariantCulture) == &amp;#34;113.1&amp;#34;, &amp;#34;Prop Decimal is incorrect.&amp;#34;);
hasBeenCalled = true;
return (new SystemReply(200, system.ApiHandlingManager.SerializeObject(new PostEventOutput())));
          });
// Send the starting System event
      controllerInstance.SystemEvent(&amp;#34;Cmf.Test.PostEventDataFlow&amp;#34;, new
      {
          propString = &amp;#34;123&amp;#34;,
          propDecimal = 113.1,
          propBool = false
      });
// Check if post event was received
      scenario.Utilities.WaitFor(5, &amp;#34;It was never invoked&amp;#34;, () =&amp;gt;
      {
          return hasBeenCalled;
      });
}
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test setup is the same. In this test we will use the &lt;code&gt;system.ApiHandlingManager&lt;/code&gt; to register our mock api. Whenever our API is called, the callback will activate. We will then check the request we received and reply back with the expected object. By registering APIs we are able to test all interactions from the MES to the Automation layer.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Example IPC-CFX Running MES System
&lt;div id="example-ipc-cfx-running-mes-system" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#example-ipc-cfx-running-mes-system" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;Now let&amp;rsquo;s look at an example where we are using a different protocol and are running not against a mock system but against a running system.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; You will have to setup your runsettings file to point to the system you wish to test, this is normally done when creating a test project using the CM CLI.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;I have an SMT Line Reflow Oven integration. This integration will create materials through an SMT Line and then will execute the reflow oven actions through using IPC-CFX.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Reflow Oven"
src="https://image.j-roque.com/posts/20250516-testinglowcode/img/reflowoven.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It will use the &lt;a
href="https://www.connectedfactoryexchange.com/cfxdemo/sdk/html/T_CFX_Production_WorkStarted.htm"
target="_blank"
&gt;Work Started&lt;/a&gt; event to perform an MES Track-In, a &lt;a
href="https://www.connectedfactoryexchange.com/html/T_CFX_Production_Processing_UnitsProcessed.htm"
target="_blank"
&gt;UnitsProcessed&lt;/a&gt; to send a message to IoT Data Platform and then send a &lt;a
href="https://www.connectedfactoryexchange.com/cfxdemo/sdk/html/T_CFX_Production_WorkCompleted.htm"
target="_blank"
&gt;Work Completed&lt;/a&gt; event to Track-Out the material.&lt;/p&gt;
&lt;p&gt;The scenario for this test run is a bit different.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[TestInitialize]
public void Initialize()
{
  var driverHandle = &amp;#34;CMF.Driver.IPCCFX&amp;#34;;
  var driver = $&amp;#34;amqp://localhost:5001&amp;#34;;
  var driverAddress = &amp;#34;/queue/CMF.Driver.IPCCFX&amp;#34;;
  var targetHandle = &amp;#34;oven.test.machine&amp;#34;;
  var target = $&amp;#34;amqp://localhost:5672&amp;#34;;
  var managerName = &amp;#34;IPC-CFX Manager&amp;#34;;
Configuration = new ScenarioConfiguration()
    .ManagerId(&amp;#34;IPC-CFXManager&amp;#34;)
    .WriteLogsTo(&amp;#34;c:/temp/CFX-Simulator.log&amp;#34;)// Activate this line to send the logs to a particular place
    .ConfigPath(&amp;#34;C:\\Users\\jroque\\Downloads\\IPC-CFXManager\\config.full.json&amp;#34;)
    .AddSimulatorPlugin&amp;lt;IPCCFX.PluginMain&amp;gt;(new IPCCFX.Plugin.SettingsBuilder()
      .AddBroker()
      .AddTestCFXEndpoint(targetHandle, &amp;#34;&amp;#34;, target)
      .AddConnectIoTCFXEndpoint(driverHandle, driverAddress, driver)
    .StartMode&amp;lt;LocalStartMode.PluginMain&amp;gt;(new LocalStartMode.Plugin.SettingsBuilder()
      .ManagerLocation(&amp;#34;C:\\Users\\jroque\\Downloads\\IPC-CFXManager&amp;#34;)
      .Build())
  .Build());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For this test I used a downloaded manager from the system and I am connecting to the system using the downloaded configuration. The IPC-CFX simulator will require me to add a broker, a CFX Endpoint and also information about my integration endpoint.&lt;/p&gt;
&lt;p&gt;My test will now consist of sending CFX messages and validate that in the MES the corresponding object are in the correct state. Here we will focus on testing the material tracking events.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;(...)
var events = new Events();
var transactionId = Guid.NewGuid();
#region Work Started
var workStarted = new CFX.Production.WorkStarted
{
    TransactionID = transactionId,
    PrimaryIdentifier = panel.Name,
    Lane = 1
};
_scenario.Log.Debug($&amp;#34;Work Started &amp;#39;{panel.Name}&amp;#39;&amp;#34;);
this._cfxSimulators[&amp;#34;Oven&amp;#34;].SendMessage(new CFX.CFXEnvelope(workStarted),
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionMode.Publish,
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionDestination.Exchange);
_scenario.Utilities.WaitFor(60, $&amp;#34;Oven material for &amp;#39;{panel.Name}&amp;#39; never trackedin&amp;#34;, () =&amp;gt;
{
    panel = new GetObjectByNameInput()
    {
        Name = panel.Name,
        Type = typeof(Cmf.Navigo.BusinessObjects.Material)
    }.GetObjectByNameSync().Instance as Cmf.Navigo.BusinessObjects.Material;
return panel?.SystemState == MaterialSystemState.InProcess;
});
#endregion Work Started
var product = new GetObjectByIdInput()
{
    Id = panel.Product.Id,
    Type = typeof(Cmf.Navigo.BusinessObjects.Product)
}.GetObjectByIdSync().Instance as Cmf.Navigo.BusinessObjects.Product;
var unitsProcessed = new CFX.Production.Processing.UnitsProcessed
{
    TransactionId = transactionId,
    OverallResult = CFX.Structures.ProcessingResult.Succeeded,
    CommonProcessData = events.Products.FirstOrDefault(prod =&amp;gt; prod.Key == product.Name).Value
};
_scenario.Log.Debug($&amp;#34;Units Processed &amp;#39;{panel.Name}&amp;#39;&amp;#34;);
this._cfxSimulators[&amp;#34;Oven&amp;#34;].SendMessage(new CFX.CFXEnvelope(unitsProcessed),
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionMode.Publish,
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionDestination.Exchange);
#region Work Completed
var workCompleted = new CFX.Production.WorkCompleted
{
    TransactionID = transactionId,
    PrimaryIdentifier = panel.Name
};
_scenario.Log.Debug($&amp;#34;Work Completed &amp;#39;{panel.Name}&amp;#39;&amp;#34;);
this._cfxSimulators[&amp;#34;Oven&amp;#34;].SendMessage(new CFX.CFXEnvelope(workCompleted),
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionMode.Publish,
    Cmf.Connect.IoT.Driver.IpcCfx.DriverObjects.CommandExecutionDestination.Exchange);
_scenario.Utilities.WaitFor(60, $&amp;#34;Oven material for &amp;#39;{panel.Name}&amp;#39; never trackedout&amp;#34;, () =&amp;gt;
{
    panel = new GetObjectByNameInput()
    {
        Name = panel.Name,
        Type = typeof(Cmf.Navigo.BusinessObjects.Material)
    }.GetObjectByNameSync().Instance as Cmf.Navigo.BusinessObjects.Material;
return panel?.SystemState == MaterialSystemState.Queued;
});
#endregion Work Completed&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that now my test does not need any links to master data, now all the information used is the one inside the the running MES system. Also, the test has to be more complex as it has to prepare the MES objects to be in the correct scenario for the Oven integration to be tested. In the snippet of code we only show the pertinent part to the test orchestrator tool.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250516-testinglowcode/img/runningagainstsystem.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;Running against a live local system is a good way to work for developing a solution as every change in the workflow will impact the test run. Personally I work against my local system to create a solution, when I finish I export my solution and then point my test against the IoT Data package, so I am sure that what was tested against development works against what was exported.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;There are several more variations of the use of this tool and of how you can mock all the externalities. Hopefully this post jogs your curiosity and prompts you to start using the tool.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250516-testinglowcode/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250516-testinglowcode/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Something is Better than Nothing</title>
<link>https://j-roque.com/posts/20250424-somethingbetterthannothing/</link>
<pubDate>Thu, 24 Apr 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250424-somethingbetterthannothing/</guid>
<description>Some thoughts on the impact of being too pessimistic as a developer</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250424-somethingbetterthannothing/featured.png" alt="Something is Better than Nothing" /&gt;&lt;/p&gt;&lt;p&gt;&lt;code&gt;Something is better than nothing.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This is a very profound notion and one that is intrinsic to the whole concept of an engineer. He is someone that wishes to change what is to what could be in a very practical and flawed sense.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Motivation
&lt;div id="motivation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#motivation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;This week I had an architecture meeting. These meetings serve to help people in the organization that have complex requirements and need a third-party opinion. In this group we leverage the experience of some members to provide feedback and challenge requirements. These can be highly technical topics or can be understanding a bit more if the requirements make sense. They are also good ways to engage with the overall community and understand where people feel less confident.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Architecture Group"
src="./img/architecturegroup.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In this particular meeting, I could only participate the first 15 minutes and then had to leave to another meeting. Yes, the modern day bliss of endless meetings. To me in those first 15 minutes I was completely relieved, the requirement was simple and straightforward, probably the simplest anyone had ever submitted to this group.&lt;/p&gt;
&lt;p&gt;Jump to one hour after, I see the colleague that submitted the issue and apologize to him for having to leave the meeting early and asked him how the rest of the meeting went. What I saw was mind boggling. He looked at me with what could only be described as terror. His first sentence was simple and clear - &amp;ldquo;I don&amp;rsquo;t know if I am going to be able to do it&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;I was left in complete surprise and promptly asked him if the requirements were the one&amp;rsquo;s that I thought. He replied they were exactly the one&amp;rsquo;s I said. To which I followed with - &amp;ldquo;But that is just doing x and y. That&amp;rsquo;s two days tops if you have no experience and if you use the first day playing guitar (a hobby of this colleague)&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;What went next was what prompted this blog post. He started saying things that I can only qualify as a cross between raving techno babble and demon speak. From concurrency problems, to parallelization strategies to scaling. A demented rant, not generated by his own mind of course, but by someone on the architecture forum, that instilled in him not the fear of God but something also quite terrible, the fear of coding.&lt;/p&gt;
&lt;p&gt;He was injected with a terrible disease. All the possible use cases where something went wrong, of the roads wrongly taken, the bottomless pits an apocalypse of coding. Like a dooms day prophet this person in the architecture group, which is not doubt a very capable and intelligent developer, shot so many concepts, abstractions and possibilities of problems that it ended in what I can only describe as a Human Stack Overflow. It&amp;rsquo;s pernicious and devilish as everything he said was true, every concept, every possibility, was valid and possible. It was a whole litany of truisms.&lt;/p&gt;
&lt;p&gt;Did it matter an once for the actual problem? No, it sure didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;I spent the next half hour in desperation and unbelief trying to deconstruct all of this and explaining to my colleague that a method call doesn&amp;rsquo;t always end in a nuclear plant meltdown. I felt he left unconvinced, I hope he didn&amp;rsquo;t give up software development and take on geese farming, but I am not convinced he won&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;This is a problem that I see a lot in very senior people and in software development generally. Sometimes we forget that we are engineers. We are here to perform the impossible. The impossible in the sense, of things that are not naturally occurring, until with our will we wished them into being. Therefore, how can we expect our will to be perfect. No engineer has ever built the perfect system, but should the infeasibility of perfection stop us from our goal of creation?&lt;/p&gt;
&lt;p&gt;The answer is simple, let&amp;rsquo;s be practical! Let&amp;rsquo;s not let future issues and the inevitability of mistakes, stop us from the wonder and let&amp;rsquo;s face it, the fun of creation.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Hearing you in my head
&lt;div id="hearing-you-in-my-head" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hearing-you-in-my-head" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;I know what you are thinking. He is saying vibe code all the way, just code away without any regards for performance or testing. After all, what the colleague said was all completely valid, they were true warnings.&lt;/p&gt;
&lt;p&gt;My argument is a very hard one.&lt;/p&gt;
&lt;p&gt;I am defending something that is like an appeal to common sense, proper measure, a middle way.&lt;/p&gt;
&lt;p&gt;Should your code be performant? That depends, what is the effort and ROI (return on investment) of making your code more performant. For some applications, performance is critical all edge cases are important, but let&amp;rsquo;s face it those are rare. Even in those types of applications, there are parts that have those requirements and there are other parts where it doesn&amp;rsquo;t really matter.&lt;/p&gt;
&lt;p&gt;Is it interesting to see the performance &lt;a
href="https://stackoverflow.com/questions/22851234/linq-vs-foreach-vs-for-performance-test-results"
target="_blank"
&gt;difference between LINQ, for and foreach?&lt;/a&gt; Yes. Is that more important than writing code that everyone can understand at a glance? Depends. That is why an engineer is not a typist in C# or JS, he is someone that is able to understand a system and optimize what needs to be optimized and make trade offs.&lt;/p&gt;
&lt;p&gt;If you aim for no trade offs you will either be stuck in endless development or you will be so paralyzed by the immenseness of the labour ahead that you don&amp;rsquo;t even start.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Dunning–Kruger effect
&lt;div id="dunningkruger-effect" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dunningkruger-effect" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;The Dunning–Kruger effect is a very interesting cognitive bias where it basically states that the less knowledge you have about a topic, the more you tend to overestimate your ability in that topic. Numerous bad decisions have been made due to this and most software development methodologies try to deploy counter measures to this effect. This is always a hot topic when we are paternalistic regarding our more junior colleagues folly.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Dunning Kruger"
src="./img/dunningkruger.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In a sense, the mountain is high and the climb is hard, it is also harder than you would expect in the base of the mountain, but should you stop climbing?&lt;/p&gt;
&lt;p&gt;Sometimes I wonder if that naivety and folly of misjudging is also what helps us take risk and surpass ourselves. I fear that the end of the dunning-kruger curve may sometimes not be a hardened and seasoned old man, joyful to teach and spread the good news, the joy of reaching the summit, but is a ragged and disillusioned, grinch like cynic who raves about the lunacy of climbing.&lt;/p&gt;
&lt;h2 class="relative group"&gt;Final Thoughts
&lt;div id="final-thoughts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#final-thoughts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Solving engineering problems is fun. Most problems are not complex, but they are a complex problem with a multitude of simple problems. Tackle the simple problems to start building to the complex problems.&lt;/p&gt;
&lt;p&gt;Don&amp;rsquo;t be paralyzed by the possibility of failure. You will fail. Learn to fail gracefully. Feature toggles, testing, documentation, assume you will fail and create the strategies to react to a failure.&lt;/p&gt;
&lt;p&gt;Be joys and wondrous for working in building things that weren&amp;rsquo;t there until you willed them into being and remember something is always better than nothing.&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250424-somethingbetterthannothing/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250424-somethingbetterthannothing/featured.png" type="image/png" medium="image"/>
</item>
<item>
<title>Full 3D Printer Automation</title>
<link>https://j-roque.com/posts/20250407-3dprinter/</link>
<pubDate>Mon, 07 Apr 2025 00:00:00 +0000</pubDate>
<author>joao.roque.94@gmail.com (João Roque)</author>
<guid>https://j-roque.com/posts/20250407-3dprinter/</guid>
<description>Using Connect IoT to integrate with a 3D Printer</description>
<content:encoded>&lt;p&gt;&lt;img src="https://j-roque.com/posts/20250407-3dprinter/featured.png" alt="Full 3D Printer Automation" /&gt;&lt;/p&gt;&lt;p&gt;Integrating a &lt;a
href="https://www.prusa3d.com/category/original-prusa-mini/"
target="_blank"
&gt;Prusa mini 3D printer&lt;/a&gt; into an MES system.&lt;/p&gt;
&lt;h1 class="relative group"&gt;Overview
&lt;div id="overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h1&gt;
&lt;p&gt;I have been wanting to integrate my personal 3D printer into the CM MES for some time. I think it&amp;rsquo;s a simple example of how you can map integration touch points to bring visibility and control to the shopfloor. It&amp;rsquo;s not uncommon to see additive manufacturing elements being brought to the shopfloor, either for prototyping, spare parts production or event for the production process itself.&lt;/p&gt;
&lt;p&gt;The 3D Printer has a Raspberry Pi 4b with a camera and is running &lt;a
href="https://octoprint.org/"
target="_blank"
&gt;OctoPi&lt;/a&gt;. OctoPi is a very common, remote controller and UI for 3D printer control. It uses the printer serial port interface and is able to send and receive information from the printer.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="3D Printer"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/3dprinter.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For this integration, we will:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Retrieve a recipe from the printer&lt;/li&gt;
&lt;li&gt;Validate the recipe on Track-In&lt;/li&gt;
&lt;li&gt;Start the printing job&lt;/li&gt;
&lt;li&gt;Perform an Assemble - Explicit Add&lt;/li&gt;
&lt;li&gt;Track-Out the material.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;MES Model Overview
&lt;div id="mes-model-overview" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#mes-model-overview" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;For this scenario we will have a resource &lt;code&gt;3d Printer&lt;/code&gt; and a step &lt;code&gt;3d Print&lt;/code&gt;. The &lt;code&gt;3d Printer&lt;/code&gt; will also have a resource consumable feed called &lt;code&gt;Filament Feeder&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The consumable feeds in the MES are where we can attach our raw materials, so that they are able to be consumed. In my use case my printer has only one filament slot, but for other printers we could have several consumable feeds each one capable of having different materials with different products, for example the use case of multiple colors or multiple filament types.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Resource View Consumable Feed"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/resource-consumablefeed.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Notice that we have already attached a material &lt;code&gt;Blue PETG-001&lt;/code&gt; with product &lt;code&gt;Blue PETG Spool&lt;/code&gt; and that this matches what we see in the actual printer.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Resource View Consumable Feed"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/printer-feeder-real.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;One important aspect of our integration is that we will have a &lt;code&gt;BOM&lt;/code&gt; (bill of materials) to describe the materials required for our product and a &lt;code&gt;Recipe&lt;/code&gt; to describe what will be the machine recipe to be applied. We will see more information about both further on the use case.&lt;/p&gt;
&lt;p&gt;There were other configurations that are relevant, like creating a product, defining a material flow, defining what services the resource provides. Explanations on these topics is out of scope for our goal today. Here are some helpful references to our documentation portal:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/routing-and-dispatching/flow-modeling/flowmodeling/"
target="_blank"
&gt;Creating a Flow Model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/materials-and-containers/how-to/howto_createproduct/"
target="_blank"
&gt;How To: Create a Product&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/materials-and-containers/how-to/howto_creatematerial/"
target="_blank"
&gt;How To: Create a Material&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/resource-tracking/how-to/howto_createresource/"
target="_blank"
&gt;How To: Create a Resource&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Create an Automation Driver Definition REST Client
&lt;div id="create-an-automation-driver-definition-rest-client" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#create-an-automation-driver-definition-rest-client" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Create an &lt;code&gt;Automation Protocol&lt;/code&gt; for &lt;code&gt;REST Client&lt;/code&gt; with the default configurations.&lt;/p&gt;
&lt;p&gt;As stated before we will integrate with OctoPi. OctoPi has very good documentation that can be read &lt;a
href="https://docs.octoprint.org/en/master/api/index.html"
target="_blank"
&gt;here&lt;/a&gt;. According to this specification let&amp;rsquo;s map the APIs we need.&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; It is common when facing an interface for it to have a lot commands and events which we are not interested in using. For some protocols, like opc-ua, the very act of registering an event can have performance impact as it is making a subscription in the target machine. As a system integrator mapping only what you need for your integration is a good rule of thumb.&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;Two important APIs to understand the state of the printer are &lt;code&gt;/api/connection&lt;/code&gt; and &lt;code&gt;api/printer&lt;/code&gt;, one to retrieve the connection status and the other for the printer status.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connection Status"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/octopi-constatus.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can map the connection status as:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connection Status MES"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-constatus.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Also, for the printer status:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Printer Status"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/octopi-printerstatus.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can map the printer status as:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connection Status MES"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-printerstatus.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For this use case, we will also retrieve a the recipe list and download the Recipe Body.&lt;/p&gt;
&lt;p&gt;For the recipe list we can leverage the files api:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Files"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/octopi-files.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Which we can then map as:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Recipe List"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-recipelist.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For our use case we want to receive all possible recipes, so we send with a query path recursive as true.&lt;/p&gt;
&lt;p&gt;The download body we will also use the files api.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Download Recipe"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-downloadrecipe.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Download Recipe Argument"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-downloadrecipepathparameter.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;For the download recipe the user will have to invoke the command with a recipe id. The driver will use the id provided to replace it in the url.&lt;/p&gt;
&lt;p&gt;Now we have the action APIs, these are the APIs that are going to be used to perform actions in the printer.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ConnectToPrinter&lt;/code&gt; will create a connection between OctoPi and the printer.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connect To Printer"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-connectprinter.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The connect to printer requires a specific set of arguments. For this use case we will set the expected value in the default, this can be overridden by the integration when invoking the command.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{  &amp;#34;command&amp;#34;: &amp;#34;connect&amp;#34;,  &amp;#34;port&amp;#34;: &amp;#34;AUTO&amp;#34;,  &amp;#34;baudrate&amp;#34;: 0,  &amp;#34;printerProfile&amp;#34;: &amp;#34;_default&amp;#34;,  &amp;#34;save&amp;#34;: false,  &amp;#34;autoconnect&amp;#34;: true}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Connect To Printer Arguments"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-connectprinterargs.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;SelectRecipe&lt;/code&gt; will upload the recipe from OctoPi to the printer.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Select Recipe"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-selectrecipe.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;One interesting thing about this API is that it requires the recipe id, similar to the download, but also that success is returning as a 204. This is very common that is why in the REST Client driver you can have comma separated status code, that qualify what is a success.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Select Recipe Argument"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-selectrecipearguments.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{&amp;#34;command&amp;#34;: &amp;#34;select&amp;#34;, &amp;#34;print&amp;#34;: false}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This API also requires a body. This API would allow to select the recipe and print, but for this use case I decided to separate it into two different moments.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;StartJob&lt;/code&gt; will create a new printer job, so it can start printing.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Job"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-startjob.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Start Job Argument"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-startjobarguments.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{  &amp;#34;command&amp;#34;: &amp;#34;start&amp;#34;}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This API also requires a body.&lt;/p&gt;
&lt;p&gt;Finally, we require an API to track the status of the printing job.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Job Status"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-jobstatus.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This api will inform the job progression status. When the Job reaches completion 100%, we will assemble and track-out.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Create an Automation Controller
&lt;div id="create-an-automation-controller" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#create-an-automation-controller" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Creating our controller, we will specify that it has one driver for &lt;code&gt;REST Client&lt;/code&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Setup
&lt;div id="setup" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#setup" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In our setup page the template will automatically generate the driver quickstart. In previous posts we used &lt;code&gt;Data Flow&lt;/code&gt; when creating the controller and it generated the template in &lt;code&gt;Data Flow&lt;/code&gt; this time I chose &lt;code&gt;Control Flow&lt;/code&gt;, so we will have an example of a &lt;code&gt;Control Flow&lt;/code&gt; template.&lt;/p&gt;
&lt;p&gt;For setting up the connection, we will configure our connection, then call a function, if successful we will finish the connection cycle. For simpler setup cycles, the OnInitialize event has an auto setup flag that will merge both actions, of course this is contingent on your specific integration.&lt;/p&gt;
&lt;p&gt;The call function will check what is the current communication status. If the status is &lt;code&gt;Closed&lt;/code&gt; it means we will need to perform the connection from OctoPi to the Printer. If it&amp;rsquo;s &lt;code&gt;Operational&lt;/code&gt; or &lt;code&gt;Printing&lt;/code&gt; it means the system is already live and running.&lt;/p&gt;
&lt;p&gt;Depending on your integration boot cycles and what constitutes being connected can be simpler or more complex.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Setup"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/setup.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In yellow, we can see the initialization of the driver, here we will set all the communication values, like the REST API to connect to, port and for OctoPi the API token. All OctoPi requests must have a header X-API-Key with the api key. This would be very cumbersome to have to configure this in the driver definition for all the requests. That is why you can define it in the Equipment Configuration and it will impact all the requests.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Setup API"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-setupapikey.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In green, is where we will perform the setup actions. On the right side we define a function that is to be invoked in the setup.&lt;/p&gt;
&lt;p&gt;This function will execute a call to check the connection status and will either, send a connect to the printer, do nothing as the printer may already be connected or throw an exception if the connection is in an invalid state.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Setup Function"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-setupfunction.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Recipe Selection
&lt;div id="recipe-selection" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#recipe-selection" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the recipe selection page we will handle selecting a recipe and uploading it to the MES.&lt;/p&gt;
&lt;p&gt;The recipe selection has two flows, one is to list recipes and another to download recipe bodies. There are two native IoT tasks, the &lt;code&gt;Recipe List Requested&lt;/code&gt; and the &lt;code&gt;Recipe Body Requested&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the &lt;code&gt;Recipe List Requested&lt;/code&gt;, when we receive a call we will reply back with an array of recipe names. In order to extract the recipe names from a REST API call we will use the JSONata task.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Recipe List"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-recipelistwf.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The API response is:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;#34;files&amp;#34;: [
    {
      &amp;#34;date&amp;#34;: 1743496451,
      &amp;#34;display&amp;#34;: &amp;#34;nut_0.2mm_PETG_MINI_10m.gcode&amp;#34;,
      &amp;#34;gcodeAnalysis&amp;#34;: {
        &amp;#34;dimensions&amp;#34;: {
          &amp;#34;depth&amp;#34;: 105.835,
          &amp;#34;height&amp;#34;: 7.0,
          &amp;#34;width&amp;#34;: 130.0
        },
        &amp;#34;estimatedPrintTime&amp;#34;: 525.7696335117364,
        &amp;#34;filament&amp;#34;: {
          &amp;#34;tool0&amp;#34;: {
            &amp;#34;length&amp;#34;: 295.8933699999964,
            &amp;#34;volume&amp;#34;: 0.7117069599134038
          }
        },
        &amp;#34;printingArea&amp;#34;: {
          &amp;#34;maxX&amp;#34;: 170.0,
          &amp;#34;maxY&amp;#34;: 103.835,
          &amp;#34;maxZ&amp;#34;: 7.0,
          &amp;#34;minX&amp;#34;: 40.0,
          &amp;#34;minY&amp;#34;: -2.0,
          &amp;#34;minZ&amp;#34;: 0.0
        },
        &amp;#34;travelArea&amp;#34;: {
          &amp;#34;maxX&amp;#34;: 179.0,
          &amp;#34;maxY&amp;#34;: 178.0,
          &amp;#34;maxZ&amp;#34;: 37.0,
          &amp;#34;minX&amp;#34;: 0.0,
          &amp;#34;minY&amp;#34;: -2.0,
          &amp;#34;minZ&amp;#34;: 0.0
        },
        &amp;#34;travelDimensions&amp;#34;: {
          &amp;#34;depth&amp;#34;: 180.0,
          &amp;#34;height&amp;#34;: 37.0,
          &amp;#34;width&amp;#34;: 179.0
        }
      },
      &amp;#34;hash&amp;#34;: &amp;#34;c88736e62a260028d6df98218d1a36c0dbae0463&amp;#34;,
      &amp;#34;name&amp;#34;: &amp;#34;nut_0.2mm_PETG_MINI_10m.gcode&amp;#34;,
      &amp;#34;origin&amp;#34;: &amp;#34;local&amp;#34;,
      &amp;#34;path&amp;#34;: &amp;#34;nut_0.2mm_PETG_MINI_10m.gcode&amp;#34;,
      &amp;#34;prints&amp;#34;: {
        &amp;#34;failure&amp;#34;: 4,
        &amp;#34;last&amp;#34;: {
          &amp;#34;date&amp;#34;: 1743702683.226399,
          &amp;#34;printTime&amp;#34;: 809.2995931629994,
          &amp;#34;success&amp;#34;: true
        },
        &amp;#34;success&amp;#34;: 3
      },
      &amp;#34;refs&amp;#34;: {
        &amp;#34;download&amp;#34;: &amp;#34;http://raspberrypi-octopi.local/downloads/files/local/nut_0.2mm_PETG_MINI_10m.gcode&amp;#34;,
        &amp;#34;resource&amp;#34;: &amp;#34;http://raspberrypi-octopi.local/api/files/local/nut_0.2mm_PETG_MINI_10m.gcode&amp;#34;
      },
      &amp;#34;size&amp;#34;: 347321,
      &amp;#34;statistics&amp;#34;: {
        &amp;#34;averagePrintTime&amp;#34;: {
          &amp;#34;_default&amp;#34;: 887.0897298313794
        },
        &amp;#34;lastPrintTime&amp;#34;: {
          &amp;#34;_default&amp;#34;: 809.2995931629994
        }
      },
      &amp;#34;type&amp;#34;: &amp;#34;machinecode&amp;#34;,
      &amp;#34;typePath&amp;#34;: [
        &amp;#34;machinecode&amp;#34;,
        &amp;#34;gcode&amp;#34;
      ]
    }
  ],
  &amp;#34;free&amp;#34;: 27399016448,
  &amp;#34;total&amp;#34;: 31109500928
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The JSONata expression is quite simple &lt;code&gt;files.name&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the &lt;code&gt;Recipe Body Requested&lt;/code&gt;, when we receive a call we will use the recipe id and call the &lt;code&gt;Download Recipe&lt;/code&gt;, we will then use the response and apply a converter to convert it into a &lt;code&gt;Base64&lt;/code&gt; string.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Download Recipe"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-downloadrecipewf.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Recipe Selection - MES
&lt;div id="recipe-selection---mes" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#recipe-selection---mes" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;When we have a running manager with this controller we can use this integration to chose a recipe. We can create a recipe and choose a body with &lt;code&gt;Source&lt;/code&gt; &lt;code&gt;Downloaded From Equipment&lt;/code&gt;. The action of &lt;code&gt;Get Recipe&lt;/code&gt; will query the &lt;code&gt;Recipe List Requested&lt;/code&gt; for the instance of the selected Resource. For the selection of recipes and then when selecting &lt;code&gt;Download Recipe&lt;/code&gt; it will grab a Base64 string and store it in the MES and also create a Recipe Checksum.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MES Select Recipe"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/select-recipe.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div
class="flex px-4 py-3 rounded-md bg-primary-100 dark:bg-primary-900"
&gt;
&lt;span
class="text-primary-400 pe-3 flex items-center"
&gt;
&lt;span class="relative block icon"&gt;&lt;svg width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/&gt;&lt;/svg&gt;
&lt;/span&gt;
&lt;/span&gt;
&lt;span
class="dark:text-neutral-300"
&gt;&lt;strong&gt;Info:&lt;/strong&gt; A Resource can be used for Recipe Download if it has recipe management enabled, automation mode &lt;code&gt;Online&lt;/code&gt; and then has the recipe relevant checkboxes. The Resources available for the &lt;code&gt;Get Recipe&lt;/code&gt; are the one&amp;rsquo;s configured as mode &lt;code&gt;Online&lt;/code&gt; and with the checkboxes enabled.&lt;/span&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/tutorials/modules/recipe-management/?h=recipe/"
target="_blank"
&gt;Recipe Management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a
href="https://help.criticalmanufacturing.com/userguide/business-data/recipe/manage_recipe_body/?h=recipe%2F/#step-1-manage-body"
target="_blank"
&gt;Manage Recipe Body&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 class="relative group"&gt;Recipe Validation
&lt;div id="recipe-validation" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#recipe-validation" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;One of the common requests when handling recipes is the need to always make sure that the MES has the latest version of the recipe. It is not uncommon for a machine recipe to be changed and keep the same name. For this use case in the Track-In action we will perform a validation of the recipe body.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Recipe Validation"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-recipevalidation.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This workflow can be split into three actions. In green, the workflow listener and replier to the topic &lt;code&gt;Validate Recipe&lt;/code&gt;. In blue, the download of the recipe body and in grey, the calculation of the recipe body checksum, the comparison between the MES recipe checksum and the machine recipe checksum and then we create a reply to the MES.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Material Handling
&lt;div id="material-handling" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#material-handling" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the material handling page we will handle all the actions regarding the material. There will be several different actions, let&amp;rsquo;s go step by step.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Material Handling"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-materialhandling.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 class="relative group"&gt;Start Printing Job
&lt;div id="start-printing-job" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#start-printing-job" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;The first action of the flow will be the listener for the topic &lt;code&gt;OnTrackIn&lt;/code&gt;. When it receives a message in this topic, it will perform the &lt;code&gt;Select Recipe&lt;/code&gt; and the &lt;code&gt;Start Job&lt;/code&gt;. It will also save a set of global variables, one of those will start the polling for the job status.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller TrackIn"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-trackin.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It will store the $global.material, $global.recipe and the Start Job will store the $global.JobStarted.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Job Started"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-jobstarted.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 class="relative group"&gt;Receive BOM information
&lt;div id="receive-bom-information" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#receive-bom-information" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;For the track-in we have actions that must occur before the track-in transaction like the recipe validation, but we also want some information that is only resolved after the transaction is complete like the BOM information. We will have a listener for BOM information that will just store the information in the global context.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller BOM Info"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-bominfo.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;It will store the $global.BOMProduct, $global.BOMName, $global.feederMaterial and the $global.feederMaterialProduct.&lt;/p&gt;
&lt;h4 class="relative group"&gt;Job Finish Polling
&lt;div id="job-finish-polling" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#job-finish-polling" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h4&gt;
&lt;p&gt;When the Job Started is finished it will start the timer for the job status monitoring.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Job Status Polling"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-jobfinishtimer.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;This timer will execute every 30 seconds and will query the printer for the &lt;code&gt;Job Status&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If the printer is in an unexpected state it will throw an exception.&lt;/p&gt;
&lt;p&gt;If the job status is &lt;code&gt;Operational&lt;/code&gt; or &lt;code&gt;Printing&lt;/code&gt; and the completion is 100% it will execute an assemble and a track-out of the material.&lt;/p&gt;
&lt;p&gt;We will create outputs in the &lt;code&gt;Job Status&lt;/code&gt; that will parse the information and make it easier to use it.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Automation Controller Job Status"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-jobstatuswf.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;If the job is not yet finished we will log the completion rate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Creating the Business Logic
&lt;div id="creating-the-business-logic" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#creating-the-business-logic" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;In our workflows we created three system listeners. We have the &lt;code&gt;OnTrackIn&lt;/code&gt;, &lt;code&gt;ValidateRecipe&lt;/code&gt; and the &lt;code&gt;BOMData&lt;/code&gt;.&lt;/p&gt;
&lt;h3 class="relative group"&gt;Developer Portal
&lt;div id="developer-portal" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#developer-portal" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;The MES is able to execute logic before and after system transactions. In our developer portal we have a detailed explanation of both the MES REST API and detailing all the action groups where we can hook the business logic.&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://developer.criticalmanufacturing.com/11.1/reference/index.html"
target="_blank"
&gt;Critical Manufacturing MES API Reference&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Critical Manufacturing MES API Reference"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/devportal-mesapiref.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We will want to hook our events when the user tracks-in the material.&lt;/p&gt;
&lt;p&gt;In the developer portal we can take a look at the REST API as if we were calling it with an external REST application.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Track In Swagger"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/devportal-trackinswagger.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;As we are using the default system hooking mechanism and the call is being made by a button press in the Resource View UI track-in button, we do not have to know all of that API.&lt;/p&gt;
&lt;p&gt;If we go to the &lt;code&gt;DEE Reference&lt;/code&gt; page:&lt;/p&gt;
&lt;p&gt;&lt;a
href="https://developer.criticalmanufacturing.com/11.1/reference/api-extensionpoints/index.html"
target="_blank"
&gt;Critical Manufacturing MES Extension Points Reference&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Critical Manufacturing MES Extension Points Reference"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/devportal-mesextensionpoints.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We will have an introduction of the hooking mechanism and on the right side a list of all the extension points.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Critical Manufacturing MES Extension Points Reference Search TrackIn"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/devportal-trackinsearch.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can search by our action and then we will see all the possible extension points. For our use case we will perform a simple track-in so we will use the &lt;code&gt;TrackInMaterials&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Critical Manufacturing MES Extension Points Reference TrackInMaterials"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/devportal-trackinmaterials.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Create a DEE
&lt;div id="create-a-dee" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#create-a-dee" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;In the MES we can add the action group, both for pre and post:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MaterialManagement.MaterialManagement.TrackInMaterials.Pre&lt;/li&gt;
&lt;li&gt;MaterialManagement.MaterialManagement.TrackInMaterials.Post&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="MES DEE"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-dee.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;The first action of our extensibility DEE will be to handle the recipe validation and start the printing job.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;#region Info
/// &amp;lt;summary&amp;gt;
/// Summary text
///     Start Printing Job - Will validate the Recipe, Start Printing Job and send BOM information
///    Assumptions:
/// Action Groups:
///     MaterialManagement.MaterialManagement.TrackInMaterials.Pre
///     MaterialManagement.MaterialManagement.TrackInMaterials.Post
/// Depends On:
/// Is Dependency For:
/// Exceptions:
/// &amp;lt;/summary&amp;gt;
#endregion Info
// System
UseReference(&amp;#34;&amp;#34;, &amp;#34;System.Data&amp;#34;);
UseReference(&amp;#34;Newtonsoft.Json.dll&amp;#34;, &amp;#34;Newtonsoft.Json.Linq&amp;#34;);
UseReference(&amp;#34;%MicrosoftNetPath%Microsoft.CSharp.dll&amp;#34;, &amp;#34;&amp;#34;);
// Foundation
UseReference(&amp;#34;Cmf.Foundation.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Foundation.BusinessObjects&amp;#34;);
UseReference(&amp;#34;Cmf.Foundation.BusinessOrchestration.dll&amp;#34;, &amp;#34;&amp;#34;);
UseReference(&amp;#34;&amp;#34;, &amp;#34;Cmf.Foundation.Common.Exceptions&amp;#34;);
UseReference(&amp;#34;&amp;#34;, &amp;#34;Cmf.Foundation.Common&amp;#34;);
// Navigo
UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessObjects&amp;#34;);
UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.Abstractions&amp;#34;);
UseReference(&amp;#34;Cmf.Navigo.BusinessOrchestration.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.ResourceManagement.InputObjects&amp;#34;);
// Intercept Transaction input
var serviceProvider = (IServiceProvider)Input[&amp;#34;ServiceProvider&amp;#34;];
var trackInMaterialsInput = Input[&amp;#34;TrackInMaterialsInput&amp;#34;] as Cmf.Navigo.BusinessOrchestration.MaterialManagement.InputObjects.TrackInMaterialsInput;
var resource = trackInMaterialsInput.Resource;
var instance = resource.GetAutomationControllerInstance(); // Retrieve automation instance
// Our Code will only apply to
//  - resources of Resource Type &amp;#34;3d Printer&amp;#34;
//  - that have an automation instance
if(resource.ResourceType == &amp;#34;3d Printer&amp;#34; &amp;amp;&amp;amp; instance != null) {
// Code to be executed on before the Track-In
    if(Input[&amp;#34;ActionGroupName&amp;#34;].ToString().Contains(&amp;#34;Pre&amp;#34;)) {
        var data = new
        {
            material = trackInMaterialsInput.Materials.FirstOrDefault().Name,
            recipe = trackInMaterialsInput.Recipe.ResourceRecipeName,
            recipeHash = trackInMaterialsInput.Recipe.BodyChecksum
        };
// Validate Recipe - If successful recipeValidationResult will be true
        dynamic reply = instance.SendRequest(&amp;#34;ValidateRecipe&amp;#34;, Newtonsoft.Json.JsonConvert.SerializeObject(data), 10000);
if((bool) reply.recipeValidationResult) {
            // Start Printing Job
            instance.SendRequest(&amp;#34;OnTrackIn&amp;#34;, Newtonsoft.Json.JsonConvert.SerializeObject(data), 10000);
        } else {
            throw new Exception(&amp;#34;Invalid Recipe&amp;#34;);
        }
    }
}
(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is very important to realize that when you hook a DEE Action to an action group it will be executed for every track in in the system. For our use case, we used the Resource resource type to be the anchor of executing this particular integration.&lt;/p&gt;
&lt;p&gt;If the Resource ResourceType is &lt;code&gt;3d Printer&lt;/code&gt; and the resource has an automation instance attached we will execute two actions. First we will send a message to the instance with the topic &lt;code&gt;Validate Recipe&lt;/code&gt; with a data object with relevant information. if the reply comes with a recipeValidationResult as true, we will then start the printing job, by sending a message to the topic &lt;code&gt;OnTrackIn&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The next phase of our DEE is to provide information regarding the bill of materials. The bill of materials for the material is resolved in the track in operation. If we hook in &lt;code&gt;Pre&lt;/code&gt; we will have the objects in a state pre transaction, if we hook in &lt;code&gt;Post&lt;/code&gt; we will already have the objects in the final state. In our case we wish to send the information that is resolved in the track-in transaction, so we can add the hook in &lt;code&gt;Post&lt;/code&gt; and send the information to the automation instance.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-cs"&gt;#region Info
/// &amp;lt;summary&amp;gt;
/// Summary text
///     Start Printing Job - Will validate the Recipe, Start Printing Job and send BOM information
///    Assumptions:
/// Action Groups:
///     MaterialManagement.MaterialManagement.TrackInMaterials.Pre
///     MaterialManagement.MaterialManagement.TrackInMaterials.Post
/// Depends On:
/// Is Dependency For:
/// Exceptions:
/// &amp;lt;/summary&amp;gt;
#endregion Info
// System
UseReference(&amp;#34;&amp;#34;, &amp;#34;System.Data&amp;#34;);
UseReference(&amp;#34;Newtonsoft.Json.dll&amp;#34;, &amp;#34;Newtonsoft.Json.Linq&amp;#34;);
UseReference(&amp;#34;%MicrosoftNetPath%Microsoft.CSharp.dll&amp;#34;, &amp;#34;&amp;#34;);
// Foundation
UseReference(&amp;#34;Cmf.Foundation.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Foundation.BusinessObjects&amp;#34;);
UseReference(&amp;#34;Cmf.Foundation.BusinessOrchestration.dll&amp;#34;, &amp;#34;&amp;#34;);
UseReference(&amp;#34;&amp;#34;, &amp;#34;Cmf.Foundation.Common.Exceptions&amp;#34;);
UseReference(&amp;#34;&amp;#34;, &amp;#34;Cmf.Foundation.Common&amp;#34;);
// Navigo
UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessObjects&amp;#34;);
UseReference(&amp;#34;Cmf.Navigo.BusinessObjects.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.Abstractions&amp;#34;);
UseReference(&amp;#34;Cmf.Navigo.BusinessOrchestration.dll&amp;#34;, &amp;#34;Cmf.Navigo.BusinessOrchestration.ResourceManagement.InputObjects&amp;#34;);
// Intercept Transaction input
var serviceProvider = (IServiceProvider)Input[&amp;#34;ServiceProvider&amp;#34;];
var trackInMaterialsInput = Input[&amp;#34;TrackInMaterialsInput&amp;#34;] as Cmf.Navigo.BusinessOrchestration.MaterialManagement.InputObjects.TrackInMaterialsInput;
var resource = trackInMaterialsInput.Resource;
var instance = resource.GetAutomationControllerInstance(); // Retrieve automation instance
// Our Code will only apply to
//  - resources of Resource Type &amp;#34;3d Printer&amp;#34;
//  - that have an automation instance
if(resource.ResourceType == &amp;#34;3d Printer&amp;#34; &amp;amp;&amp;amp; instance != null) {
// Code to be executed on before the Track-In
    if(Input[&amp;#34;ActionGroupName&amp;#34;].ToString().Contains(&amp;#34;Pre&amp;#34;)) {
        var data = new
        {
            material = trackInMaterialsInput.Materials.FirstOrDefault().Name,
            recipe = trackInMaterialsInput.Recipe.ResourceRecipeName,
            recipeHash = trackInMaterialsInput.Recipe.BodyChecksum
        };
// Validate Recipe - If successful recipeValidationResult will be true
        dynamic reply = instance.SendRequest(&amp;#34;ValidateRecipe&amp;#34;, Newtonsoft.Json.JsonConvert.SerializeObject(data), 10000);
if((bool) reply.recipeValidationResult) {
            // Start Printing Job
            instance.SendRequest(&amp;#34;OnTrackIn&amp;#34;, Newtonsoft.Json.JsonConvert.SerializeObject(data), 10000);
        } else {
            throw new Exception(&amp;#34;Invalid Recipe&amp;#34;);
        }
    } else {
        // Get Resource Current State
        resource.Load();
        // Retrieve BOM (bill of materials)
        var bom = resource.CurrentBOM;
        if(bom != null) {
string feederMaterial;
            string feederMaterialProduct;
var material = trackInMaterialsInput.Materials.FirstOrDefault();
#region Retrieve Feeder Material
// For Resource ResourceType &amp;#34;3d Printer&amp;#34; we will only have one consumable feed with one material
            IResourceOrchestration resourceOrchestration = ApplicationContext.CurrentServiceProvider.GetService&amp;lt;IResourceOrchestration&amp;gt;();
            var consumables = resourceOrchestration.GetConsumableFeeds(new GetConsumableFeedsInput(){
                OnlyFirstMaterials = true,
                Resource = resource
            });
DataSet ds = NgpDataSet.ToDataSet(consumables.ConsumableFeeds);
            if (HasData(ds))
            {
                if(ds.Tables[0].Rows.Count &amp;gt; 1){
                    throw new Exception(&amp;#34;Only one feeder is supported for 3D Printer&amp;#34;);
                }
                var row = ds.Tables[0].Rows[0];
                feederMaterial = row.Field&amp;lt;string&amp;gt;(&amp;#34;SubResourceTargetEntityMaterialResourceSourceEntityName&amp;#34;);
                feederMaterialProduct = row.Field&amp;lt;string&amp;gt;(&amp;#34;SubResourceTargetEntityMaterialResourceSourceEntityProductName&amp;#34;);
            } else {
                throw new Exception(&amp;#34;Please attach a Material to the Consumable Feeder&amp;#34;);
            }
#endregion
// Retrieve BOM Products
            bom.Load();
            bom.LoadBomProducts(material.Step, material.LogicalFlowPath);
            var data = new
            {
                BOMProduct = bom.BomProducts.FirstOrDefault().Name,
                BOMName = bom.Name,
                feederMaterial = feederMaterial,
                feederMaterialProduct = feederMaterialProduct
            };
instance.SendRequest(&amp;#34;BOMData&amp;#34;, Newtonsoft.Json.JsonConvert.SerializeObject(data), 10000);
        } else {
            throw new Exception(&amp;#34;Missing Mandatory BOM&amp;#34;);
        }
    }
}
static bool HasData(DataSet ds)
{
    return ds != null &amp;amp;&amp;amp; ds.Tables != null &amp;amp;&amp;amp; ds.Tables.Count &amp;gt; 0 &amp;amp;&amp;amp; ds.Tables[0].Rows != null &amp;amp;&amp;amp; ds.Tables[0].Rows.Count &amp;gt; 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We have extracted all the required information for the assemble and will send it to the topic &lt;code&gt;BOMData&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 class="relative group"&gt;Running the Scenario - Nuts and Bolts
&lt;div id="running-the-scenario---nuts-and-bolts" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-the-scenario---nuts-and-bolts" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s see what we can do with this controller!!!&lt;/p&gt;
&lt;h3 class="relative group"&gt;Automation Manager
&lt;div id="automation-manager" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#automation-manager" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;I connected the Controller to an Automation Manager.&lt;/p&gt;
&lt;p&gt;We have already seen the recipe selection, so let&amp;rsquo;s try and use a material and perform a material handling cycle. For this scenario we will print a 3D Nut.
We have a product &lt;code&gt;3d Nut&lt;/code&gt; and a material &lt;code&gt;Nut-003&lt;/code&gt;.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250407-3dPrinter/img/printing.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;Notice that when we press the track-in button in the UI, &lt;code&gt;Connect IoT&lt;/code&gt; is notified and performs the recipe validation and also the start of the printing job. Throughout the printing process, IoT is logging the completion rate every 30 seconds. When the completion rate is 100% it will retrieve the feeder length information and do an assemble with explicit add. This will change the material quantity to 1. It will then perform the track-out changing the material to processed.&lt;/p&gt;
&lt;h3 class="relative group"&gt;UI - Live Printer Feed
&lt;div id="ui---live-printer-feed" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ui---live-printer-feed" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;This implementation had a small customization in the &lt;code&gt;Resource View&lt;/code&gt; page. The &lt;code&gt;Resource View&lt;/code&gt; and &lt;code&gt;Step View&lt;/code&gt; are central pages of the interaction between the operator and the MES. These pages are build in a low code framework &lt;code&gt;UI Pages&lt;/code&gt; that the user is able clone and customize.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Pages"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-uipage.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;I cloned both the main page, the &lt;code&gt;ResourceView_Cluster&lt;/code&gt; and the &lt;code&gt;Materials at Resource&lt;/code&gt; and created the &lt;code&gt;ResourceView_Cluster_3d&lt;/code&gt; and the &lt;code&gt;Materials at Resource 3d Printer&lt;/code&gt;. In the &lt;code&gt;Materials at Resource 3d Printer&lt;/code&gt; I dragged and dropped a browser widget that will use the OctoPi camera feed url.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Page Materials at Resource"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-materialsatresourceuipage.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Then I just add the reference to my page in the &lt;code&gt;ResourceView_Cluster&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Page Resource View"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-uipageresourceview.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Page Resource View Panels"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-uipagepanelresourceview.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Finally we will have to override the default &lt;code&gt;Resource View&lt;/code&gt; with our new &lt;code&gt;ResourceView_Cluster_3d&lt;/code&gt;. The smart table &lt;a
href="https://help.criticalmanufacturing.com/userguide/administration/tables/smart-tables/uipagecontext/"
target="_blank"
&gt;UI Page Context&lt;/a&gt; allows us to do just that.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="UI Page Context"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-uipagecontext.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 class="relative group"&gt;Let&amp;rsquo;s Now add the Bolt
&lt;div id="lets-now-add-the-bolt" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lets-now-add-the-bolt" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h3&gt;
&lt;p&gt;First, we will create the recipe:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Bolt Recipe"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-boltrecipe.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Now, we can create our BOM for our bolt.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Bolt BOM"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-boltbom.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;In our &lt;code&gt;3d Print&lt;/code&gt; let&amp;rsquo;s add the services and bom context for the bolt.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Bolt Service Context"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-boltservicecontext.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Bolt BOM Context"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-boltbomcontext.png"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;I created a &lt;code&gt;Bolt-001&lt;/code&gt; material and will now, like we did with the nut, produce a bolt.&lt;/p&gt;
&lt;video controls width="100%"&gt;
&lt;source src="https://image.j-roque.com/posts/20250407-3dPrinter/img/bolt_printing.mp4" type="video/mp4"&gt;
&lt;/video&gt;
&lt;p&gt;We can also take a look at what happened in our process from the perspective of our raw materials. Looking at our raw material and consumable feeder:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Consumable Consumption"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-assemble.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;We can also see how all of this information is now part of our material genealogy:&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Material Genealogy"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/mes-genealogy.gif"
&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 class="relative group"&gt;Summary
&lt;div id="summary" class="anchor"&gt;&lt;/div&gt;
&lt;span
class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
&lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#summary" aria-label="Anchor"&gt;#&lt;/a&gt;
&lt;/span&gt;
&lt;/h2&gt;
&lt;p&gt;We did it we created a 3D Printed Nut &amp;amp; Bolt. In a very simple example, we have recipe management, we have material tracking and recipe validation.&lt;/p&gt;
&lt;p&gt;&lt;figure&gt;&lt;img
class="my-0 rounded-md"
loading="lazy"
decoding="async"
fetchpriority="low"
alt="Nut &amp;amp; Bolt"
src="https://image.j-roque.com/posts/20250407-3dPrinter/img/nut&amp;amp;bolt.jpg"
&gt;&lt;/figure&gt;
&lt;/p&gt;
</content:encoded>
<enclosure url="https://j-roque.com/posts/20250407-3dprinter/featured.png" type="image/png" length="0"/>
<media:content url="https://j-roque.com/posts/20250407-3dprinter/featured.png" type="image/png" medium="image"/>
</item>
</channel>
</rss>