Data path diagram: I²C sensors to an I²C multiplexer to the ESP32-S3 running ARC firmware, then half-duplex RS-485 to the V5 Brain.
ARC-01Designed data path: sensors, I²C multiplexer, ESP32-S3, then RS-485 to the V5 Brain.

Overview

Operation

The sensors of the ARSENAL sit on an I²C bus, either directly or behind I²C multiplexers. Some sensors, such as the three TMP117 temperature sensors of the ARSENAL-3, are soldered onto the board; additional sensors connect through RJ9 ports. ARC runs each sensor in its own FreeRTOS task, which selects the multiplexer channel of that sensor, reads its registers and stores the latest reading. A separate RS-485 task listens for requests from the V5 Brain and answers each read request with one packet that carries the readings of the requested sensors. The Brain can also send configuration packets, which set the type and register offsets of each sensor, and a reboot command, which restores the default offsets and clears the stored readings.

Role

I am the sole author of the ARC firmware; selected definitions, such as the sensor status codes, were retained from the legacy team firmware. I co-designed the RS-485 protocol with the teammate who writes the V5 Brain software. The CBU electronics team designed the board. Development began in August 2026 in a private team repository and continues in the public ARC repository.

Verification

On November 10, 2026, the ARSENAL-3 ran ARC on the bench with its three on-board TMP117 temperature sensors while the V5 Brain polled the board over RS-485. The captured V5 display recorded 59 replies and 0 bad packets, with valid frame markers and checksums. Each packet carries a mod-256 checksum. A failed or missing read is reported as 0xFFFF for that sensor.

The ARSENAL-3 board, a black circuit board on pink anti-static foam with its status LEDs lit, cabled to a VEX V5 Brain labelled CBU 3. The Brain screen shows the ARC three-temperature bus test.
ARC-02The ARSENAL-3 on the bench, cabled to the V5 Brain over RS-485, November 10, 2026.
V5 Brain screen titled V5PacketSender, ARC three-temperature bus test: port 21 at 115200 baud, 510 sent, 59 received, 0 bad; reply OK with markers and checksum; header 82, 34-byte body, 49 bytes total; sensor IDs 2, 3 and 4 reading 27.40, 28.02 and 26.30 degrees C; and a hex dump of the reply ending in DE AD BE EF.
ARC-03The V5 Brain display during the same test, with readings from sensors 2, 3 and 4.

In October 2026, an earlier version of ARC ran on the ARSENAL-2.5 with the RS-485 link under isolated test, and a read request from the V5 Brain was received and parsed without error; the serial capture is in Technical detail.

Engineering decisions

  • Sensor abstraction. Every sensor uses one acquisition framework, the Sensor<N> template. The register reads specific to a sensor are supplied by its sensor type or by a read callback, so a new sensor needs no new class. Adding a sensor shows an example.
  • Shared-bus ownership. Each sensor task holds the bus mutex across multiplexer channel selection and the register transaction, so no other task can switch channels mid-read. The held lock is passed to every register read and write. Bus lock shows the guard class.
  • Separate communication. A dedicated RS-485 task answers V5 requests from cached readings, so requests do not wait on sensor acquisition. Packets and configuration describes the packets.

ARC was bench-tested at CPPDev b24ce84. The class structure and code excerpts on this page come from 49d54a0; the TMP117 example comes from b24ce84.

Benjamin-Elmore/ARC on GitHub

Technical detail

Software architecture

ARC system architecture at the CPPDev branch, in three packages. 01 Sensor hardware: I²C sensors, reached directly or through optional I²C multiplexers. 02 ARC controller, a Seeed XIAO ESP32-S3 running ESP-IDF and FreeRTOS firmware: I2CBus provides I²C access to Sensor, which runs one task per sensor, holds sensor types and appendable offsets, and implements the RS485InterfaceSensor interface. LockGuard guards the bus mutex and is passed to register reads and writes. RS485Comm hands packets to RS485PacketComp, which dispatches read, three-stage configuration and reboot packets to RS485CommParser. An SD logger is optional and off by default. 03 Robot interface: an RS-485 transceiver links over half-duplex RS-485 to the V5 Brain, which sends read, configuration and reboot requests and receives sensor data. The read direction of the sensor-to-Brain chain was verified on the bench on November 10, 2026.
ARC-04ARC software architecture in three packages: sensor hardware, the ARC controller classes and the RS-485 robot interface.

Adding a sensor

