HotLoop Flow 2.2.0 release notes
HotLoop Flow 2.2.0 is the release where Flow talks to the PLCs, remembers what it knew when it restarts, and shows you where a message went. It is also the first release under HotLoop’s Business Source License, and I’m putting that first because it’s the part you have to read.
The license changed
Section titled “The license changed”2.2.0 and every release after it is under HotLoop’s Business Source License 1.1, the same license as the rest of HotLoop. It is not an open source license and I’m not going to pretend it is. Flow’s source is still public, so you can read every line you run.
- Home and lab use is free.
- Accredited schools and educational institutions, nonprofits and government bodies are free for their own operations and mission.
- Business use is free when it runs through EmberNET. That’s the only business path.
- Hosting Flow as a service, reselling it and embedding it in a product you sell are not granted.
- Each version converts to GPL-3.0 or later four years after it is first made public.
LICENSE is the legal text, byte for byte the one in HotLoop-io/HotLoop-io. 2.1.0 and everything before it stay under the license they shipped with. Nothing about those releases changes. The ported Node-RED files keep their original license headers and credits, so their license text now ships in LICENSES/ next to NOTICE, and the image carries both in /usr/share/doc/hotloop-flow/. NOTICE also covers the patched copy of gos7 that Flow carries now (below).
What’s new
Section titled “What’s new”Tracing and replay
- Every message gets a bounded trace. Click a debug message and the editor lights the path it took, with timing and the payload at each hop, through splits, joins and subflows.
- A trace becomes a flow test, or replays into a draft so you can try a fix on the message that broke it.
- Every hop can go to an OpenTelemetry collector as a span (
trace.otlp.endpoint). It was proven against the real collector image in CI.
State that survives a restart
- Flow, global and node context live in a SQLite file,
state.db, on the data volume. A restart no longer wipes them. - Durable wires. Mark a wire durable and, while the node on the other end keeps up, it’s an ordinary wire and writes nothing. When that node stops taking messages, the wire spills to the same file in order and drains when the target comes back. I proved it against a real InfluxDB killed for ten minutes, with the runtime
kill -9’d mid-outage and mid-drain: none missing, none doubled.
Plant nodes
- Modbus TCP read and write, OPC UA read, write and subscribe, S7 read and write, EtherNet/IP read and write, and Sparkplug B as an edge node and as a host. Flow can talk to what
scanfinds now. - Sparkplug does the births, the will,
bdSeq, rebirth requests and commands the spec asks for. I checked it against pysparkplug in both directions, edge node and host. - A silent device fails the request inside the timeout and the next message reconnects. An OPC UA subscription notices it dropped, says so once, and picks itself back up.
The editor
- Copy and paste, import and export of a selection, subflows with their own canvas, nested groups, search across every flow, debug filters, alt-click to delete a wire. A browser test clicks through every node dialog from the list the runtime serves, so a new node is covered the day it ships.
- Dragging a wire onto a node never worked in Chromium. It does now.
Checks
- A node whose config node isn’t in the file (a deleted broker a subflow still names, or a reference to a tab or the wrong kind of config node) is reported on import, on load and on deploy, and it fails to start. Before, it quietly never worked.
- Deleting a flow or a node takes its id out of the Catch, Status and Complete scopes that named it.
What changed
Section titled “What changed”- Context persists by default.
store.context.persistis on, so a value set before a restart is there after it, where before every restart started empty. A flow that counted on an empty context at start now has to clear what it expects to be clear. Setpersisttofalsefor the old behavior. - Tracing is on by default and bounded every way: 256 journeys, 512 hops each, 16 MiB all told, 1000 new journeys a second. It’s memory only, so a restart starts the list over.
trace.enabled: falseturns it off. - gos7 printed a raw line to Go’s global logger on every S7 timeout, and nothing on the node could turn it off. Flow carries gos7 in
third_party/gos7with that one line sent through the handler’s logger, andthird_party/gos7/HOTLOOP_PATCH.txtsays exactly what changed. - The durable queue counts a message as waiting after it’s on disk, not before.
- The image is built and pushed with Podman, not Docker. Same image, same tags (
2.2.0,2.2,2), still linux/amd64 and linux/arm64. The build file is aContainerfilenow, anddocker-publish.ymlisimage-publish.yml.
Upgrade notes
Section titled “Upgrade notes”From 2.1.0 it’s a plain image bump (helm upgrade, or the new tag in your Quadlet unit). No migration, no flag. A flows.json and a config file that ran on 2.1.0 run on 2.2.0, and the byte-exact round trip of the flow file is untouched.
- The first start creates
state.dbindata.dir, on the volume. Withpersistence.enabledfalse in the chart it survives a container restart and not a pod replacement. - Going back to 2.1.0 leaves
state.dbwhere it is. 2.1.0 has no code that reads it. - Every write to
state.dbis a write to an SD card or an SSD, so the defaults batch. Context changes waitflushInterval(30 seconds) and are written together, and a value set to what it already was writes nothing. Akill -9can lose that much.docs/durable/README.mdhas the numbers and what it costs a card.
New settings
Section titled “New settings”| YAML | Environment | Default |
|---|---|---|
store.path |
HOTLOOP_FLOW_STORE_PATH |
empty, which is state.db in data.dir |
store.synchronous |
HOTLOOP_FLOW_STORE_SYNCHRONOUS |
normal. full fsyncs every commit and wears the card for it. |
store.context.persist |
HOTLOOP_FLOW_CONTEXT_PERSIST |
true |
store.context.flushInterval |
HOTLOOP_FLOW_CONTEXT_FLUSH_INTERVAL |
30s. 0s writes every change before the flow moves on. |
store.durable.maxBytes |
HOTLOOP_FLOW_DURABLE_MAX_BYTES |
256 MiB for every durable wire together |
store.durable.ackFlush |
HOTLOOP_FLOW_DURABLE_ACK_FLUSH |
100ms. 0s marks each message before the next is sent. |
trace.enabled, trace.traces, trace.maxHops, trace.maxMessageBytes, trace.maxBytes, trace.perSecond |
true, 256, 512, 16384, 16 MiB, 1000. perSecond: 0 traces every message. |
|
trace.otlp.endpoint, trace.otlp.serviceName, trace.otlp.interval |
HOTLOOP_FLOW_TRACE_OTLP_ENDPOINT |
empty (no export), hotloop-flow, 2s |
trace.otlp.headersSecret (chart) |
HOTLOOP_FLOW_TRACE_OTLP_HEADERS |
none. A Secret key holding Name=value pairs separated by commas, for a hosted collector’s API key. |
The chart has all of these under store and trace in values.yaml.
What isn’t proven
Section titled “What isn’t proven”It has never run on a real plant floor.
- The S7 and EtherNet/IP nodes have only met simulators: the server in python-snap7 for S7 and
ab_serverfrom libplctag for EtherNet/IP. Neither has talked to a Siemens CPU or a Logix controller. The S7 bit write is checked on the wire, request by request, because the simulator overwrites the whole byte the way a CPU doesn’t. - Modbus and OPC UA were tested against pymodbus and Microsoft’s OPC PLC.
Aes256_Sha256_RsaPssisn’t in the OPC UA dropdown, because gopcua v0.9.1 fails it against OPC PLC and I won’t offer a setting I can’t check. - Durable wires have never been on a Pi. The measurements were taken on an x86 box under WSL, and I won’t print a card figure I didn’t measure on a card.
- Not done: Modbus RTU, Modbus over TLS, OPC UA events, history, method calls and browse, S7 timers and counters, PCCC controllers.
Image: ghcr.io/hotloop-io/hotloop-flow:2.2.0, linux/amd64 and linux/arm64. Chart: https://hotloop.io/hotloop-flow/. The README at this tag has the rest: https://github.com/HotLoop-io/hotloop-flow/blob/v2.2.0/README.md