From 6d256a00f0c4816544a4de47d07bca1cc20e3bcf Mon Sep 17 00:00:00 2001 From: patrickryan01 Date: Wed, 4 Nov 2026 14:31:46 -0500 Subject: [PATCH] docs: hotloop transmitter 1.1.0 is out, so the pages follow its notes, timestamps and the package install the install page has the package route and the downloads on hotloop.io, the wire format, api, proof, timing and roadmap pages follow the v1.1.0 docs, and the 1.1.0 release notes sit beside 1.0.0. the private repo's paths and evidence folder stay out. --- astro.config.mjs | 3 +- scripts/sync-docs.mjs | 1 + scripts/sync-manifest.mjs | 2 +- src/components/ProductPicker.astro | 4 +- src/content/docs/products.md | 2 +- src/content/docs/releases/index.md | 1 + .../docs/releases/transmitter-1-1-0.md | 80 +++++++++++++++++++ src/content/docs/transmitter/api.md | 3 + src/content/docs/transmitter/install.md | 16 ++-- src/content/docs/transmitter/overview.md | 13 +-- src/content/docs/transmitter/proof.md | 4 + src/content/docs/transmitter/roadmap.md | 7 +- src/content/docs/transmitter/timing.md | 29 +++---- src/content/docs/transmitter/wire-format.md | 23 ++++-- 14 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 src/content/docs/releases/transmitter-1-1-0.md diff --git a/astro.config.mjs b/astro.config.mjs index 181bf90..dcbab7a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -172,7 +172,8 @@ export default defineConfig({ { 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' }, + { slug: 'releases/transmitter-1-1-0', label: 'Release notes 1.1.0' }, + { slug: 'releases/transmitter-1-0-0', label: 'Release notes 1.0.0' }, ], }, { diff --git a/scripts/sync-docs.mjs b/scripts/sync-docs.mjs index f139c95..2ca9e31 100644 --- a/scripts/sync-docs.mjs +++ b/scripts/sync-docs.mjs @@ -487,6 +487,7 @@ ${rows('cascade')} ## HotLoop Transmitter +- [HotLoop Transmitter 1.1.0 release notes](/releases/transmitter-1-1-0/) - [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 c5ecbf4..6f48833 100644 --- a/scripts/sync-manifest.mjs +++ b/scripts/sync-manifest.mjs @@ -219,7 +219,7 @@ Object.assign(SLUG_RULES, { ['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'], - ['Transmitter', 'Transmitter', 'transmitter/overview', 'transmitter/install', 'releases/transmitter-1-0-0', 'transmitter/roadmap']] + ['Transmitter', 'Transmitter', 'transmitter/overview', 'transmitter/install', 'releases/transmitter-1-1-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 8ee64f2..4167da3 100644 --- a/src/components/ProductPicker.astro +++ b/src/components/ProductPicker.astro @@ -7,8 +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' }; +// Transmitter 1.1.0 shipped 2026-11-04 (HotLoop-io/hotloop-transmitter v1.0.0). Change this when the next one does. +const transmitter = { version: '1.1.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]; diff --git a/src/content/docs/products.md b/src/content/docs/products.md index 260402d..1079db2 100644 --- a/src/content/docs/products.md +++ b/src/content/docs/products.md @@ -26,7 +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 | +| **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 3009378..1d52f3f 100644 --- a/src/content/docs/releases/index.md +++ b/src/content/docs/releases/index.md @@ -40,4 +40,5 @@ IoT, Edge, Gateway and Edge Relay release together from one tag, so they share t ## HotLoop Transmitter +- [HotLoop Transmitter 1.1.0 release notes](/releases/transmitter-1-1-0/) - [HotLoop Transmitter 1.0.0 release notes](/releases/transmitter-1-0-0/) diff --git a/src/content/docs/releases/transmitter-1-1-0.md b/src/content/docs/releases/transmitter-1-1-0.md new file mode 100644 index 0000000..9206336 --- /dev/null +++ b/src/content/docs/releases/transmitter-1-1-0.md @@ -0,0 +1,80 @@ +--- +title: "HotLoop Transmitter 1.1.0" +description: "Every message carries a timestamp from the PLC's clock, and the library installs from a CODESYS package: what changed, what was run, and what was not." +--- + +Every message now carries a timestamp from the PLC's clock, and the library now installs from a CODESYS `.package`. + +What that means for you: a subscriber that wants to know when a value was sampled gets it from the packet instead of +guessing from when it arrived, and you can install the library through the CODESYS Installer like any other add-on. + +## What changed for a project that uses 1.0.0 + +* **Timestamps, on by default.** The NetworkMessage and the DataSetMessage each carry a Timestamp (Part 14 7.2.4.4.2 + and 7.2.4.5.4), the same DateTime, read from the PLC's real-time clock once per message. ExtendedFlags1 goes from + `01` to `21`, DataSetFlags1 from `69` or `6B` to `E9` or `EB`, and a DataSetFlags2 byte `10` is added. A message is + 17 bytes longer. The field list and the values did not move, and `udiConfigMajor` is the same. +* **`bTimestamp := FALSE` sends exactly the 1.0.0 bytes.** Pin that if a receiver of yours refuses timestamps. The lab + checked it: 1142 and 1144 byte messages, none stamped, the sizes 1.0.0 sent. +* **The resolution is one second.** The clock is read with `SysTimeRtcGet`, which is whole seconds, so a receiver sees + the same timestamp on every message inside a second and it can be up to a second behind the instant the message was + built. Sub-second stamps are on the roadmap. +* **A new output, `diLastClockResult`.** If the clock cannot be read the message goes out unstamped, which Part 14 + allows, and this says why. +* **A new system library, SysTimeRtc.** The library names it as a placeholder, like SysSocket, and the runtime provides + it. Control for Linux SL 4.22.0.0 does. A target without it cannot use 1.1.0 with timestamps; stay on 1.0.0. +* **The `.package`.** `HotLoopTransmitter-1.1.0.package` was made by CODESYS's own PackageManagerCLI and puts the same + library into the library repository through the Installer. See the [install page](/transmitter/install/). + +## 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, Variant and RawData, timestamps on. `test/verify` decodes every message and every value equals what + the PLC reports over its own OPC UA server. Every message has both timestamps, they are the same instant, they never + go backwards, they are within the whole-second tolerance of the clock the capture ran on, and over an 11 second + capture the stamp moved 11 seconds. The container shares the host's clock, so this shows the stamp is the PLC's clock + reading and not an invented one; it does not test a controller whose clock is wrong. +* open62541 1.4.9 reads the NetworkMessage and DataSetMessage Timestamp of all 113 messages of each writer with its + own header decoders, and decodes the values as it did for 1.0.0. +* HotLoop core's subscriber (from its main branch, not in a core release yet) reads both publishers against a live runtime: 21 values each, both counters + advancing, nothing refused. Every reading carries the publisher's whole-second timestamp as its time, and the + timestamp moved with the PLC clock over a 3 second wait. +* The `.package` installs through `PackageManagerCLI --install`, the library appears in the System repository (it was + not there before), and the example builds against the library as the Installer put it there, 0 errors and 0 warnings. +* Cost on a 20 ms task, two publishers: about 9.6 us added per cycle on average, 10 to 100 us on the cycles that send, + and the longest cycle in the run 81 us. Three publish slots were skipped in that run, and no send was refused. The + timestamp itself is not visible in the numbers. 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. +* A controller whose real-time clock is unset or wrong. The block would stamp what the clock says. +* The `.package` in the CODESYS IDE's own Package Manager window. It installed headless with PackageManagerCLI. +* Multicast. The lab used unicast. +* A runtime that loses and regains its network, or a send to an address that is not there. +* What happens when the two hour demo window of the runtime ends. +* Receivers other than the four above. + +## Not in this release + +KeepAlive and Delta Frames, more than one DataSetMessage per NetworkMessage, WSTRING, a per-field status, sub-second +timestamps, message security, and the subscriber side. The [roadmap](/transmitter/roadmap/) says which are next. + +## Rolling back + +A PLC project pinned to 1.0.0 keeps working and keeps sending the 1.0.0 bytes. Within 1.1.0, `bTimestamp := FALSE` is +the same bytes. + +## Files + +Download from https://hotloop.io/hotloop-transmitter/1.1.0/, and check them against `SHA256SUMS` in the same folder. + +``` +5ec021b23f5d57460016834a03b2b196d6dbc83aeb5d0ad60ace8d7b4fbbb4a7 HotLoopTransmitter-1.1.0.library +e172f4674f6cbfce2aeaf017d5a6c733245b1b9aa3e527369c753bf07b6ce0c2 HotLoopTransmitter-1.1.0.compiled-library +b3686bdebd052f1ddea46d1f1ff409f3b6c9a016ca5f3e8f4cd6f7922ccb479c HotLoopTransmitter-1.1.0.package +``` diff --git a/src/content/docs/transmitter/api.md b/src/content/docs/transmitter/api.md index cfebed5..db6c3bc 100644 --- a/src/content/docs/transmitter/api.md +++ b/src/content/docs/transmitter/api.md @@ -21,6 +21,7 @@ Everything is in the `HLTX` namespace. The block is `HLTX.FB_HLTX_Publisher`. | `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 | +| `bTimestamp` | BOOL | TRUE | stamp every message with the PLC clock, in the NetworkMessage and the DataSetMessage (whole seconds, see `WIRE-FORMAT.md`). FALSE sends the 1.0.0 bytes, 17 bytes shorter | | `tRetry` | TIME | T#2S | how long to wait before reopening the socket after ten sends in a row failed | ## Outputs @@ -35,6 +36,8 @@ Everything is in the `HLTX` namespace. The block is `HLTX.FB_HLTX_Publisher`. | `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 | +| `diLastClockResult` | UDINT | the last result of reading the PLC clock for a timestamp, 0 is fine. When it is not 0 the message goes out unstamped | +| `diLastClockResult` | UDINT | the last result of reading the PLC clock for a timestamp, 0 is fine. When it is not 0 the message goes out unstamped | | `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()` | diff --git a/src/content/docs/transmitter/install.md b/src/content/docs/transmitter/install.md index 7cf14e4..d1a1900 100644 --- a/src/content/docs/transmitter/install.md +++ b/src/content/docs/transmitter/install.md @@ -3,20 +3,26 @@ 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. +You need a CODESYS development system, 3.5 SP22 or later, and a runtime whose target has the SysSocket and SysTimeRtc components, +which the CODESYS Control runtimes for Linux SL have (the second one is new in 1.1.0, for the timestamp). -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`). +## From the CODESYS Installer + +Since 1.1.0 the release also has `HotLoopTransmitter-1.1.0.package`, at https://hotloop.io/hotloop-transmitter/1.1.0/HotLoopTransmitter-1.1.0.package. Install it with the Package Manager of the IDE (Tools, Package Manager), or with no window using `PackageManagerCLI.exe --profile="" --install=.package --targetProfiles=""` with no window). The Installer puts the library into that profile's library repository under the company HotLoop, and you go to step 3 below. The package is the same library as the `.library` file, in a ZIP the Installer reads, so the two routes give the same library. + +## From the library file + +1. Download `HotLoopTransmitter-1.1.0.library` from https://hotloop.io/hotloop-transmitter/1.1.0/HotLoopTransmitter-1.1.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. + SysSocket, SysTimeRtc and their 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`. +Structured Text visible in the Library Manager. It is in the same folder, as `HotLoopTransmitter-1.1.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. diff --git a/src/content/docs/transmitter/overview.md b/src/content/docs/transmitter/overview.md index 4b93177..6fd5e8f 100644 --- a/src/content/docs/transmitter/overview.md +++ b/src/content/docs/transmitter/overview.md @@ -12,7 +12,8 @@ configurator. You add the library, call one function block once per cycle, and a 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. +including HotLoop itself, with a few lines of Structured Text. Every message carries a timestamp from the PLC's clock +unless you switch it off with `bTimestamp`. ## Why this is Structured Text and not Go @@ -23,8 +24,8 @@ 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. +1. Install `HotLoopTransmitter-1.1.0.library` into the CODESYS library repository (Tools, Library Repository, + Install), or install `HotLoopTransmitter-1.1.0.package` with the CODESYS Installer, then add it to the project's Library Manager. The [install page](/transmitter/install/) has the downloads and the details. 2. Declare a publisher, add the variables you want to publish once at start, and call it every cycle: ```iecst @@ -57,7 +58,9 @@ sections of the spec that define each byte. * 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/). + out. Measured on a 20 ms task with two publishers and timestamps on: about 9.6 us added per cycle on average, 10 to + 100 us on the cycles that send, and the longest cycle in the run 81 us. A busy host has stretched a single send to + milliseconds. The measured numbers and their limits are in 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 @@ -65,7 +68,7 @@ sections of the spec that define each byte. ## 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 +The CODESYS Control for Linux SL 4.22.0.0 runtime, in a container. See the [timing page](/transmitter/timing/) and the [proof page](/transmitter/proof/) for what was run and what was read back. Not run on physical hardware. ## License diff --git a/src/content/docs/transmitter/proof.md b/src/content/docs/transmitter/proof.md index 15ddd44..a7ba513 100644 --- a/src/content/docs/transmitter/proof.md +++ b/src/content/docs/transmitter/proof.md @@ -3,6 +3,10 @@ title: "What was run" description: "What the library was built with, run on, and read back by, and the one place two readers disagree." --- +This page is the 1.0.0 run. The 1.1.0 run, with timestamps and the `.package`, is in the [1.1.0 release notes](/releases/transmitter-1-1-0/). It used the same lab, plus +`test/verify -timestamps`, `test/open62541/decode.c` (open62541's own header decoders reading the Timestamps), +`test/core/run.sh` with timestamps, and `test/package.sh`. + 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 diff --git a/src/content/docs/transmitter/roadmap.md b/src/content/docs/transmitter/roadmap.md index 0fc34e4..e1786ef 100644 --- a/src/content/docs/transmitter/roadmap.md +++ b/src/content/docs/transmitter/roadmap.md @@ -6,6 +6,11 @@ description: "What HotLoop Transmitter has built, what is next, and what is deli 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.1.0 + +* DataSetMessage and NetworkMessage timestamps from the PLC's real-time clock, on by default, `bTimestamp` switches them off. +* The library as a CODESYS `.package`, installed through the CODESYS Installer. + ## Shipped in 1.0.0 * One publisher block, `FB_HLTX_Publisher`: one NetworkMessage per publishing interval, one DataSetMessage, a Data @@ -20,7 +25,7 @@ when it has run on the real 4.22.0.0 runtime and a receiver has read it. ## Next -* DataSetMessage and NetworkMessage timestamps, from the runtime's real-time clock. +* Sub-second timestamps. The 1.1.0 stamp is the real-time clock's whole seconds. * 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. diff --git a/src/content/docs/transmitter/timing.md b/src/content/docs/transmitter/timing.md index 855d26e..525a264 100644 --- a/src/content/docs/transmitter/timing.md +++ b/src/content/docs/transmitter/timing.md @@ -3,30 +3,27 @@ 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. +Measured on 2026-11-04, on 1.1.0 with timestamps on, on The Beast, which is a 64 thread AMD Threadripper 2990WX that other work shares (load average 17 to 18 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 | +| Measure | baseline (no publisher) | two publishers, timestamps on | |---|---|---| -| 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 | +| Mean time in `PLC_PRG` per cycle | 103 ns | 9 584 ns | +| Longest time in `PLC_PRG` in the window | 1 us | 81 us | +| Cycles under 10 us | 11 997 of 11 997 | 9 580 of 11 976 | +| Cycles 10 to 50 us | 0 | 2 299 | +| Cycles 50 to 100 us | 0 | 97 | +| Cycles 100 us or more | 0 | 0 | +| Messages sent / refused / slots skipped | 0 / 0 / 0 | 2 849 / 0 / 3 | 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. +* **A cycle that sends costs 10 to 100 us.** About 2 400 cycles in the window sent (one in five), and 2 396 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.16 KB. The cycles in between cost what the baseline costs. +* **The mean added cost is about 9.5 us per 20 ms cycle**, 0.05 percent of the task. The timestamp itself is one clock read and 17 more bytes a message; the run before it (1.0.0, no timestamp) measured 10.6 us, and the difference is inside what the host's load moves it by, so this run does not show a cost for the timestamp. +* **The longest send was 62 us and the longest encode 12 us** in this window. Three publish slots were skipped, so the task ran late three times; no send was refused. `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, which the 1.0.0 run showed (below). -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. +The 1.0.0 run of the same day (load average 21 to 22) measured a mean of 10.6 us and one send of 14.3 ms, and the run before it (2026-11-01, load average 30 to 37) a mean of 11.7 us and a longest `PLC_PRG` of 3.7 ms. The typical cost is steady at 10 us a cycle; the tail moves with what the host is doing, so read the longest figure as a limit of the measurement, and put the publisher in its own lower priority task if a stall matters to your control logic (not measured here). 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. diff --git a/src/content/docs/transmitter/wire-format.md b/src/content/docs/transmitter/wire-format.md index dc6a2fb..14835a0 100644 --- a/src/content/docs/transmitter/wire-format.md +++ b/src/content/docs/transmitter/wire-format.md @@ -8,15 +8,18 @@ read at reference.opcfoundation.org on 2026-10-31. The section numbers are the o 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. +a Data Key Frame, so every message holds every field. Since 1.1.0 each message carries a timestamp from the PLC's +clock, in both the NetworkMessage and the DataSetMessage, unless `bTimestamp` is FALSE, in which case the bytes are +exactly 1.0.0's. The tables show both: the rows marked stamped are there only when the timestamp is on. + +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 | +| 1 | `21` stamped, `01` unstamped | ExtendedFlags1: PublisherId type 001 = UInt16 in bits 0 to 2, Timestamp (bit 5) when stamped. No DataSetClassId, no security header, no PicoSeconds, no ExtendedFlags2 | | 2 | PublisherId | `uiPublisherId` | | 1 | `0F` | GroupFlags: WriterGroupId, GroupVersion, NetworkMessageNumber and SequenceNumber present | | 2 | WriterGroupId | `uiWriterGroupId` | @@ -25,6 +28,7 @@ binary is defined; the block writes them byte by byte, so the host's byte order | 2 | SequenceNumber | counts messages sent, wraps at 65535 | | 1 | Count | always 1 (payload header, 7.2.4.5.2) | | 2 | DataSetWriterId | `uiDataSetWriterId` | +| 8 | Timestamp, stamped only | the NetworkMessage Timestamp (7.2.4.4.2), a DateTime (Part 6, 5.2.2.5): 100 ns ticks since 1601-01-01 00:00:00 UTC as an Int64. It comes after the payload header and before the DataSetMessage | 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. @@ -33,13 +37,22 @@ promoted fields or a security header, and a receiver that requires one of those | 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 | +| 1 | `69` Variant, `6B` RawData; stamped `E9` and `EB` | 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), and DataSetFlags2 (bit 7) when stamped. Status is absent. Unstamped, DataSetFlags2 is absent too and the type is the default, a Data Key Frame | +| 1 | `10`, stamped only | DataSetFlags2: message type 0 (Data Key Frame) in bits 0 to 3, Timestamp (bit 4) | | 2 | DataSetMessage sequence number | counts messages sent, wraps at 65535 | +| 8 | Timestamp, stamped only | the DataSetMessage Timestamp (7.2.4.5.4), the same DateTime as the NetworkMessage's, after the sequence number | | 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 | +## The timestamp + +The block reads the PLC's real-time clock once per message with `SysTimeRtcGet` (the SysTimeRtc system library, which the runtime provides), which is whole seconds since 1970-01-01 UTC, and writes `(seconds + 11644473600) * 10000000` as the DateTime. Both timestamps in a message are that one value. Two things follow from it: + +* The resolution is one second. A receiver sees the same timestamp on every message inside a second, and it is up to a second behind the instant the message was built. Sub-second stamps are on the roadmap. +* The time is only as right as the PLC's clock. A controller with an unset clock stamps its message 1970. When the clock cannot be read (`diLastClockResult` is not 0) the message goes out unstamped, which Part 14 allows, rather than not at all. + 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. @@ -83,7 +96,7 @@ fields are "handled like a Structure DataType", so each value is encoded as a St 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. +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. The [proof page](/transmitter/proof/) has the rest. ## Size