Adding a sensor requires no new class: a Sensor<N> is configured and given one read function, either directly or through its sensor type, where N is the number of multiplexers on its bus. Each sensor task runs at a configurable priority and reports a status code on failure, and offsets, setup callbacks and loop callbacks are appendable rather than hard-coded.

  1. Implement a read function that receives the sensor and the held bus lock, reads the registers of the sensor through that lock and calls setReadings with three 16-bit values, unless the sensor type already supplies one.
  2. Construct the Sensor<N> with its name, bus, I²C address and ID (1 to 15). Optional arguments follow in this order: a flag to print readings (the false in the example below), sensor type, multiplexer channel, task priority (default 2), setup and loop callbacks, methods, variables, offsets, a read function and an offset register.
  3. Assign a sensor type, in the constructor or with SensorOffsetConfig::addOffsets, to attach the offset registers of that type. At b24ce84, the temperature type also attaches its read function and offset register, so the example below passes no read function.
  4. Call setup() before starting RS-485. This registers the sensor on the bus, runs its initialization steps, registers it with the RS-485 link and starts its task. The RS-485 sensor registry is closed to changes once its task starts.
Example: TMP117 temperature sensor (condensed)C++
// src/main.cpp
namespace {
    constexpr const char* TAG = "ARC";
    constexpr uint8_t TMP117_ADDRESS = 0x48;
    constexpr uint8_t TEMP1_ID = 2;
    // ...
    constexpr uint8_t TEMP1_MUX_CHANNEL = 3;   // 0x70, channel 3
    // ...
}

extern "C" void app_main()
{
    // ...
    auto& sensorBus = *I2CARSENAL::multiplexerBus;
    constexpr auto temperature = SensorUtils::SensorConfig::TEMPERATURE;
    static Sensor<2> temp1(
        "temp1", sensorBus, TMP117_ADDRESS, TEMP1_ID, false, temperature, TEMP1_MUX_CHANNEL);
    // ...

    for (auto* sensor : {&temp1, &temp2, &temp3}) {
        const auto status = sensor->setup();
        if (status != SensorUtils::SENSOR_OK) {
            ESP_LOGE(TAG, "%s setup failed: %s", sensor->getName().data(),
                     SensorUtils::sensor_status_str(status));
            continue;
        }
        // ...
    }

    ESP_ERROR_CHECK(RS485ARSENAL::startRS485ARSENAL());
    // ...
}

// lib/Sensor/ARC_Sensor.h, in the Sensor<N> constructor
    // SET SENSOR TYPE
    // Offsets are added and type is changed
    if (sensorType != SensorUtils::SensorConfig::NONE) {
        this->setSensorType(sensorType);
    }

// lib/Sensor/ARC_Sensor.h, Sensor<N> members
bool setSensorType(SensorUtils::SensorConfig config) override {
    // Set the type of sensor and add the relevant values needed.

    this->setSensorTypeWoOffset(config);
    SensorOffsetConfig::addOffsets(*this, config);
    return true;
}

bool setSensorTypeWoOffset(SensorUtils::SensorConfig config) {
    // Set the type of sensor on its own

    this->_type = config;
    if (config == SensorUtils::SensorConfig::TEMPERATURE) {
        this->_readFunction = SensorOffsetConfig::readTemperature<N>;
        this->_offsetMemReg = 0x07;
    }
    return true;
}

// lib/Sensor/SensorOffsetConfig.h
namespace SensorOffsetConfig {

    template <std::size_t N>
    void readTemperature(Sensor<N>& sensor, LockGuard& lock) {
        constexpr uint8_t temperatureRegister = 0x00;
        uint8_t temperatureBytes[2]{};
        const esp_err_t readStatus = sensor.readDeviceRegister(
            temperatureRegister, temperatureBytes, sizeof(temperatureBytes), lock);

        if (readStatus != ESP_OK) {
            return;
        }

        const uint16_t highByte = temperatureBytes[0];
        const uint16_t lowByte = temperatureBytes[1];
        const uint16_t rawTemperature = (highByte << 8) | lowByte;
        sensor.setReadings({rawTemperature, 0, 0});
    }

    template <std::size_t N>
    void addTemperatureOffsets(Sensor<N>& sensor) {
        constexpr uint8_t temperatureOffsetRegister = 0x07;
        constexpr uint16_t defaultTemperatureOffset = 0;
        constexpr uint8_t temperaturePacketSection = 0;
        constexpr uint8_t temperatureComponent = 0;
        constexpr auto registerLayout = SensorUtils::OffsetLayout::REG_16_BIT_HIGH_FIRST;

        sensor.appendOffset(
            "offsetTemperature", defaultTemperatureOffset, temperatureOffsetRegister,
            temperaturePacketSection, temperatureComponent, registerLayout);
    }

