Unnamed Telemetry Software (v1)

Foreword and Background

I used to work in a small team in our university that made electric vehicles to compete in various efficiency races. I was responsible for the telemetry systems of these vehicles for the two years spanning 2021 and 2023. The article date is the date at which I last worked on this project.

This was a program I wrote targeting the STM32F407 chip for transmitting a limited amount of telemetry from the car as per the rules of the race.

Description

Disclaimer: A big part of the source code for this project is not available due to poor code quality and the time it would take to make it suitable for publication. However, some parts of the code are available in a now abandoned project of mine, at a specific commit: LibStuff (called Stuff at the time) @ ff75dd4.

It was decided, after weighing various options, that we were to use a LoRa module to carry out the communication for the telemetry data. This small piece of software was made to transmit various voltages, temperatures and other assorted data while also making sure that the packets of data had not deteriorated over the air.

Working Principles

Reflection

The packets were declared in a weird way as to have quick and automated access to various data members within the structs that defined them:

struct LocalObservationsPacket {
    static constexpr uint16_t packet_id = 4;

    MEMREFL_BEGIN(LocalObservationsPacket, 4);

    float MEMREFL_DECL_MEMBER(timestamp);
    std::pair<float, float> MEMREFL_DECL_MEMBER(gps_coords);
    Stf::Vector<float, 3> MEMREFL_DECL_MEMBER(acceleration);
    Stf::Vector<float, 3> MEMREFL_DECL_MEMBER(orientation);

    static LocalObservationsPacket from_state(NewTelemetryState const& state);
};

where MEMREFL_DECL_MEMBER and MEMREFL_BEGIN are defined in <Stuff/Refl/ReflNew.hpp>.

The job of MEMREFL_BEGIN is to declare a few helpers named like MemReflBaseHelper (member “reflection” helper’s base) which contains information like the passed member count, and MemReflHelper which is defined as:

template<size_t R_I, typename = void>
    requires(R_I < MemReflBaseHelper::member_count)
struct MemReflHelper;

The weird declaration is due to some limitations about specialisations of struct that are within the scopes of other structs.

