ip-config.json. See The Kart Fleet Configuration for the file itself.
This page is the mechanism. Read it when you are working on the pipeline, or when you are staring at InfluxDB trying to work out what a kart is actually doing.
The Shape Of The Pipeline
Four containers and two NATS subjects.
Both subjects live on a single JetStream stream called
telemetry, file backed, holding one hour of history and capped at 1 GB. Whichever limit is reached first starts discarding.
Each consuming service has its own durable name, and every replica of that service shares it. That shared name is what makes the five replicas of
raw_to_telemetry split the work between them rather than each processing every message. It is also why raw_to_file sees the whole of raw_data independently, despite reading the same subject.telemetry_data, skipping the decode stage entirely. Internal signals are already named values, so there is nothing for the DBC to do.
Why The Receiver Sends Requests
Not everything on the kart volunteers its data, and that single fact is why the receiver transmits at all.Neither is reached directly. A connection is to the one device fitted on the kart, an ECAN or a DAQ, which bridges the kart’s CAN bus to TCP. Requests and responses both pass through it. See What Is On A Kart.
littleEndianTxData are that asking. Each one is a request to a battery node for the values of the DBC signals we want from it, and the responses that come back are what the DBC then decodes into channels. Stop sending them and the controller signals keep arriving while the battery data simply stops.
When Something Else Does The Asking
isDataLoggerAttached means “a device on this kart is already polling the battery nodes”. It covers two situations:
- A data logger fitted alongside the ECAN. Both devices are present, and the logger does the polling.
- A DAQ fitted. There is no ECAN on the kart at all. The DAQ replaces the whole of the ECAN’s functionality and adds the running telemetry on top, and it does the polling itself.
This is also why the receiver discards frames whose payload bytes are all zero when the flag is set. Those are the attached device’s own requests going past rather than a node’s answer, so they are counted as
xDataLoggerReqsPerConsumeCnt and never sent for decoding.More Requests Than There Are Nodes
The receiver does not know how many battery nodes a kart has, or which node IDs they are using. Nothing inip-config.json says, and nothing asks the kart first. So it requests every node it could have and lets the kart sort it out.
The DBC defines messages for five battery nodes, node 0 through node 4, and the request set covers all five. How many are actually fitted depends on the category:
A node that is present answers. A node that is not present says nothing whatsoever. So on a junior the receiver asks all five nodes on every pass and gets replies from three of them, and the three are not necessarily nodes 0 to 2: the IDs present are whatever that kart’s nodes are set to, so in practice it might be 1, 3 and 4.
Most of a bambino’s request traffic is therefore unanswered by design, and that is not a fault. An unanswered request produces nothing at all, so it costs time on every pass but it does not appear as an
xFrameIdUnknown_ measurement. Absent nodes look like fewer response counters, never like rubbish.One Task Per Connection
The receiver does not discover karts and does not multiplex them. At startup it readsip-config.json, keeps the karts with at least one active connection, and within each kart keeps the connections whose isActive is true. isActive is optional and defaults to true, so a connection that omits it is kept. Every surviving connection gets its own long running task, its own TCP socket and its own counters. Nothing is shared between connections.
Each connection then runs up to two loops against that one socket:
A connection with a data logger or a DAQ attached is listen only, for the reasons above. So
isDataLoggerAttached changes what the receiver does, not merely what it records.Transmitting Requests
The request frames are not in the code. They live inip-config.json under littleEndianTxData, keyed by battery spec, and they are held little endian. One pass through the whole set is a dataset, which is the unit fullRequestDatasetsPerSecond counts.
For a big endian connection the receiver converts them at startup: byte 0 is copied through untouched, bytes 1 to 4 are reversed, and bytes 5 to 12 are reversed. A wrong isLittleEndian therefore does not only garble what comes back, it also byte swaps the questions being asked.
Frames are then grouped into chunks of txChunkSize and each chunk is written to the socket as a single write, with a fixed delay between chunks:
Because the comparison is against elapsed time since the connection opened,
fullRequestDatasetsPerSecond is held as a long run average rather than enforced second by second. A connection that falls behind will run faster than the configured rate until it has caught up, and one that is rebuilt starts its averaging again from zero.The Rate, And Why It Is Low
fullRequestDatasetsPerSecond is 2 on every connection in every hub region, so battery signals are requested, and therefore collected, at 2 Hz. That is the rate the rate controller holds, and requests are the only thing on the kart whose rate the receiver governs at all.
The controller is neither polled nor rate limited by anything here. It broadcasts on its own schedule, at 50 Hz, and the receiver simply records what arrives. The two streams in InfluxDB therefore have very different densities: battery channels sparse, controller channels far denser.
The charging export aggregates into one second windows and forward fills, so a 2 Hz battery channel is comfortably denser than the exported grid. See Charging Export.
Receiving Frames
Every frame on the wire is exactly 13 bytes. The consumer loop reads exactly 13 bytes, adds the frame to the current message, and keeps going while the socket still has data available. One burst of available data becomes one published message.
Two frames are counted but deliberately not forwarded:
- The attached device’s own requests. Where
isDataLoggerAttachedis true, a frame whose bytes from 5 onward are all zero is treated as the logger or DAQ polling rather than a node responding. - Frame ID 1793. Dropped unconditionally.
raw_data, and a KPI message of internal signals onto telemetry_data.
Decoding
raw_to_telemetry consumes raw_data. For each received frame it extracts the frame ID from bytes 1 to 4, using the byte order carried on the message rather than re-reading configuration, and looks that ID up in the DBC for that connection. DBCs are parsed once and cached in memory per container.
- Found. The payload is read as a single 64 bit value from offset 5, and every signal the DBC message defines is unpacked from it. One frame becomes one sample per signal.
- Not found. One sample named
xFrameIdUnknown_{frameId}with a value of 1, and the payload is discarded.
Byte order within the payload is a property of each signal in the DBC, so the DBC handles it.
isLittleEndian governs only the frame ID and the request frames. That is the whole of its influence, and it is enough to make a device look silent.{batterySpec}_node{index}.dbc, and otherwise it is {batterySpec}.dbc. The DBC files ship in the distribution.
Configuration Reload
A background service pollsip-config.json every five seconds. It compares the file’s last write time, and if it has moved, re-reads and re-validates the file.
Validation is all or nothing across the whole file: a mistake on one kart prevents the change to another kart in the same edit from taking effect.
Every time the file is read, its entire contents are written to a separate dated log, at
Logs/Container/FromTcp/Consumer/ToRaw/Producer/ip-config/{dd.MM.yyyy}.ip-config.log. That gives a timestamped history of every version of the configuration the receiver has ever run, and those logs sync to S3 with the rest. It is the fastest way to answer what a kart was set to at the time, and whether an edit was actually picked up.Node Labels
Where a battery spec exposes a DBC per node and the kart has more than one connection, each connection is labelledNode0, Node1 and so on by index. A kart with a single connection gets no label, and nor does one whose battery spec has a single DBC.
The label matters because it prefixes every internal signal name for that connection, giving Node0_xTotalResCnt. On a multi node kart the label is the only thing that tells you which node an internal signal came from.
The label and the DBC are decided separately. A single connection kart on a per node battery spec still reads the node 0 DBC, but carries no label, so its signals arrive unprefixed.
xEndpoint is the exception to the prefixing. Its label is appended rather than prefixed, giving xEndpoint_Node0. That matters for the InfluxDB write below.Writing To InfluxDB
telemetry_to_influx consumes telemetry_data into a bounded in memory channel, then batches. A batch is flushed when it reaches 5000 points or 1000 ms have passed, whichever comes first.
One sample becomes one point:
This is why the same signal from two nodes of one kart shares a measurement name and is separated only by the
battery tag, and it is why the STL export prefixes the battery name onto its columns. It is also why xEndpoint_Node0, whose label is a suffix, stays a measurement of its own with no battery tag.The Raw File
When it is on, both sent and received frames are written, which makes it the only record of what was actually asked as well as answered.A rolled file is zipped and its
.bin deleted, so a finished file on disk is rawData_{timestamp}.zip. Only the file currently open is a .bin, and it stays a .bin if the container is killed rather than stopped cleanly.ConvertRawDataLogToCsv in the solution takes a folder of those zips and writes every record into a single CSV. Nothing ships raw files off the machine on its own, because the log sync only matches names carrying today’s date in dd.mm.yyyy form and the raw file names do not use it.
Connecting And Reconnecting
Connection handling is a loop that never gives up, and it narrates itself entirely through internal signals.1
Attempt
xConnectionAttempt is emitted for every attempt, successful or not.2
Success or failure
Success adds
xNewConnection. Failure adds xTcpConnectionFailed and xPing, the latter being an ICMP result for that connection’s IP address, which is how you tell “unreachable” from “reachable but refusing”.3
Run until something breaks
The producer and consumer loops run against the socket. If either throws, it emits
xTcpConnectionKilled and cancels its sibling, so both stop together.4
Rebuild
The socket is disposed and the outer loop connects again from scratch. The container does not restart, and neither do the other karts.
A rebuilt connection gets a new set of counters, so
xTotalReqsCnt and xTotalResCnt reset to zero. A cumulative counter that has gone backwards is a reconnection, not a fault in the counter.Key Files
All paths are undertelemetry/TelemetryOnGround/ in can-wifi.