    // ...

    // Register preset offsets once, before starting the sensor.
    template <std::size_t N>
    void addOffsets(Sensor<N>& sensor, SensorUtils::SensorConfig type) {
        // ...
        switch (type) {
            case SensorUtils::SensorConfig::TEMPERATURE:
                addTemperatureOffsets(sensor);
                break;
            // ...
        }
    }

}
ARC-06Excerpts that follow one sensor: its construction and setup in src/main.cpp, the temperature type attaching the read function and offset register in lib/Sensor/ARC_Sensor.h, and the read function and offsets in lib/Sensor/SensorOffsetConfig.h. Omitted lines are marked // ....

Bus lock

Because multiple sensor tasks share one I²C bus, every read executes under a lock. The RAII class below makes that lock difficult to misuse.

lib/LockGuard.h (condensed)C++
class LockGuard {
public:
    explicit LockGuard(SemaphoreHandle_t& mutex) : _mutexReference(mutex) {
        if (_mutexReference != nullptr &&
            xSemaphoreTake(_mutexReference, portMAX_DELAY) != pdFALSE) {
            _isLockedByLockGuard = true;
        }
    }
    // Releases only if this guard took the mutex.
    ~LockGuard() noexcept {
        if (_isLockedByLockGuard) xSemaphoreGive(_mutexReference);
    }
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
    bool isMutexLocked() { return _isLockedByLockGuard; }
private:
    SemaphoreHandle_t& _mutexReference;
    bool _isLockedByLockGuard = false;
};
ARC-07LockGuard, condensed from lib/LockGuard.h.

The lock is released automatically when the guard leaves scope, and only if this guard acquired it, so a failed acquisition can never release a lock held by another task. A register read or write is refused without a held lock, and each bus error is logged with the operation that produced it. After each read, the sensor task releases the lock, runs any loop callbacks and waits 10 ms before the next cycle.

Packets and configuration

A byte-level state machine parses incoming packets and accepts six packet types: read, a three-stage configuration sequence, reboot and failure. Read, configuration and reboot packets are dispatched to their own parsers. The RS-485 task answers each read request from the cached readings that the sensor tasks keep current.

  • A read reply is a single packet containing two timestamps, up to 15 sensor IDs and six bytes of data per sensor, with DE AD BE EF frame markers around the checksum.
  • Configuration runs in stages: the first packet sets the type of each sensor, the following packets carry up to three offset vectors per sensor, and the final packet writes those offsets to the registers of the sensor.
  • A reboot request restores the default offsets of each sensor and clears its readings.

Specifications

Values from the code at CPPDev 49d54a0
RS-485 UART115200 baud, 8 data bits, no parity, 1 stop bit (8N1)
Sensor IDs1 to 15
Packet types6
Receive buffer128 bytes
Sensor task delay10 ms between cycles
Bench read request37 bytes (as in the serial capture below)

An optional SD logger records uptime and heap usage to an SD card; it is disabled by default.

Serial capture, October 2026

ESP32-S3 serial monitor, October 28, 2026Serial output
=== SERIAL STATUS ===
_status: ESP_OK (0)
_isFirstRead: false
_validPacket: false
_currentPacketState: WAITING_FOR_HEADER (0)
_packetLength: 22 body bytes
_timeSinceLastReceive: 0
_timeOfSending: 164438584
_packet [full buffer] (128 bytes, hex):
  000: 80 00 16 00 00 00 00 09 CD 22 38 10 00 00 00 00
  016: 00 00 00 00 00 00 00 00 00 DE AD BE EF 00 00 00
  032: 40 DE AD BE EF 00 00 00 00 00 00 00 00 00 00 00
  ... (rows 048-112: all 00)
_sensorIDS (slot order): 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
=====================
UART received: 37
ARC-05Serial output from an earlier ARC version on the ARSENAL-2.5, October 28, 2026, with the RS-485 link under isolated test. Duplicate buffer output and all-zero rows are omitted.

The capture shows a read request from the V5 Brain received and parsed without error: READ header, 22-byte body, sensor ID 1, valid checksum and both DE AD BE EF frame markers. _validPacket reads false because the receive loop clears the flag as soon as the reply is sent, before the status block prints. The capture predates the configuration packets.

Next project: MarketTesting →