diff --git a/astro.config.mjs b/astro.config.mjs index 33d1ea1..181bf90 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -161,6 +161,20 @@ export default defineConfig({ collapsed: true, items: grouped('cascade'), }, + { + label: 'HotLoop Transmitter', + collapsed: true, + items: [ + { slug: 'transmitter/overview', label: 'Overview' }, + { slug: 'transmitter/install', label: 'Install' }, + { slug: 'transmitter/api', label: 'API' }, + { slug: 'transmitter/wire-format', label: 'What goes on the wire' }, + { slug: 'transmitter/timing', label: 'What it adds to the task' }, + { slug: 'transmitter/proof', label: 'What was run' }, + { slug: 'transmitter/roadmap', label: 'Roadmap' }, + { slug: 'releases/transmitter-1-0-0', label: 'Release notes' }, + ], + }, { label: 'Release notes', collapsed: true, diff --git a/scripts/forbidden.mjs b/scripts/forbidden.mjs index c9f8cf8..e9d7bfb 100644 --- a/scripts/forbidden.mjs +++ b/scripts/forbidden.mjs @@ -2,15 +2,12 @@ // own, so no customer is named, not even by a project code, and a product that has not shipped // is not on the page. The build and the sync both fail on a match, in the generated pages, the // built HTML, the search index, and the strings the social cards are drawn from. -// -// When HotLoop Transmitter ships, take it off this list in the same change that adds it to the site. export const FORBIDDEN = [ [/\bUT3\b/i, 'UT3, a customer project code'], [/\bTrane\b/i, 'Trane, a customer'], [/tranetech/i, 'tranetech, a customer tenant'], [/expert[ -]?lift/i, 'Expert Lift, a customer'], [/a customer'?s? production (program|plc)/i, 'a customer program, described by whose it is'], - [/HotLoop Transmitter/, 'HotLoop Transmitter, which is not on the public lineup until it ships'], ]; // Returns one line per match, with a little of the text around it. diff --git a/scripts/sync-docs.mjs b/scripts/sync-docs.mjs index 029618e..f139c95 100644 --- a/scripts/sync-docs.mjs +++ b/scripts/sync-docs.mjs @@ -467,7 +467,7 @@ for (const [slug, p] of mainPages) { .sort((a, b) => b.slug.localeCompare(a.slug, undefined, { numeric: true })) .map((m) => `- [${m.title}](/${m.slug}/)${m.status === 'next' ? ' (on main, next release)' : ''}`) .join('\n'); - const fm = ['---', MARK, 'title: "Release notes"', 'description: "What shipped in every HotLoop core, HotLoop Flow and HotLoop Cascade release, and what is on main waiting for the next one, newest first."', 'editUrl: false', '---', ''].join('\n'); + const fm = ['---', MARK, 'title: "Release notes"', 'description: "What shipped in every HotLoop core, Flow, Cascade and Transmitter release, and what is on main waiting for the next one, newest first."', 'editUrl: false', '---', ''].join('\n'); out.set('releases/index.md', `${fm} Every release has its own page, written the day it shipped. A note marked "on main, next release" describes a release that is not out yet, so nothing in it is in what you have installed. The latest HotLoop core release is ${releases.hotloop.version}, the latest Flow release is ${releases.flow.version}, and the latest Cascade release is ${releases.cascade.version}. @@ -484,6 +484,10 @@ ${rows('flow')} ## HotLoop Cascade ${rows('cascade')} + +## HotLoop Transmitter + +- [HotLoop Transmitter 1.0.0 release notes](/releases/transmitter-1-0-0/) `); } diff --git a/scripts/sync-manifest.mjs b/scripts/sync-manifest.mjs index 892112c..c5ecbf4 100644 --- a/scripts/sync-manifest.mjs +++ b/scripts/sync-manifest.mjs @@ -16,7 +16,7 @@ export const REPOS = { cascade: { slug: 'HotLoop-io/hotloop-cascade', private: true, releaseName: 'HotLoop Cascade' }, }; -// The six sections of the site, in the order the product picker shows them. +// The seven sections of the site, in the order the product picker shows them. export const PRODUCTS = [ { id: 'iot', name: 'HotLoop IoT', repo: 'hotloop', tagline: 'The automation base, built in Go for OT.' }, { id: 'edge', name: 'HotLoop Edge', repo: 'hotloop', tagline: 'IoT plus the machine layer.' }, @@ -24,6 +24,8 @@ export const PRODUCTS = [ { id: 'edge-relay', name: 'HotLoop Edge Relay', repo: 'hotloop', tagline: 'Poll, forward, survive.' }, { id: 'flow', name: 'HotLoop Flow', repo: 'flow', tagline: 'Node-RED compatible flows on a Go runtime.' }, { id: 'cascade', name: 'HotLoop Cascade', repo: 'cascade', tagline: 'The plant talking to everything else.' }, + // Hand-written: the library's repo is private and its pages are plain files in src/content/docs/transmitter. + { id: 'transmitter', name: 'HotLoop Transmitter', repo: null, tagline: 'OPC UA PubSub from inside the PLC.' }, ]; const ws = (s) => new RegExp(s.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+'), 'g'); @@ -100,7 +102,7 @@ export const PAGES = [ { product: 'shared', group: 'Install', slug: 'install/helm', repo: 'hotloop', file: 'docs/DEPLOYMENT.md', title: 'k3s and Helm', description: 'Deploy any HotLoop chart on k3s or any Kubernetes: the values worth setting, the database choice, upgrades, rollbacks and what a good install looks like.' }, { product: 'shared', group: 'Install', slug: 'install/quadlet', repo: 'hotloop', file: 'deploy/quadlet/README.md', stopAt: 'Updates', title: 'Podman Quadlet', description: 'Run HotLoop as real systemd services on a Pi or small industrial PC with Podman Quadlet, with no Docker, no Compose and no cluster.' }, { product: 'shared', group: 'Install', slug: 'install/auto-update', repo: 'hotloop', file: 'deploy/quadlet/README.md', sections: ['Updates'], title: 'The :stable auto-update', description: 'Every box follows the stable tag and Podman moves it. What an update does, what a rollback cannot undo, and how to hold updates until you say.' }, - { product: 'shared', group: 'Lineup', slug: 'products', repo: 'hotloop', file: 'docs/PRODUCTS.md', title: 'HotLoop products: six on three release lines', description: 'Six products on three release lines, what each of the four core products has, which database each runs, how to install them and how to move a site up a product.' }, + { product: 'shared', group: 'Lineup', slug: 'products', repo: 'hotloop', file: 'docs/PRODUCTS.md', title: 'HotLoop products: seven on four release lines', description: 'Seven products on four release lines, what each core product has, which database each runs, how to install them and how to move a site up.' }, { product: 'shared', group: 'Lineup', slug: 'roadmap', repo: 'hotloop', file: 'docs/ROADMAP.md', title: 'HotLoop roadmap', description: "What HotLoop is, what is left to build and what is broken, in the order it gets done, for HotLoop core. Flow and Cascade link their own." }, // ---- HotLoop Flow ---- @@ -203,20 +205,21 @@ Object.assign(SLUG_RULES, { roadmap: [ // The roadmap is the core's. Say so, and say where the other two lines keep theirs. Links are HTML because the link pass runs after the rules and would drop a site path. [ws('HotLoop core ships as four products out of one codebase. See [Products](#products).'), - 'HotLoop is six products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring and Cascade for workflows. This roadmap is for HotLoop core, the four products that come out of one codebase. See [Products](#products). Flow and Cascade keep their own, in the Flow roadmap and the Cascade roadmap.'], + 'HotLoop is seven products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring, Cascade for workflows, and Transmitter for putting a PLC\'s values on the wire. This roadmap is for HotLoop core, the four products that come out of one codebase. See [Products](#products). Flow, Cascade, and Transmitter keep their own, in the Flow roadmap, the Cascade roadmap, and the Transmitter roadmap.'], ], 'products': [ [ws('HotLoop core is four products, one codebase, one tag. Every core release ships all four in lockstep,'), - 'HotLoop is six products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring and Cascade for workflows.\n\nThey ship on three release lines. **HotLoop core** is IoT, Edge, Gateway, and Edge Relay: four products, one codebase, one tag. Every core release ships all four in lockstep,'], + 'HotLoop is seven products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring, Cascade for workflows, and Transmitter for putting a PLC\'s values on the wire.\n\nThey ship on four release lines. **HotLoop core** is IoT, Edge, Gateway, and Edge Relay: four products, one codebase, one tag. Every core release ships all four in lockstep,'], [ws("PLC on your bench. Features split them. Protocols don't."), - "PLC on your bench. Features split them. Protocols don't.\n\n**Flow** and **Cascade** are their own lines with their own versions, so a Flow release never moves the core, and the other way around. Everything below the table is about the core. Here is where each of the six starts:\n\n" + "PLC on your bench. Features split them. Protocols don't.\n\n**Flow**, **Cascade**, and **Transmitter** are their own lines with their own versions, so a Flow release never moves the core, and the other way around. Everything below the table is about the core. Here is where each of the seven starts:\n\n" + '| Product | Release line | Docs | Install | Release notes | Roadmap |\n|---|---|---|---|---|---|\n' + [['IoT', 'HotLoop core', 'iot/overview', 'iot/install', 'releases/#hotloop-core', 'roadmap'], ['Edge', 'HotLoop core', 'edge/overview', 'edge/install', 'releases/#hotloop-core', 'roadmap'], ['Gateway', 'HotLoop core', 'gateway/overview', 'gateway/install', 'releases/#hotloop-core', 'roadmap'], ['Edge Relay', 'HotLoop core', 'edge-relay/overview', 'edge-relay/install', 'releases/#hotloop-core', 'roadmap'], ['Flow', 'Flow', 'flow/overview', 'flow/install', 'releases/#hotloop-flow', 'flow/roadmap'], - ['Cascade', 'Cascade', 'cascade/overview', 'cascade/running', 'releases/#hotloop-cascade', 'cascade/roadmap']] + ['Cascade', 'Cascade', 'cascade/overview', 'cascade/running', 'releases/#hotloop-cascade', 'cascade/roadmap'], + ['Transmitter', 'Transmitter', 'transmitter/overview', 'transmitter/install', 'releases/transmitter-1-0-0', 'transmitter/roadmap']] .map(([n, l, d, i, r, m]) => `| **HotLoop ${n}** | ${l} | Docs | Install | Notes | Roadmap |`).join('\n') + '\n\nThe table that follows compares the four core products.'], ], diff --git a/src/components/ProductPicker.astro b/src/components/ProductPicker.astro index 7ad4eca..8ee64f2 100644 --- a/src/components/ProductPicker.astro +++ b/src/components/ProductPicker.astro @@ -1,5 +1,5 @@ --- -// The family map: six products on three release lines, each with the same five things +// The family map: seven products on four release lines, each with the same five things // (docs, install, release notes, roadmap, and its version). Versions and release state // come from src/data/sync.json, which the sync writes, so this can't disagree with the pages. import sync from '../data/sync.json'; @@ -7,6 +7,8 @@ import sync from '../data/sync.json'; const hot = sync.repos.hotloop.release; const flow = sync.repos.flow.release; const cascade = sync.repos.cascade.release; +// Transmitter 1.0.0 shipped 2026-11-04 (HotLoop-io/hotloop-transmitter v1.0.0). Change this when the next one does. +const transmitter = { version: '1.0.0' }; const latest = (prefix: string) => sync.pages.filter((p) => p.release && p.status !== 'next' && p.slug.startsWith(prefix)).map((p) => p.slug).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0]; @@ -15,8 +17,9 @@ const lines = [ { id: 'core', label: 'HotLoop core', version: hot.version, note: 'One codebase, one version. IoT to Edge to Gateway is the path a site grows along, and Edge Relay is the headless forwarder beside them.', notes: `/${latest('releases/hotloop-')}/`, roadmap: '/roadmap/', ids: ['iot', 'edge', 'gateway', 'edge-relay'] }, { id: 'flow', label: 'Flow', version: flow.version, note: 'Its own release line, running next to HotLoop.', notes: `/${latest('releases/flow-')}/`, roadmap: '/flow/roadmap/', ids: ['flow'] }, { id: 'cascade', label: 'Cascade', version: cascade.version, note: cascade.prerelease ? 'Pre-release. Its own release line, running next to HotLoop.' : 'Its own release line, running next to HotLoop.', notes: `/${latest('releases/cascade-')}/`, roadmap: '/cascade/roadmap/', ids: ['cascade'] }, + { id: 'transmitter', label: 'Transmitter', version: transmitter.version, note: 'Its own release line, running next to HotLoop.', notes: `/releases/transmitter-${transmitter.version.replace(/\./g, '-')}/`, roadmap: '/transmitter/roadmap/', ids: ['transmitter'] }, ]; -const install: Record = { iot: '/iot/install/', edge: '/edge/install/', gateway: '/gateway/install/', 'edge-relay': '/edge-relay/install/', flow: '/flow/install/', cascade: '/cascade/running/' }; +const install: Record = { iot: '/iot/install/', edge: '/edge/install/', gateway: '/gateway/install/', 'edge-relay': '/edge-relay/install/', flow: '/flow/install/', cascade: '/cascade/running/', transmitter: '/transmitter/install/' }; ---
diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro index b8b43fa..df9c34b 100644 --- a/src/components/SiteFooter.astro +++ b/src/components/SiteFooter.astro @@ -35,6 +35,7 @@ const hasTerms = existsSync(new URL('../content/docs/terms.md', import.meta.url) HotLoop roadmap Flow roadmap Cascade roadmap + Transmitter roadmap {hasLicense && License} {hasTerms && Terms} diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx index 9273c66..adffd42 100644 --- a/src/content/docs/index.mdx +++ b/src/content/docs/index.mdx @@ -1,6 +1,6 @@ --- title: HotLoop documentation -description: "Docs for every HotLoop product: IoT, Edge, Gateway, Edge Relay, Flow, and Cascade, with each claim marked as proven on real equipment or not proven yet." +description: "Docs for every HotLoop product: IoT, Edge, Gateway, Edge Relay, Flow, Cascade, and Transmitter, with each claim marked as proven or not." template: splash editUrl: false hero: @@ -23,7 +23,7 @@ import LatestReleases from '../../components/LatestReleases.astro'; ## The family -HotLoop is six products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring and Cascade for workflows. They ship on three release lines, so each carries its own version. Each section in the sidebar shows only what that product ships, and a bigger product links back to the pages it carries from a smaller one. +HotLoop is seven products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring, Cascade for workflows, and Transmitter for putting a PLC's values on the wire. They ship on four release lines, so each carries its own version. Each section in the sidebar shows only what that product ships, and a bigger product links back to the pages it carries from a smaller one. diff --git a/src/content/docs/products.md b/src/content/docs/products.md index 4f550f4..260402d 100644 --- a/src/content/docs/products.md +++ b/src/content/docs/products.md @@ -1,7 +1,7 @@ --- # generated by scripts/sync-docs.mjs -title: "HotLoop products: six on three release lines" -description: "Six products on three release lines, what each of the four core products has, which database each runs, how to install them and how to move a site up a product." +title: "HotLoop products: seven on four release lines" +description: "Seven products on four release lines, what each core product has, which database each runs, how to install them and how to move a site up." editUrl: false --- @@ -9,14 +9,14 @@ editUrl: false This page follows `main` at 4d5da2f, and it differs from the copy in HotLoop core 4.18.11, the latest release. Parts of it may be newer than what you have installed. The [release notes](/releases/) say what moved. ::: -HotLoop is six products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring and Cascade for workflows. +HotLoop is seven products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring, Cascade for workflows, and Transmitter for putting a PLC's values on the wire. -They ship on three release lines. **HotLoop core** is IoT, Edge, Gateway, and Edge Relay: four products, one codebase, one tag. Every core release ships all four in lockstep, same version, each with its own image, its own Helm chart and its own +They ship on four release lines. **HotLoop core** is IoT, Edge, Gateway, and Edge Relay: four products, one codebase, one tag. Every core release ships all four in lockstep, same version, each with its own image, its own Helm chart and its own Podman Quadlet units. Pick the one that fits the box. Every product speaks every protocol HotLoop has, so you never step up a product just to read the PLC on your bench. Features split them. Protocols don't. -**Flow** and **Cascade** are their own lines with their own versions, so a Flow release never moves the core, and the other way around. Everything below the table is about the core. Here is where each of the six starts: +**Flow**, **Cascade**, and **Transmitter** are their own lines with their own versions, so a Flow release never moves the core, and the other way around. Everything below the table is about the core. Here is where each of the seven starts: | Product | Release line | Docs | Install | Release notes | Roadmap | |---|---|---|---|---|---| @@ -26,6 +26,7 @@ PLC on your bench. Features split them. Protocols don't. | **HotLoop Edge Relay** | HotLoop core | Docs | Install | Notes | Roadmap | | **HotLoop Flow** | Flow | Docs | Install | Notes | Roadmap | | **HotLoop Cascade** | Cascade | Docs | Install | Notes | Roadmap | +| **HotLoop Transmitter** | Transmitter | Docs | Install | Notes | Roadmap | The table that follows compares the four core products. diff --git a/src/content/docs/releases/index.md b/src/content/docs/releases/index.md index c472ca4..3009378 100644 --- a/src/content/docs/releases/index.md +++ b/src/content/docs/releases/index.md @@ -1,7 +1,7 @@ --- # generated by scripts/sync-docs.mjs title: "Release notes" -description: "What shipped in every HotLoop core, HotLoop Flow and HotLoop Cascade release, and what is on main waiting for the next one, newest first." +description: "What shipped in every HotLoop core, Flow, Cascade and Transmitter release, and what is on main waiting for the next one, newest first." editUrl: false --- @@ -37,3 +37,7 @@ IoT, Edge, Gateway and Edge Relay release together from one tag, so they share t - [HotLoop Cascade 1.0.0 release notes](/releases/cascade-1-0-0/) - [HotLoop Cascade 0.1.2 release notes](/releases/cascade-0-1-2/) - [HotLoop Cascade 0.1.1 release notes](/releases/cascade-0-1-1/) + +## HotLoop Transmitter + +- [HotLoop Transmitter 1.0.0 release notes](/releases/transmitter-1-0-0/) diff --git a/src/content/docs/releases/transmitter-1-0-0.md b/src/content/docs/releases/transmitter-1-0-0.md new file mode 100644 index 0000000..355e9ca --- /dev/null +++ b/src/content/docs/releases/transmitter-1-0-0.md @@ -0,0 +1,80 @@ +--- +title: "HotLoop Transmitter 1.0.0" +description: "The first release of HotLoop Transmitter: a CODESYS library that publishes PLC values as OPC UA PubSub, what is in it, what was run, and what was not." +--- + +First release. A CODESYS library, written in Structured Text over SysSocket UDP, that puts your PLC's values on the +wire as OPC UA PubSub (Part 14, UADP). You add the library, declare one function block, add the variables you want +out, and call it once per cycle. Anything that subscribes to OPC UA PubSub can read it, HotLoop core's main branch included. + +What that means for you: a PLC with no PubSub library can now feed a PubSub subscriber with about fifteen lines of +Structured Text, and nothing else gets installed on the controller. + +## What is in the box + +* One block, `HLTX.FB_HLTX_Publisher`. One NetworkMessage per publishing interval, one DataSetMessage, a Data Key + Frame, so every message holds every field. +* Variant and RawData field encodings, picked per publisher. Variant carries its own types. RawData carries none, so + the receiver has to be told the layout, which the [wire format page](/transmitter/wire-format/) spells out byte by byte. +* BOOL, SINT, USINT, INT, UINT, DINT, UDINT, LINT, ULINT, REAL, LREAL, STRING and TIME fields, and arrays of all of + those but STRING, up to 128 fields and 1472 bytes a message. A message never fragments. +* Unicast or multicast, with publisher id, writer group id, dataset writer id, publishing interval, group version + and configuration version as inputs. +* A non-blocking send. A bad address, a dead socket or a field list that is too big puts the block in an error + state you can read and never faults your application. + +## What I ran, and what it showed + +All of it on The Beast, on the files below, against CODESYS Control for Linux SL 4.22.0.0 in a container. + +* The library builds with 0 errors and 0 warnings in CODESYS 3.5.22.30. The example application builds against it + with 0 errors and 0 warnings. +* Two publishers, one Variant and one RawData, seventeen fields each. Every value on the wire equals what the PLC + reports over its own OPC UA server, with no gap in the sequence numbers. +* open62541 1.4.9 decodes the Variant message in full and the RawData scalars. It cannot read a RawData array or a + long RawData string at all, which is its limit and not the library's. +* HotLoop core's subscriber (from its main branch, not in a release yet) reads both publishers, including the string, the TIME, both arrays and + two counters that move between reads, and refuses nothing. +* A second Go decoder reads all 222 captured datagrams. +* Cost on a 20 ms task, two publishers: about 10.6 us added per cycle on average. A cycle that sends costs 10 to + 100 us. The tail is the `sendto` call, and on a busy shared host it reached 14.3 ms once in 12 000 cycles. Put the + publisher in its own lower priority task if that matters to your control logic. the [timing page](/transmitter/timing/) has the table. + +## What I did not run + +* Any physical controller, any CODESYS runtime but Control for Linux SL 4.22.0.0, and any CPU but x86-64. The byte + order handling for a big endian target is written and not run. +* Multicast. The lab used unicast. +* A runtime that loses and regains its network, or a send to an address that is not there. The block counts failures + and backs off, but I did not break a network on purpose. +* What happens when the two hour demo window of the runtime ends. +* Receivers other than the four above. + +## Not in this release + +DataSetMessage and NetworkMessage timestamps, KeepAlive and Delta Frames, more than one DataSetMessage per +NetworkMessage, WSTRING, a per-field status, message security, and the subscriber side. the [roadmap](/transmitter/roadmap/) says which +are next. + +## If you are coming from another publisher + +A receiver that insists on a timestamp, a KeepAlive or a Delta Frame will not be satisfied by this version, because +none of them are sent. Every message is a Key Frame, which any receiver can read cold. A RawData array here is an +Int32 length, the elements, and zero padding up to the declared size, which is how the OPC Foundation's reference +stack writes it. A receiver that expects an array as back to back elements with no length has to be told the fields +are separate scalars instead. + +## For a project that already uses it + +There is no earlier version. The wire format changes only when `udiConfigMajor` does, and a PLC project pinned to +this library version keeps the same fields and bytes. + +## Files + +Download both files and `SHA256SUMS` from https://hotloop.io/hotloop-transmitter/1.0.0/, then install `HotLoopTransmitter-1.0.0.library` through Tools, Library Repository, Install in CODESYS 3.5 SP22 or later. +The `.compiled-library` is the same library without its source. + +``` +91b8023a73870925082c170588feb43d363e6d07ce008e1471fa8f5ff8db2c0d HotLoopTransmitter-1.0.0.library +b02b0b40e434f35e4af1def20d6cc38688463537c4800354931b8c6094a66686 HotLoopTransmitter-1.0.0.compiled-library +``` diff --git a/src/content/docs/roadmap.md b/src/content/docs/roadmap.md index 70d3b70..630d4aa 100644 --- a/src/content/docs/roadmap.md +++ b/src/content/docs/roadmap.md @@ -25,7 +25,7 @@ Every line of it is ours. Nothing is embedded, bridged or forked, so when something breaks at 3 AM there's one codebase to read and nobody else's bug to wait on. -HotLoop is six products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring and Cascade for workflows. This roadmap is for HotLoop core, the four products that come out of one codebase. See [Products](#products). Flow and Cascade keep their own, in the Flow roadmap and the Cascade roadmap. +HotLoop is seven products for the plant floor: four that grow with a site (IoT, Edge, Gateway, and Edge Relay), plus Flow for wiring, Cascade for workflows, and Transmitter for putting a PLC's values on the wire. This roadmap is for HotLoop core, the four products that come out of one codebase. See [Products](#products). Flow, Cascade, and Transmitter keep their own, in the Flow roadmap, the Cascade roadmap, and the Transmitter roadmap. ## Products diff --git a/src/content/docs/transmitter/api.md b/src/content/docs/transmitter/api.md new file mode 100644 index 0000000..cfebed5 --- /dev/null +++ b/src/content/docs/transmitter/api.md @@ -0,0 +1,60 @@ +--- +title: "HotLoop Transmitter API" +description: "Every input, output and method of the HotLoop Transmitter publisher function block." +--- + +Everything is in the `HLTX` namespace. The block is `HLTX.FB_HLTX_Publisher`. + +## Inputs + +| Input | Type | Default | Meaning | +|---|---|---|---| +| `bEnable` | BOOL | FALSE | TRUE opens the socket and starts publishing. FALSE closes it and returns the block to `Disabled`, which also clears a `ConfigError` | +| `sDestination` | STRING(15) | `127.0.0.1` | dotted IPv4 address: a unicast host, or a multicast group from 224.0.0.0 to 239.255.255.255 | +| `uiPort` | UINT | 4840 | destination UDP port. 4840 is the one OPC UA registers | +| `sLocalAddress` | STRING(15) | empty | multicast only: the address of the interface to send from. Empty lets the OS choose | +| `usiMulticastTTL` | USINT | 1 | multicast time to live | +| `uiPublisherId` | UINT | 1 | PublisherId, sent as UInt16 | +| `uiWriterGroupId` | UINT | 1 | WriterGroupId | +| `uiDataSetWriterId` | UINT | 1 | DataSetWriterId | +| `tPublishingInterval` | TIME | T#100MS | time between messages. Minimum T#1MS. The block can only send on a cycle, so the real rate is the interval rounded up to a whole number of cycles of the task that calls it | +| `eEncoding` | `E_HLTX_Encoding` | `Variant` | `Variant` or `RawData`. Change it while disabled | +| `udiGroupVersion` | UDINT | 1 | GroupVersion in the group header | +| `udiConfigMajor`, `udiConfigMinor` | UDINT | 1, 0 | DataSet ConfigurationVersion. Bump the major whenever the field list changes | +| `tRetry` | TIME | T#2S | how long to wait before reopening the socket after ten sends in a row failed | + +## Outputs + +| Output | Type | Meaning | +|---|---|---| +| `eState` | `E_HLTX_State` | `Disabled`, `Running`, `Backoff` (waiting to reopen the socket) or `ConfigError` (see `eError`; nothing is sent) | +| `eError` | `E_HLTX_Error` | `None`, `NoFields`, `BadInterval`, `BadAddress`, `SocketCreate`, `SocketOption`, `TooBig`, `BadField` | +| `bActive` | BOOL | TRUE while messages are going out | +| `udiSent` | UDINT | messages handed to the network | +| `udiSendErrors` | UDINT | sends the OS refused or cut short | +| `udiSkipped` | UDINT | publish slots missed because the task ran late | +| `uiMessageSize` | UINT | bytes in the last message | +| `diLastSocketResult` | UDINT | the last runtime error code from a socket call, 0 is fine | +| `udiLastCallUs`, `udiMaxCallUs` | UDINT | microseconds spent in the last call of the block, and the longest since enable or `Clear()` | +| `udiMaxEncodeUs`, `udiMaxSendUs` | UDINT | the longest time spent building one message, and in the send call itself, since enable or `Clear()` | + +## Methods + +Add fields once, before the first call, in the order a receiver should read them. Each returns TRUE when the field +was accepted. Up to 128 fields. + +| Method | Adds | +|---|---| +| `AddBool(pValue)`, `AddSInt`, `AddUSInt`, `AddInt`, `AddUInt`, `AddDInt`, `AddUDInt`, `AddLInt`, `AddULInt`, `AddReal`, `AddLReal`, `AddTime` | one scalar. `pValue` is `ADR(variable)` | +| `AddString(pValue, uiMaxLen)` | one STRING. `uiMaxLen` is the N in `STRING(N)` | +| `AddArray(pFirst, eType, uiCount)` | an array of `uiCount` elements of any type but STRING. `pFirst` is `ADR(array[first])` | +| `AddField(pData, eType, uiCount, uiMaxLen)` | the general form the others call | +| `Clear()` | forgets the field list and the maximum call time | + +The block keeps the addresses you give it, so after an online change that moves variables, call `Clear()` and add +the fields again. WORD, DWORD and LWORD go in as UINT, UDINT and ULINT through the same-sized method. + +## Calling it + +Call the instance once per cycle from one task. Do not call one instance from two tasks. Several publishers in +one program are fine and each owns its own socket; give each its own writer group and dataset writer id. diff --git a/src/content/docs/transmitter/install.md b/src/content/docs/transmitter/install.md new file mode 100644 index 0000000..7cf14e4 --- /dev/null +++ b/src/content/docs/transmitter/install.md @@ -0,0 +1,33 @@ +--- +title: "Install HotLoop Transmitter" +description: "Install the library into the CODESYS library repository, add it to a project, and what to do when the block reports an error." +--- + +You need a CODESYS development system, 3.5 SP22 or later, and a runtime whose target has the SysSocket component, +which the CODESYS Control runtimes for Linux SL have. + +1. Download `HotLoopTransmitter-1.0.0.library` from https://hotloop.io/hotloop-transmitter/1.0.0/HotLoopTransmitter-1.0.0.library, and check it against `SHA256SUMS` in the same folder (`sha256sum -c SHA256SUMS`). +2. In the CODESYS IDE: Tools, Library Repository, Install, pick the file. It is now in the repository under the + company HotLoop. +3. In your project, open the Library Manager of the application, Add Library, and pick HotLoop Transmitter. + SysSocket and its interface libraries come with it as dependencies and need nothing from you. +4. Declare a `HLTX.FB_HLTX_Publisher`, add your fields, call it every cycle (README has the example). +5. Download to the controller. The destination must be reachable from the controller's network; for multicast the + network's switches must carry the group. + +The `.compiled-library` file is the same library without its source. Install that one instead if you do not want the +Structured Text visible in the Library Manager. It is in the same folder, as `HotLoopTransmitter-1.0.0.compiled-library`. + +To check what arrives, any UDP listener on the destination port works. The lab used a small Go program that listens on a port or joins a +multicast group and prints each datagram as a line of JSON with its hex. + +## If the block reports an error + +| `eError` | Meaning | Do | +|---|---|---| +| `NoFields` | no field was added | call an `Add` method before enabling | +| `BadInterval` | `tPublishingInterval` is under 1 ms | set it | +| `BadAddress` | `sDestination` is not a dotted IPv4 address | fix it | +| `SocketCreate`, `SocketOption` | the runtime refused a socket call; `diLastSocketResult` has its code | the block retries by itself after `tRetry` | +| `TooBig` | the fields do not fit in 1472 bytes | publish fewer, or use two publishers with different writer ids | +| `BadField` | a stored address was cleared | `Clear()` and add the fields again | diff --git a/src/content/docs/transmitter/overview.md b/src/content/docs/transmitter/overview.md new file mode 100644 index 0000000..4b93177 --- /dev/null +++ b/src/content/docs/transmitter/overview.md @@ -0,0 +1,73 @@ +--- +title: "HotLoop Transmitter" +description: "A CODESYS library, written in Structured Text over SysSocket UDP, that publishes PLC values as OPC UA PubSub (UADP) with no other library and no configurator." +--- + +HotLoop Transmitter puts a PLC's values on the network as OPC UA PubSub, the way Part 14 of the OPC UA spec says to, +from a library you drop into the CODESYS project. It is a transmitter in the instrument-loop sense: the part that +puts the process value on the wire so something else can read it. + +It is written in Structured Text over `SysSocket` UDP, and it needs no other library, no extra license, and no +configurator. You add the library, call one function block once per cycle, and a UADP NetworkMessage goes out every +publishing interval to a unicast address or a multicast group. + +What that means for you: a PLC that has no PubSub library can still feed anything that subscribes to OPC UA PubSub, +including HotLoop itself, with a few lines of Structured Text. + +## Why this is Structured Text and not Go + +Every new HotLoop tool is written in Go unless there is a real reason. This is the real reason: the thing has to +run inside the PLC's own scan, in the IEC 61131-3 runtime, as a library the programmer references from their project. +A CODESYS runtime runs Structured Text, not Go. The tests and tools around the library (the capture tool, the +verifier) are Go. + +## Use it + +1. Install `HotLoopTransmitter-1.0.0.library` into the CODESYS library repository (Tools, Library Repository, + Install), then add it to the project's Library Manager. The [install page](/transmitter/install/) has the download and the details. +2. Declare a publisher, add the variables you want to publish once at start, and call it every cycle: + +```iecst +PROGRAM PLC_PRG +VAR + pub : HLTX.FB_HLTX_Publisher; + bInit : BOOL; +END_VAR + +IF NOT bInit THEN + bInit := TRUE; + pub.sDestination := '239.0.0.22'; // a unicast host or a multicast group + pub.uiPort := 4840; + pub.uiPublisherId := 1; + pub.uiWriterGroupId := 1; + pub.uiDataSetWriterId := 1; + pub.tPublishingInterval := T#100MS; + pub.AddReal(ADR(GVL.rTemperature)); + pub.AddBool(ADR(GVL.bRunning)); + pub.AddArray(ADR(GVL.aValues), HLTX.E_HLTX_Type.REAL_T, 250); +END_IF +pub.bEnable := TRUE; +pub(); +``` + +The [API page](/transmitter/api/) lists every input, output and method. The [wire format page](/transmitter/wire-format/) is the packet, byte by byte, with the +sections of the spec that define each byte. + +## What it will and won't do to your scan + +* It never blocks. The socket is non-blocking and the message is built in a fixed buffer. +* The cost of a call is the cost of building one message, and it is only paid on the cycles where a message goes + out. The measured numbers are on the [timing page](/transmitter/timing/). +* A socket error, a bad address or an oversized field list never faults the application. The block reports an + `eState` and an `eError`, counts the failure, and the program keeps scanning. +* It does not fragment. A message is at most 1472 bytes. A field list that does not fit puts the block in + `ConfigError` and sends nothing. + +## Tested against + +The CODESYS Control for Linux SL 4.22.0.0 runtime, in a container. The [timing page](/transmitter/timing/) and the [proof page](/transmitter/proof/) say what +was run and what was read back. Not run on physical hardware. + +## License + +HotLoop's Business Source License 1.1, the same as every HotLoop product. The [license page](https://hotloop.io/license/) has the text. diff --git a/src/content/docs/transmitter/proof.md b/src/content/docs/transmitter/proof.md new file mode 100644 index 0000000..15ddd44 --- /dev/null +++ b/src/content/docs/transmitter/proof.md @@ -0,0 +1,49 @@ +--- +title: "What was run" +description: "What the library was built with, run on, and read back by, and the one place two readers disagree." +--- + +Everything below ran on The Beast on 2026-11-04, from `test/run.sh all` and `test/core/run.sh`, on the library that is in `dist/1.0.0/`. Nothing here ran on a physical controller. + +## The build + +* Library: `test/build.sh` ends `Compile complete -- 0 errors, 0 warnings` and `RESULT OK`, in the CODESYS 3.5.22.30 (SP22 Patch 3) ScriptEngine under Wine. +* Example application, built against the installed library, for the CODESYS Control for Linux SL device (4102, 0000 0005, 4.20.0.0): `Build complete -- 0 errors, 0 warnings : Ready for download`. + +## The runtime + +`ghcr.io/embernet-ai/codesys-control-sl:4.22.0.0`, started by `test/lab/start_runtime.sh`, loads the application as a boot project and reports `Application started`, `CODESYS Control ready`. Demo mode, because there is no runtime license. The longest single run was under an hour; what happens at the end of the two hour demo window was not tried. + +## What went over the wire, and who read it + +The example application has two publishers, both to a capture listener on a Podman network: writer group 1 and dataset writer 1 in the Variant encoding (1142 bytes), and writer group 2 and dataset writer 2 in the RawData encoding (1144 bytes). Seventeen fields each: one of every type the library supports, a 250 element REAL array, a 4 element INT array, and two counters. + +| Reader | What it is | Variant | RawData | +|---|---|---|---| +| `test/verify` | a decoder written from OPC 10000-14 and 10000-6 alone, which also reads the same variables from the runtime's OPC UA server and compares them | every field equal to the PLC's, no gap in the NetworkMessage and DataSetMessage sequence numbers over the capture | the same | +| open62541 1.4.9 | an independent PubSub implementation, built from its release tarball (sha256 `8735e61391cde0d84e6347a4293d2400b6a72838fb44513caf22d70dfca3a0cf`), run as a subscriber with a DataSet metadata of the 17 fields | all 15 values checked equal, including the string, both arrays and the TIME | the 11 numeric scalars equal. Its RawData reader reads one scalar per field and bounds its buffer by the fields' in-memory sizes (`DataSetReader_processRaw`, `src/pubsub/ua_pubsub_reader.c`), so it cannot read a RawData string longer than a few characters or any RawData array | +| HotLoop core's UADP subscriber | the driver in HotLoop at commit 343b7636, on HotLoop core's main branch, which is not in a core release yet, `internal/driver/uadp.go`, run by `test/core/run.sh` against a live runtime and told the RawData layout in its DataSet types | 21 values checked equal, including the string, the TIME and elements 0, 1, 124 and 249 of the 250 REAL array and all four of the INT array, and both counters advancing between two reads 3 seconds apart | the same 21 values and the same two counters. 62 key frames received, none refused | +| A second Go decoder | the UADP decoder in our own receiver code, at commit f640c324 of that repository, run on the 222 captured datagrams with the layout of the 17 fields | all 111 messages decoded, every checked value correct | all 111 messages decoded, every checked value correct, the arrays with their Int32 length and padding | + +Wireshark 4.4.7's `tshark` (Alpine package) was tried on a capture and dissects UDP port 4840 as plain data: its OPC UA dissector is for the TCP protocol, so it was not usable as a UADP decoder. + +## RawData arrays and strings + +This library writes an Int32 element count before each RawData array, and a string as an Int32 length, its bytes, then zero padding up to MaxStringLength. That is what the OPC Foundation's reference stack does. It was looked up, not argued from the spec text alone: + +* UA-.NETStandard at commit `fcfc84eab5c105780d7268474a47d344d100aca1`, `src/Opc.Ua.PubSub/Encoding/Uadp/UadpBinaryReader.cs` lines 389 to 403 (the `ReadRawScalar` documentation) and 593 to 648 (`TryReadPaddedArray`): arrays keep their Part 6 length prefix and the block is padded to `product(ArrayDimensions)` elements, and a string is an Int32 length inside a `4 + MaxStringLength` block. +* The same repository's `tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpRawDataPaddingTests.cs` asserts the string layout. +* The spec text it implements: OPC 10000-14 7.2.4.5.11 ("handled like a Structure DataType", padding "to the maximum size indicated by ArrayDimensions or MaxStringLength") with OPC 10000-6 5.2.5 ("One dimensional Arrays are encoded as a sequence of elements preceded by the number of elements encoded as an Int32 value"). +* open62541 1.4.9 does not read RawData arrays at all, so it neither agrees nor disagrees. + +HotLoop core's subscriber and the second Go decoder both read the RawData arrays this way, with the length and the padding, and both decode this library's RawData. + +One difference from the reference stack: where it throws when a string is longer than MaxStringLength, this library cuts it at a character boundary, because a PLC task has nowhere to throw to. + +## Not verified + +* Any physical controller, any CODESYS runtime other than Control for Linux SL 4.22.0.0, and any CPU architecture other than x86-64 (the library is Structured Text, but the byte order handling for a big endian target is written and not run). +* Multicast. The block sets the TTL, loopback and send interface for a multicast group; the lab used unicast only. +* A runtime that loses and regains its network, and a send to an address that is not there. The code counts failures and backs off, but a deliberate fault test was not run. +* The behavior at the end of the two hour demo window. +* Receivers other than the ones above. diff --git a/src/content/docs/transmitter/roadmap.md b/src/content/docs/transmitter/roadmap.md new file mode 100644 index 0000000..0fc34e4 --- /dev/null +++ b/src/content/docs/transmitter/roadmap.md @@ -0,0 +1,34 @@ +--- +title: "Transmitter roadmap" +description: "What HotLoop Transmitter has built, what is next, and what is deliberately not planned." +--- + +What is in the box today, what is next, and what is deliberately not planned. A line moves to "Shipped" only +when it has run on the real 4.22.0.0 runtime and a receiver has read it. + +## Shipped in 1.0.0 + +* One publisher block, `FB_HLTX_Publisher`: one NetworkMessage per publishing interval, one DataSetMessage, a Data + Key Frame, UADP over UDP, from Structured Text over SysSocket. +* Variant and RawData field encodings. +* BOOL, SINT, USINT, INT, UINT, DINT, UDINT, LINT, ULINT, REAL, LREAL, STRING and TIME fields, and arrays of + every one of those but STRING, in both encodings. +* Unicast and multicast destinations, publisher id, writer group id, dataset writer id, publishing interval, + group version and configuration version, all set as inputs of the block. +* A non-blocking send, a bounded cost per call, counters for sent, refused and skipped, and an error state that + never faults the application. + +## Next + +* DataSetMessage and NetworkMessage timestamps, from the runtime's real-time clock. +* Several DataSetMessages in one NetworkMessage (the Sizes array), so one datagram can carry more than one writer. +* Data Delta Frames and KeepAlive messages, to cut the bytes of a mostly steady dataset. +* WSTRING fields. +* A per-field status, so a Bad value can be sent as Bad. + +## Not planned + +* Message security (signing and encryption). The block sends in the clear, on a network the plant already trusts. +* Chunked NetworkMessages. A message that does not fit in one 1500 byte frame is refused, not split. +* The subscriber side. HotLoop core subscribes to UADP; this library publishes. +* MQTT transport. UDP is what a CODESYS runtime reaches without more libraries. diff --git a/src/content/docs/transmitter/timing.md b/src/content/docs/transmitter/timing.md new file mode 100644 index 0000000..855d26e --- /dev/null +++ b/src/content/docs/transmitter/timing.md @@ -0,0 +1,33 @@ +--- +title: "What Transmitter adds to the PLC task" +description: "The measured cost of the publisher on a 20 ms cyclic task, and what that does and does not tell you about a physical controller." +--- + +Measured on 2026-11-04 on The Beast, which is a 64 thread AMD Threadripper 2990WX that other work shares (load average 21 to 22 during this run). The runtime is CODESYS Control for Linux SL 4.22.0.0 in a container, in demo mode, with a 20 ms cyclic task, pinned to four cores (40 to 43) with `taskset`. The runtime's own task statistics were not available over OPC UA, so the example application times itself: `PLC_PRG` reads `LTIME()` at its start and end, keeps a mean and a maximum, and counts each cycle into a histogram (`examples/lab/build_app.py`). The baseline is the same program without the publisher calls. + +The publish run has two publishers, one Variant and one RawData, each sending about 1.1 KB every 100 ms, so a message goes out every fifth cycle. Statistics were reset 45 s after the runtime started and read 240 s later (`HLTX_RT_CPUS=40-43 HLTX_TIMING_SECONDS=240 test/run.sh all`). + +| Measure | baseline (no publisher) | two publishers | +|---|---|---| +| Mean time in `PLC_PRG` per cycle | 115 ns | 10 753 ns | +| Longest time in `PLC_PRG` in the window | under 1 us | 14 303 us | +| Cycles under 10 us | 12 001 of 12 001 | 9 592 of 11 993 | +| Cycles 10 to 50 us | 0 | 2 296 | +| Cycles 50 to 100 us | 0 | 100 | +| Cycles 100 us to 1 ms | 0 | 3 | +| Cycles 1 to 10 ms | 0 | 1 | +| Cycles 10 ms or more | 0 | 1 | +| Messages sent / refused / slots skipped | 0 / 0 / 0 | 2 853 / 0 / 0 | + +What that says: + +* **A cycle that sends costs 10 to 100 us.** About 2 400 cycles in the window sent (one in five), and 2 401 cycles took 10 us or more, so the sending cycles are the whole of the cost: 96 percent of them took 10 to 50 us and 4 percent took 50 to 100 us, for two messages of about 1.1 KB. The cycles in between cost what the baseline costs. +* **The mean added cost is about 10.6 us per 20 ms cycle**, 0.05 percent of the task. +* **The tail is the send call, and it can be long.** The longest message took 14.3 ms in `PLC_PRG`, of which `udiMaxSendUs` (the `sendto` itself) was 14.26 ms and `udiMaxEncodeUs` (building the message) 51 us. One cycle in the window took 14.3 ms of a 20 ms task, one more took between 1 and 10 ms, and nothing overran. `sendto` is a kernel call into a container network on a busy host, and the block cannot make it faster. It never waits for the network; it can still be slow when the kernel is busy. If a stall like that matters to your control logic, call the publisher from its own task at a lower priority than the control task. That is what I would do, and I have not measured it here. +* **No send was refused and no slot was skipped** in the window. + +The run it replaces (2026-11-01, load average 30 to 37) measured a mean of 11.7 us and a longest `PLC_PRG` of 3.7 ms, so the typical cost is steady and the tail moves with what the host is doing. + +An earlier run on the same host, with the runtime sharing cores with a build (load average 37), saw single stalls of 1.0 s in the encode and 2.5 s in the send and 460 skipped slots. The encode is plain arithmetic, so a one second stall in it is the runtime's thread not being scheduled, not the block. I am keeping it here because it shows the limit of this measurement: this is a container on a general purpose kernel with no real-time scheduling, so it says what the block costs, and says nothing about the worst case a PLC task can suffer on such a host. + +What this does not tell you: the cost on a physical controller. A small ARM CPU can be ten to a hundred times slower than this host, and the cost grows with the size of the field list. Measure it on the target with `udiLastCallUs`, `udiMaxCallUs`, `udiMaxEncodeUs` and `udiMaxSendUs` on the block, which are there for that. diff --git a/src/content/docs/transmitter/wire-format.md b/src/content/docs/transmitter/wire-format.md new file mode 100644 index 0000000..dc6a2fb --- /dev/null +++ b/src/content/docs/transmitter/wire-format.md @@ -0,0 +1,93 @@ +--- +title: "What goes on the wire" +description: "The NetworkMessage and DataSetMessage HotLoop Transmitter sends, byte by byte, with the OPC 10000-14 and 10000-6 sections that define each one." +--- + +Every byte below comes from OPC 10000-14 (Part 14, PubSub) version 1.05 and OPC 10000-6 (Part 6, mappings), +read at reference.opcfoundation.org on 2026-10-31. The section numbers are the ones to open when a byte looks wrong. +Nothing here was learned by looking at another vendor's packets. + +One publisher block sends one NetworkMessage per publishing interval. A NetworkMessage carries one DataSetMessage, +a Data Key Frame, so every message holds every field. All multi-byte values are little endian, which is how OPC UA +binary is defined; the block writes them byte by byte, so the host's byte order does not matter. + +## NetworkMessage (Part 14, 7.2.4.4.2) + +| Bytes | Value | Meaning | +|---|---|---| +| 1 | `F1` | UADPVersion 1 in bits 0 to 3. UADPFlags in bits 4 to 7: PublisherId (bit 4), GroupHeader (bit 5), PayloadHeader (bit 6) and ExtendedFlags1 (bit 7) all present | +| 1 | `01` | ExtendedFlags1: PublisherId type 001 = UInt16. No DataSetClassId, no security header, no timestamp, no ExtendedFlags2 | +| 2 | PublisherId | `uiPublisherId` | +| 1 | `0F` | GroupFlags: WriterGroupId, GroupVersion, NetworkMessageNumber and SequenceNumber present | +| 2 | WriterGroupId | `uiWriterGroupId` | +| 4 | GroupVersion | `udiGroupVersion` | +| 2 | NetworkMessageNumber | always 1, because 0 is invalid | +| 2 | SequenceNumber | counts messages sent, wraps at 65535 | +| 1 | Count | always 1 (payload header, 7.2.4.5.2) | +| 2 | DataSetWriterId | `uiDataSetWriterId` | + +Because Count is 1, the payload has no Sizes array. The block does not send chunked messages, discovery messages, +promoted fields or a security header, and a receiver that requires one of those is not served by this version. + +## DataSetMessage (Part 14, 7.2.4.5.4 and 7.2.4.5.5) + +| Bytes | Value | Meaning | +|---|---|---| +| 1 | `69` Variant, `6B` RawData | DataSetFlags1: valid (bit 0), field encoding (bits 1 and 2: 00 Variant, 01 RawData), sequence number (bit 3), ConfigurationVersion major (bit 5) and minor (bit 6). Status, DataSetFlags2 and timestamps are absent, so the type is the default, a Data Key Frame | +| 2 | DataSetMessage sequence number | counts messages sent, wraps at 65535 | +| 4 | ConfigurationVersion major | `udiConfigMajor` | +| 4 | ConfigurationVersion minor | `udiConfigMinor` | +| 2 | FieldCount | Variant only. Omitted for RawData (7.2.4.5.5) | +| ... | the fields | in the order they were added | + +Bump `udiConfigMajor` whenever the field list changes. A receiver that keeps a layout keyed on the version can then +refuse a message built for a different list instead of decoding it wrong. + +## Fields, Variant encoding (Part 6, 5.2.2.16) + +Each field is an EncodingMask byte, then the value. Bits 0 to 5 of the mask are the built-in type id (Part 6, 5.1.2, +table 1), and bit 7 says the value is an array, in which case an Int32 element count comes before the elements. + +| PLC type | Type id | Value bytes | +|---|---|---| +| BOOL | 1 Boolean | 1, `00` or `01` | +| SINT | 2 SByte | 1 | +| USINT, BYTE | 3 Byte | 1 | +| INT | 4 Int16 | 2 | +| UINT, WORD | 5 UInt16 | 2 | +| DINT | 6 Int32 | 4 | +| UDINT, DWORD | 7 UInt32 | 4 | +| LINT | 8 Int64 | 8 | +| ULINT, LWORD | 9 UInt64 | 8 | +| REAL | 10 Float | 4, IEEE 754 | +| LREAL | 11 Double | 8, IEEE 754 | +| STRING | 12 String | Int32 length in bytes, then UTF-8 (5.2.2.4) | +| TIME | 7 UInt32 | milliseconds, which is how a CODESYS TIME is held | + +A CODESYS STRING holds one byte per character. Bytes up to `7F` are ASCII and go out unchanged. Bytes above `7F` are +read as ISO 8859-1 and written as their two byte UTF-8 form. That is an assumption about the PLC's code page, and it +is the one place the block interprets text. + +## Fields, RawData encoding (Part 14, 7.2.4.5.11) + +No mask and no count: each field is just its value, in order, so the receiver must know the layout. Part 14 says the +fields are "handled like a Structure DataType", so each value is encoded as a Structure field is in Part 6. + +* **Scalars** are their value bytes, as in the Variant table, with no mask. +* **Arrays** are an Int32 element count and then the elements (Part 6, 5.2.5: "One dimensional Arrays are encoded as + a sequence of elements preceded by the number of elements encoded as an Int32 value"). The block always sends + every element it was given, so the count equals the array's declared size and there is no padding after it. +* **Strings** are an Int32 length in bytes and the UTF-8 bytes, then zero bytes up to MaxStringLength, so the field is + always 4 + MaxStringLength bytes (7.2.4.5.11: "the field shall be padded with bytes with value zero"). The block + uses the N of `STRING(N)` as MaxStringLength, in bytes. Text longer than that is cut at a character boundary, + because the field cannot grow. A receiver's field metadata must carry the same MaxStringLength and an + ArrayDimensions entry for each array. + +This is the reading of the OPC Foundation's own reference stack. In UA-.NETStandard (commit `fcfc84eab5c105780d7268474a47d344d100aca1`), `src/Opc.Ua.PubSub/Encoding/Uadp/UadpBinaryReader.cs` lines 389 to 403 say a string is "read like a Structure field (Int32 length prefix and value) from a fixed-size 4 + MaxStringLength byte block whose remainder is zero padding", and that for an array "the array's Part 6 length (or dimensions) prefix gives the actual element count and the block is padded to product(arrayDimensions) elements". `TryReadPaddedArray` at lines 593 to 648 implements it, and `tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpRawDataPaddingTests.cs` asserts it. `docs/PROOF.md` has the rest. + +## Size + +The block builds the message in a 1472 byte buffer: 1500 bytes of Ethernet payload less 20 of IP and 8 of UDP, so a +message never fragments. A field list that does not fit puts the block in `ConfigError` with error `TooBig`; it does +not truncate. A 250 element REAL array in Variant encoding is 1005 bytes, so a message of that array plus the header +fits, and two of them do not. diff --git a/src/data/sync.json b/src/data/sync.json index 79ac60f..ee327b1 100644 --- a/src/data/sync.json +++ b/src/data/sync.json @@ -73,6 +73,12 @@ "name": "HotLoop Cascade", "repo": "cascade", "tagline": "The plant talking to everything else." + }, + { + "id": "transmitter", + "name": "HotLoop Transmitter", + "repo": null, + "tagline": "OPC UA PubSub from inside the PLC." } ], "pages": [ @@ -421,7 +427,7 @@ "slug": "products", "product": "shared", "group": "Lineup", - "title": "HotLoop products: six on three release lines", + "title": "HotLoop products: seven on four release lines", "label": null, "status": "changed", "repo": "hotloop",