Skip to content
GitHub

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.

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).

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 scan finds 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.
  • Context persists by default. store.context.persist is 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. Set persist to false for 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: false turns 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/gos7 with that one line sent through the handler’s logger, and third_party/gos7/HOTLOOP_PATCH.txt says 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 a Containerfile now, and docker-publish.yml is image-publish.yml.

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.db in data.dir, on the volume. With persistence.enabled false in the chart it survives a container restart and not a pod replacement.
  • Going back to 2.1.0 leaves state.db where it is. 2.1.0 has no code that reads it.
  • Every write to state.db is a write to an SD card or an SSD, so the defaults batch. Context changes wait flushInterval (30 seconds) and are written together, and a value set to what it already was writes nothing. A kill -9 can lose that much. docs/durable/README.md has the numbers and what it costs a card.
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.

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_server from 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_RsaPss isn’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