MEMREFL_DECL_MEMBER specialises this struct to add information about the type of the member and a few getters for the member for arbitrary instances of the parent struct (the type of which is suppiled by MemReflBaseHelper::parent_type.

This is a very crude way of achieving static reflection, which is later refined to be entirely automatic for aggregate types in the later revisions of the library and the telemetry program.

Data Encoding

The data transmission part of the program doesn’t care about the stuff above and is made to send arbitrary data over the air with some rudimentary error checking. There are two parts to a transmission; the header and the payload.

The struct that defines the header is defined as:

struct PacketHeader {
    MEMREFL_BEGIN(PacketHeader, 4)

    uint32_t MEMREFL_DECL_MEMBER(crc);
    uint16_t MEMREFL_DECL_MEMBER(len);
    uint16_t MEMREFL_DECL_MEMBER(id);
    uint32_t MEMREFL_DECL_MEMBER(order);
};

Where the crc field contains the checksum of the whole transmission with the field set to 0 (which is a crude way of using CRCs), the len field contains the length of the payload, the id field contains some arbitrary integer defined by the user (used for the packet identifier in this program), and the order field contains information about how many packets were sent before this one.

After the header is generated according to the payload and the packet manager state (for the order field of the header), the whole of the data including the header and the payload is encoded using COBS to demarkate the end of a packet using a 0 byte:

    template<typename T> uint32_t tx_packet(T const& v, size_t count = 1) {
        if (!serialize_data(v))
            return 0;

        static constexpr size_t total_size = serialized_size_v<T> + serialized_size_v<PacketHeader>;

        const size_t encoded_size = encode_data<T>();
        if (encoded_size == 0)
            return 0;

        ++last_sent_packet_order;

        while (count-- > 0) {
            prepare_tx();
            tx_buffer(tx_buf.data(), encoded_size);
        }
        return last_sent_packet_order;
    }

As can be guessed from the function’s signature, sending multiples of the same packet is possible. This is inspired by pagers which use or used (I’m not sure) a similar system for error recovery/prevention. This was not necessary in the conditions in which we raced the car.

The prepare_tx and tx_buffer functions are pure virtual functions that are later extended by the LoRa specialisation of this class (which I don’t think is in the public part of the code)

Additionally, the part of the code that receives packets keeps track of some statistics:

    size_t current_overruns = 0;
    size_t total_overruns = 0;
    size_t decode_errors = 0;
    size_t crc_errors = 0;
    size_t length_errors = 0;
    size_t rx_redundant_packets = 0;
    size_t rx_dropped_packets = 0;
    size_t rx_packets = 0;

Data Collection

There exists two processes whereby the data to send is acquired: The CAN interface and device-local interfaces like UART and I²C.

Both of the parts are relatively straightforward: Whenever an interrupt was triggered (whether it be caused by CAN or something else), the source was judged, the data was parsed, and a global state of variables was updated. A snippet of the CAN code is as follows:

        switch (static_cast<CANIDs>(id)) {
        case CANIDs::BMS1: update_bms(0, 8); break;
        case CANIDs::BMS2: update_bms(8, 8); break;
        case CANIDs::BMS3: update_bms(16, 8); break;
        case CANIDs::BMS4:
            update_bms(24, 3);
            for (size_t i = 0; i < 5; i++) {
                battery_temperatures[i] = Stf::map<float>(data[i + 3], 0, 255, 0, 100);
            }
            break;
        case CANIDs::BMS5: {
            uint16_t raw_mah = Stf::convert_endian(*reinterpret_cast<const uint16_t*>(data.data()), std::endian::big);
            uint16_t raw_mwh = Stf::convert_endian(*reinterpret_cast<const uint16_t*>(data.data() + 2), std::endian::big);
            uint16_t raw_current = Stf::convert_endian(*reinterpret_cast<const uint16_t*>(data.data() + 4), std::endian::big);
            uint16_t raw_percent = Stf::convert_endian(*reinterpret_cast<const uint16_t*>(data.data() + 6), std::endian::big);
            spent_mah = Stf::map<float>(raw_mah, 0, 65535, 0, 15000);
            spent_mwh = Stf::map<float>(raw_mwh, 0, 65535, 0, 15000 * 256);
            current = Stf::map<float>(raw_current, 0, 65535, -10, 50);
            soc_percent = Stf::map<float>(raw_percent, 0, 65535, -5, 105);
        } break;

      // [snip]

Not pretty, is it?

Anyway, in regular intervals, the afforementioned from_state function was called on various packet types with the global state passed into them:

    INTERVAL_CHECK(interval_lora_vcs, VCSPacket)
    INTERVAL_CHECK(interval_lora_bms_summary, BMSSummaryPacket)
    INTERVAL_CHECK(interval_lora_engine, EnginePacket)
    INTERVAL_CHECK(interval_lora_local_observations, LocalObservationsPacket)
    INTERVAL_CHECK(interval_lora_battery_array, BatteryArrayPacket)
    INTERVAL_CHECK(interval_lora_essentials, EssentialsPacket)

Where INTERVAL_CHECK is defined as:

#define INTERVAL_CHECK(_interval, _type)                     \
    if (_interval(now)) {                                    \
        const auto packet = Unc::_type::from_state(s_state); \
        s_state.s_connection.tx_packet(packet);              \
        s_state.lora.wait_aux();                             \
    }

Where the “reflection” comes into play.

“TeleGround”

This is the program that is used to receive data from the device. Again, due to code quality and due to the time it would take to clean it up, I won’t publish the code. Here are some screenshots:

screenshot

ImGui, Boost, and SFML were used to throw this thing together.

On top of displaying the data, it saved some CSV files to later give to the race’s referrees:

screenshot

(These were the files produced during the actual race)

TODO