Видео для наглядности:

Автор 3D-модели: Kivvich BeamNG Workspace

Дисклеймер: взаимодействие с блоками управления автомобиля без должного опыта и осторожности может повлечь за собой печальные последствия.

Как это работает?

Ключевым компонентом системы является протокол OutGauge, специально разработанный для подключения различных приборов и средств индикации к автосимуляторам.

Слева направо: в состав BeamNG входит специальный скрипт на Lua outgauge.lua, который упаковывает определенные переменные в структуру и отправляет их с помощью UDP по адресу 127.0.0.1:4444. Там эти пакеты принимает и распаковывает сделанный на коленке скрипт на Python, преобразовывает в соответствующие CAN-сообщения и отправляет их блоку панели приборов.

Для простоты проекта никакого железа кроме ПК, CAN-интерфейса и самой панели не используется. У такого решения есть свои недостатки, самый главный - это отсутствие возможности управлять указателем уровня топлива, так как он аналоговый и построен на измерении сопротивления. Такой подход практикуется на большинстве автомобилей и выставить уровень топлива с помощью CAN-сообщений не получится.

Естественно, используемая панель приборов должна быть относительно современной, с поддержкой CAN-шины и минимумом аналоговых/импульсных входов. Для безумных экспериментов желательно приобрести ее на разборке, а не выдирать из собственного автомобиля.

Чтобы панель понимала сообщения, надо говорить с ней на языке ее автомобиля. Для упрощения перевода применяют специальные текстовые DBC-файлы, описывающие три основных сущности CAN-шины:

  • узлы, подключенные к шине (блоки управления, диагностические приборы и т.д.);

  • сообщения, которые эти узлы передают;

  • сигналы, которые содержатся в этих сообщениях.

Для полноценной работы скрипта на Python потребуется библиотека cantools для работы с DBC-файлами и библиотека python-can, умеющая взаимодействовать с кучей различных CAN-интерфейсов.

Важным инструментом, облегчающим обратный инжиниринг CAN-шины автомобиля, выступит ПО SavvyCAN. Вот неполный список того, что оно умеет:

  • писать логи;

  • просматривать и редактировать DBC-файлы;

  • интерпретировать поток сообщений в сигналы, основываясь на DBC-файлах;

  • строить графики;

  • работать с UDS-командами;

  • производить фаззинг.

Для просмотра и редактирования DBC-файлов может пригодится бесплатная версия CANdb++ от Vector.

Чтобы описанное в статье было проще воспринять и повторить самому, она была разбита на два уровня: базовый, в котором мы будем учить панель отображать только самую необходимую информацию, и углубленный, в котором постараемся выжать из панели максимум, пустив в ход изменение ее конфигурации и редактирование Lua-скрипта BeamNG.

Базовый уровень

Список действий для базового уровня:

  1. погасить все незадействованные лампочки (чтобы они не отвлекали во время игры) с помощью CAN-сигналов;

  2. выводить только значения оборотов, скорости и температуры двигателя на панель;

  3. оставить Lua-скрипт BeamNG первозданном виде;

  4. оставить конфигурацию панели в первозданном виде.

Структура CAN-шины автомобиля

Этот шаг не является обязательным, но упрощает поиск сообщений/сигналов и помогает лучше понять устройство автомобиля.

Основными источниками информации являются данные о комплектации и оснащении автомобиля, руководство по эксплуатации, электрические схемы и наглядное изучение проводки при разборке салона.

В данном конкретном случае, в роли подопытного выступает Lada Granta FL 2021 в комплектации "Комфорт", с двигателем ВАЗ-21127 и механической коробкой передач. Оснащение не самое навороченное, поэтому и схема получается небольшой:

Наименования блоков подобраны фривольно и могут не соответствовать официальной документации.

Всего 6 блоков, подключенных к одной-единственной шине:

  1. ECM (Engine Control Module) - блок управления двигателем;

  2. SRS (Supplemental Restraint System) - блок подушек и преднатяжителей ремней безопасности;

  3. ABS (Anti-lock Braking System) - блок управления антиблокировочной системой;

  4. Instrument Cluster - панель приборов;

  5. Head Unit - головное устройство;

  6. ERA - блок системы Экстренного Реагирования при Авариях.

Отдельно можно упомянуть блок центрального замка, но он к шине не подключается и существует сам по себе.

Сбор и анализ логов

Самый простой способ подключения к CAN-шине - через диагностический разъем. Ситуация может быть чуть сложнее, если на автомобиле установлен гейтвей, который фильтрует трафик и не пропускает сообщения. Тогда придется подключаться к шинам за гейтвеем и записывать трафик до фильтрации. При этом важно понимать, какая шина за какой домен сети отвечает.

Процесс сбора логов не представляет особой сложности, достаточно подключиться и проделать те операции, результат которых надо будет найти в логах. Например, прогреть двигатель до определенной температуры или поднять обороты до заранее известных значений.

Следующий шаг - это анализ собранных логов. Существуют разные подходы для упрощения этой задачи, есть даже автоматизированные. В целом это процесс творческий и по-своему интересный, похожий на судоку. Чем больше сигналов вы находите, тем меньше остаются загадками.

Обнаруженные сигналы вносятся в DBC-файл, который будет использоваться далее. Кстати, возможно вам повезло и CAN-шина вашего автомобиля уже описана в проекте opendbc.

Базовые подсказки, которые помогут ускорить поиски:

  • Начинать анализ желательно с поиска того, какой блок какие сообщения отправляет, чтобы не искать обороты двигателя в сообщениях блока ABS. Можно снять и прослушать блок отдельно на столе или вытащить его предохранитель в автомобиле и посмотреть, какие сообщения пропадут (вариант для экстремалов).

  • Полезно понимать, какой именно сигнал вы ищете: непрерывный (обороты двигателя), счетный (пробег) или логический (состояние дальнего света: вкл/выкл), так как каждый из них по-своему меняется со временем.

  • Сигналы могут иметь самые разные коэффициенты для перевода из "попугаев" в корректные единицы измерения, поэтому до перевода не заостряйте внимание на значениях - форма сигнала гораздо важнее.

  • Приоритет сообщения тесно связан с его ID: меньшее значение соответствует высшему приоритету и наоборот. Сообщение с меньшим ID скорее всего будет принадлежать важному блоку, например ECM.

  • Тоже самое работает и с периодом отправки сообщений: сообщения с меньшим периодом скорее всего содержат более важную информацию и наоборот.

Для быстрого получения результатов использовалось два подхода: воспроизведение записанных сообщений и сравнение с диагностическими запросами.

Воспроизведение записанных сообщений - наиболее быстрый и комфортный способ. Достаточно просто записать лог короткой поездки по городу, воспроизвести и наблюдать, как шевелятся стрелки у лежащей на столе панели.

  1. панель подключаем отдельно на столе и проигрываем записанный ранее лог, за исключением ее собственных сообщений;

  2. поочередно убираем проигрываемые сообщения, сужая круг подозреваемых, пока интересующий индикатор не поменяет свое поведение;

  3. с помощью фаззинга находим нужные биты внутри подозрительного сообщения.

Не забудьте поставить галку "Use original frame timing from captured frames", чтобы SavvyCAN использовал тайминги оригинальных сообщений.

В процессе воспроизведения могут быть свои хитрости, например, искомый сигнал может быть в одном сообщении, но отображаться только при наличии одного или нескольких других сообщений. Тут важно набраться терпения и искать корректные сочетания и последовательности.

Сравнение с диагностическими запросами - более замороченный способ, требует больше оборудования и подходит только для сигналов, описанных в OBD2, поэтому лучше оставлять его на крайний случай.

  1. записываем лог, одновременно посылая диагностические OBD2-запросы, например, спрашивая значение RPM;

  2. скармливаем SavvyCAN лог, DBC-файл с описанием OBD2-запросов и разрабатываемый DBC-файл подопытного автомобиля;

  3. строим два графика: референсный на базе OBD2 и предполагаемый;

  4. сравниваем оба графика между собой, убеждаемся, что сигналы похожи или нет.

Попробуем на реальных данных, построив два графика RPM, используя масштаб 1.0 для обоих сигналов, чтобы посмотреть исходные значения в "попугаях":

Сигналы похожи по форме, но они отличаются масштабом. График RPM для OBD2 не такой гладкий, так как частота диагностических запросов ниже, чем частота собственных сообщений автомобиля. Если вернуть сигналу OBD2 его стандартный масштаб 0.25 и подогнать предполагаемый сигнал с помощью масштаба 0.125, то можно получить финальный результат:

Сигнал RPM найден! Чтобы дополнительно удостовериться в этом, достаточно проиграть несколько обнаруженных сообщений и проверить, двигается ли стрелка тахометра.

Подключение панели и настройка BeamNG

Понадобится минимальное количество проводов:

Не забывайте про терминирующий резистор CAN-шины, без него не получится наладить обмен сообщениями.

Вывод датчика уровня топлива желательно соединить с массой, это будет соответствовать полному баку и погасит непрерывно мигающую индикацию низкого уровня топлива. Для тех, кто захочет контролировать указатель уровня топлива, ниже приведена таблица зависимости его значений от сопротивления:

Точный уровень топлива в % можно найти в 1-ом байте сообщения 0x280, например, здесь он равен 0x5C = 92%:

0280 8 01 5C FF 10 FF FF 00 00

Ну а чтобы включить поддержку OutGauge в BeamNG, достаточно поставить галку в подразделе Options -> Other. Там же можно настроить IP-адрес и порт:

Применение результатов анализа

С помощью техник, описанных выше, были найдены искомые сигналы скорости, оборотов и температуры, а также ряд сигналов, отвечающих за лампы индикации:

granta_basic.dbc
VERSION ""


NS_ :
    NS_DESC_
    CM_
    BA_DEF_
    BA_
    VAL_
    CAT_DEF_
    CAT_
    FILTER
    BA_DEF_DEF_
    EV_DATA_
    ENVVAR_DATA_
    SGTYPE_
    SGTYPE_VAL_
    BA_DEF_SGTYPE_
    BA_SGTYPE_
    SIG_TYPE_REF_
    VAL_TABLE_
    SIG_GROUP_
    SIG_VALTYPE_
    SIGTYPE_VALTYPE_
    BO_TX_BU_
    BA_DEF_REL_
    BA_REL_
    BA_DEF_DEF_REL_
    BU_SG_REL_
    BU_EV_REL_
    BU_BO_REL_
    SG_MUL_VAL_

BS_: 
BU_: ECM ABS SRS 
BO_ 384 ECM_180: 8 ECM
   SG_ rpm : 7|16@0+ (0.125,0) [0|0] "rpm" Vector__XXX

BO_ 505 ECM_1F9: 8 ECM
   SG_ speed_km_h : 23|16@0+ (0.01,0) [0|0] "" Vector__XXX

BO_ 852 ABS_354: 8 ABS
   SG_ abs_lamp_on : 55|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 1176 SRS_498: 1 SRS
   SG_ airbag_lamp : 4|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ seatbelt_fastened : 0|1@1+ (1,0) [0|0] "" Vector__XXX

BO_ 1361 ECM_551: 8 ECM
   SG_ coolant_temperature : 15|8@0+ (1,-40) [0|0] "" Vector__XXX
   SG_ check_engine_lamp_on : 32|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ check_engine_lamp_blinking : 33|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ oil_lamp_on : 34|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ overheat_lamp_with_sound : 35|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ battery_lamp_off : 24|1@1+ (1,0) [0|0] "" Vector__XXX

Пару слов о скрипте на Python, который возьмет на себя роль переводчика. Внутри него происходит настройка подключения к CAN-шине, подготовка сообщений на основании DBC-файла и создание тасков для их цикличной отправки. Остается только принимать данные, поступающие от BeamNG, и модифицировать содержимое отправляемых сообщений:

translator_basic.py
import socket
import struct
import can
import cantools
from time import time

db = cantools.database.load_file('granta_basic.dbc')
ecm_180 = db.get_message_by_name('ECM_180')
ecm_1f9 = db.get_message_by_name('ECM_1F9')
abs_354 = db.get_message_by_name('ABS_354')
srs_498 = db.get_message_by_name('SRS_498')
ecm_551 = db.get_message_by_name('ECM_551')

bus = can.interface.Bus(bustype='pcan', channel='PCAN_USBBUS1', bitrate=500000)

def socket_stuff():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(('127.0.0.1', 4444))

    data = ecm_180.encode({'rpm': 0})
    ecm_180_msg = can.Message(arbitration_id=ecm_180.frame_id, data=data, is_extended_id=False)
    ecm_180_task = bus.send_periodic(ecm_180_msg, 0.100)

    data = ecm_1f9.encode({'speed_km_h': 0})
    ecm_1f9_msg = can.Message(arbitration_id=ecm_1f9.frame_id, data=data, is_extended_id=False)
    ecm_1f9_task = bus.send_periodic(ecm_1f9_msg, 0.100)

    data = abs_354.encode({'abs_lamp_on': 0})
    abs_354_msg = can.Message(arbitration_id=abs_354.frame_id, data=data, is_extended_id=False)
    abs_354_task = bus.send_periodic(abs_354_msg, 0.100)

    data = srs_498.encode({'airbag_lamp': 0,
                           'seatbelt_fastened': 1})
    srs_498_msg = can.Message(arbitration_id=srs_498.frame_id, data=data, is_extended_id=False)
    srs_498_task = bus.send_periodic(srs_498_msg, 0.200)

    data = ecm_551.encode({'coolant_temperature': 0,
                           'check_engine_lamp_on': 0,
                           'check_engine_lamp_blinking': 0,
                           'oil_lamp_on': 0,
                           'overheat_lamp_with_sound': 0,
                           'battery_lamp_off': 1})
    ecm_551_msg = can.Message(arbitration_id=ecm_551.frame_id, data=data, is_extended_id=False)
    ecm_551_task = bus.send_periodic(ecm_551_msg, 0.100)

    start_ms = int(time() * 1000)

    while True:
        data = sock.recv(256)

        if not data:
            break

        elapsed_ms = int(time() * 1000) - start_ms
        if elapsed_ms >= 50:
            start_ms = int(time() * 1000)
            outgauge_pack = struct.unpack('I3sxH2B7f2I3f15sx15sxi', data)
            speed = int(outgauge_pack[5])
            rpm = int(outgauge_pack[6])
            engtemp = int(outgauge_pack[8])

            ecm_180_msg.data = ecm_180.encode({'rpm': rpm})
            ecm_180_task.modify_data(ecm_180_msg)

            ecm_1f9_msg.data = ecm_1f9.encode({'speed_km_h': speed * 3.6})
            ecm_1f9_task.modify_data(ecm_1f9_msg)

            ecm_551_msg.data = ecm_551.encode({'coolant_temperature': engtemp, 
                                               'check_engine_lamp_on': 0, 
                                               'check_engine_lamp_blinking': 0, 
                                               'oil_lamp_on': 0, 
                                               'overheat_lamp_with_sound': 0, 
                                               'battery_lamp_off': 1})
            ecm_551_task.modify_data(ecm_551_msg)

    sock.close()

socket_stuff()

Передача должна идти непрерывно, даже если значения не меняются, так как отсутствие сообщения в течение определенного промежутка времени панель принимает за неисправность. Теоретически, чтобы отличать одинаковые сообщения друг от друга, используются циклические счетчики. В трафике Гранты они тоже встречаются, но их отсутствие никак не влияет на работу панели, поэтому они были исключены из DBC-файла.

С выключением надоедливых лампочек все просто - достаточно один раз сформировать сообщения, выставить период отправки и забыть про них. Данные, которые непрерывно обновляются на основании информации от BeamNG представлены в таблице:

Indicator

CAN Message

BeamNG Lua Variable

speed

ECM_1F9

electrics.values.wheelspeed OR electrics.values.airspeed

RPM

ECM_180

electrics.values.rpm

coolant temp

ECM_551

electrics.values.watertemp

Если подключение было сделано правильно, то при запуске скрипт погасит все сигнальные лампы и будет ждать данных от BeamNG:

Углубленный уровень

Полученным минимумом долго сыт не будешь, к тому же BeamNG умеет симулировать гораздо больше событий, которые можно выводить на панель. Список действий для углубленного уровня:

  1. погасить все незадействованные лампочки (чтобы они не отвлекали во время игры) с помощью CAN-сигналов и конфигурации панели;

  2. выводить наибольшее количество возможной информации на панель;

  3. отредактировать Lua-скрипт BeamNG для передачи новых переменных;

  4. изменить конфигурацию панели для расширенной индикации.

Эта бесконечная гирлянда

Начнем с простого - основных контрольных ламп в центральной части панели, сигналы которых уже известны из прошлого раздела. Раньше они просто выключались, чтобы не маячить, но теперь можно задействовать их по прямому назначению, нужно только найти родственную переменную в BeamNG. Пойдем по-порядку, кратко описывая взаимосвязь реальности и симуляции.

Лампа аварийного давления масла. Загорается красным светом при включении зажигания и после запуска двигателя гаснет. При работающем двигателе загорается при недостаточном давлении в системе смазки двигателя. Ближайшее по смыслу, что удалось найти в BeamNG, это переменная electrics.values.oil, которая срабатывает при температуре масла более 130 градусов. Не совсем то, что нужно, но лучше, чем ничего. Впрочем, в скрипте BeamNG была найдена символичная строка o.oilPressure = 0 -- TODO, так что возможно в будущем лампочку получится подключить более правдиво.

Лампа антиблокировочной системы тормозов. Загорается желтым светом при включении зажигания на 2 секунды, во всех других случаях загорается при неисправности системы ABS. В BeamNG переменная electrics.values.abs передает состояние ABS и загорается, если система активна, например, при резком торможении.

Лампа "Отказ тормоза". Загорается красным светом при включении зажигания на 2 секунды. Далее загорается на основании 3-ех условий: мигает, если включен стояночный тормоз; горит постоянно при низком уровне ТЖ; горит постоянно совместно с индикатором ABS при отказе ABS. И здесь есть проблема - отдельно включить индикатор можно только с помощью выводов, подключенных к стояночному тормозу или датчику уровня ТЖ. По CAN-шине нам доступна только третья опция - когда индикатор горит совместно с индикатором ABS. Поэтому придется смириться с тем, что при включении "ручника" (electrics.values.parkingbrake) в BeamNG мы будем лицезреть еще и лампу ABS.

Лампа "Проверь двигатель". Загорается желтым светом при включении зажигания и после запуска двигателя гаснет. Далее загорается только при возникновении неисправностей, связанных с работой двигателя. В BeamNG переменная electrics.values.checkengine не используется при включении зажигания и загорается только в случае критического повреждения двигателя.

Лампа превышения температуры ОЖ. При включении зажигания загорается красным светом на 2 секунды, далее загорается, если температура ОЖ становится выше 115 градусов. В BeamNG нет логической переменной, которая отвечает за такой индикатор, поэтому просто отредактируем скрипт на Lua, чтобы он активировал индикатор при условии electrics.values.watertemp > 115.

Лампа разряда аккумуляторной батареи. Загорается красным светом при включении зажигания и после запуска двигателя гаснет. При работающем двигателе загорается в случае неисправности системы электропитания автомобиля. Поскольку у BeamNG нет задачи симулировать неисправности электрики, то в игре эта лампа горит только при заглушенном двигателе, на основании переменной electrics.values.engineRunning.

Кратко о времени

Еще при первом просмотре логов было обнаружено множество ASCII-символов в некоторых сообщениях. Всего таких сообщений оказалось 6: 0x4A4, 0x4A6, 0x4A8, 0x4AA, 0x4AC, 0x4AE; все по 8 байт размером. Выяснилось, что они содержат два вида посылок в формате NMEA: GPRMC и GPGGA. Внутри GPRMC находится рекомендуемый минимум навигационных данных, внутри GPGGA - данные о последнем зафиксированном местоположении. Найти источник сообщений было легко: единственным блоком, обладающим навигационным приемником был блок ЭРА-Глонасс.

Если прокатиться на автомобиле, собрать логи и извлечь NMEA-сообщения, то можно построить вполне себе точный и плавный трек поездки. Халявный источник координат - это всегда полезно, можно записывать свои перемещения или скармливать данные покупной системе навигации.

Но для чего эта информация в автомобиле с базовой "балалайкой" без системы навигации? При воспроизведении записанных логов, в глаза бросились часы на панели - их значение менялось на то, которое содержалось в NMEA-сообщениях.

Раз так, то почему бы не научиться выставлять точное время на панели? Эксперименты показали, что достаточно только одного сообщения GPRMC. С помощью библиотеки nmeasim составляем строку, расфасовываем ее по CAN-сообщениям и не забываем поставить паузы в 50 миллисекунд между ними.

nmea_set_time.py
from datetime import datetime, timedelta, timezone
from nmeasim.models import GpsReceiver
import time
import can

bus = can.interface.Bus(bustype='pcan', 
                        channel='PCAN_USBBUS1', 
                        bitrate=500000)

gps = GpsReceiver(
    #date_time=datetime(2020, 1, 1, 1, 20, 1, tzinfo=timezone.utc),
    date_time=datetime.now(),
    output=('RMC',)
)

gprmc_str = gps.get_output()
gprmc_bytes = gprmc_str[0].encode()

can_id = 0x4A4
for i in range(0, len(gprmc_bytes), 8):
    data = gprmc_bytes[i:i+8]
    print(hex(can_id), data)
    bus.send(can.Message(arbitration_id=can_id, data=data, is_extended_id=False))
    can_id = (can_id + 0x02) if (can_id < 0x4AE) else 0x4A4
    time.sleep(0.050)

bus.shutdown()

Можно брать время ПК или выставлять свое, например 04 часа 20 минут:

Теперь время всегда на виду.

Конфигурация и ее причуды

Конфигурация блоков - понятие растяжимое и каждый автопроизводитель подходит к ней по-своему, но основной смысл всегда один: изменение настроек/параметров без изменения самой прошивки.

Конфигурация представляет интерес для исследований по двум причинам:

  1. практическая: расширение набора штатных функций, получение информации о возможных комплектациях и опциях автомобиля;

  2. археологическая: конфигурация иногда наглядно иллюстрирует эволюцию, которую прошел автомобиль, переходя от ранних датчиков и актуаторов к более современным и технологичным (особенно если модель задержалась на рынке).

Для выведения большего количества информации на панель, чем в прошлом разделе, необходимо научиться включать дополнительные функции. Например, чтобы моргать лампочкой ESC, нужно сначала найти способ включить эту опцию через UDS, а затем найти сообщение, управляющее лампочкой. Задачу усложняет отсутствие подопытного автомобиля в нужной комплектации.

В простом случае, этот процесс включает четыре этапа:

  1. поиск возможного DID, который может отвечать за состояние функции;

  2. подбор и запись нового значения в DID, например, 1 = ВКЛ, 0 = ВЫКЛ;

  3. анализ поведения панели, например, могли загореться новые лампы;

  4. подбор сообщения и управляющего сигнала с помощью фаззинга.

Как показали эксперименты, из этих правил бывают исключения. Некоторые опции даже не потребовалось включать отдельно, а некоторые включались, но никак не выдавали себя. Иногда панель могла самостоятельно изменять свою конфигурацию, увидев в трафике сообщение от конкретной системы - так было с типом коробки передач. Вероятнее всего, такое самообучение было добавлено для упрощения установки.

Сканирование DID, доступных для чтения, выявило любопытный набор значений в диапазоне 0x0100..0x0110. Запись доступна без каких-либо механизмов защиты, достаточно перейти в расширенную диагностическую сессию 0x03.

Относительно быстро нашлась система электронного контроля устойчивости (ESC). После записи единицы в DID 0x0103 и последующей перезагрузки, на панели сама загорелась искомая индикация:

В оригинальном скрипте BeamNG для управления лампой ESC применялись две переменных: electrics.values.esc и electrics.values.tcs. Если хотя бы одна из них принимает ненулевое значение, то лампа загорается.

Насколько стало понятно по обрывкам документации на Гранту, функции ABS и ESC совмещает в себе один и тот же блок, поэтому схема почти не меняется:

Перейдем к светотехнике: указателям поворота, лампам ближнего и дальнего света. На подопытном автомобиле индикация включается банальной подачей 12В от соответствующих реле, которые управляют световыми приборами. Для этого отдельные выводы панели подключены к этим самым реле, но так как мы строго ограничены воздействиями по CAN-шине, нужно искать обходной путь.

Им станет ЦБКЭ (Центральный Блок Кузовной Электроники), который применяется в более дорогих комплектациях, одна из его основных функций - это управление светотехникой. Отдельно включать его в конфигурации панели не пришлось, достаточно было просто найти сообщение, влияющие на индикацию световых приборов:

BeamNG использует переменные electrics.values.lowbeam/electrics.values.highbeam для ближнего/дальнего света и electrics.values.signal_L/electrics.values.signal_R для левого/правого поворотников.

ЦБКЭ это отдельный блок, поэтому дорисовываем его на схеме под именем BCM (Body Control Module):

Чего еще не хватает автосимулятору? Правильно, индикатора текущей передачи! На подопытном автомобиле установлена МКПП и на панель выводятся подсказки по переключению передач. Все отображаемые комбинации этого индикатора выглядят так:

Такая индикация не подходит сразу по двум причинам:

  • указывается не текущая, а рекомендуемая передача;

  • рядом всегда мигает стрелка повышения/понижения передачи.

Найти с помощью перебора сообщений и фаззинга "чистый" способ отображения текущей передачи МКПП не удалось. Но не будем отчаиваться раньше времени, поскольку на данный автомобиль могли устанавливаться и автоматические коробки, а уж они-то обычно выводят текущий режим/передачу на панель приборов.

После записи единицы в DID 0x0100 панель зажгла шестеренку с восклицательным знаком внутри - сигнализатор неисправности трансмиссии, который применяется для АКПП или "робота":

Методом перебора был найден вот такой набор индикации:

Это уже значительно лучше, чем в случае с МКПП, такая индикация может отлично подойти для симуляции классических "прындлов". Но что если захочется поиграть на МКПП и видеть номер передачи больше, чем 2?

Предположив, что DID 0x0100 отвечает за тип коробки передач, проявляем чудеса фантазии и записываем в него двойку - панель принимает это значение и моргает один раз лампой неисправности трансмиссии. Снова перебираем сообщения и находим самого настоящего "робота":

То есть: заднюю, нейтраль и по 5 передач в ручном и автоматическом режимах. Вот это уже достойный улов! Для более современных 8-ми ступенчатых автомобилей этой индикации, конечно, не хватит, но для симуляции 5-ти ступенчатой механики или автомата - вполне. BeamNG предлагает несколько переменных для отображения передачи, нам больше всего подходит electrics.values.gearIndex.

Оба типа трансмисии могут быть полезными для BeamNG, поэтому рисуем на обновленной схеме сразу обе:

Тем не менее, выбрать нужно что-то одно. АМТ лучше подходит из-за большего количества отображаемых передач, поэтому дальше будем использовать именно её.

Поскольку основные типы индикации уже покрыты, можно попробовать найти и задействовать что-нибудь более экзотическое. В самых ранних экспериментах, во все доступные DID записывались единицы и проводился перебор сообщений совместно с фаззингом. Смысл был прост - включить все, что можно включить и задолбать панель всеми возможными сообщениями.

Именно тогда на глаза попались лампы круиз-контроля и ограничителя скорости, что оказалось удачей, так как сами они не загораются при включении зажигания. Был найден нужный DID 0x0101 и даже подходящая переменная BeamNG electrics.values.cruiseControlActive. В оригинальном интерфейсе игры круиза нет, но его можно легко добавить через UI Apps -> Add app -> Cruise Control. Никакого нового блока на схему добавлять не надо, поскольку сигналы состояния круиз-контроля на панель отправляет ECM. Получилось вполне органично, даже иконки почти одинаковые:

А как насчет состояния шин? Запись единицы в DID 0x0104 привела к появлению лампы аварийного снижения давления в шинах, которой должна управлять по CAN система контроля давления в шинах (Tire Pressure Monitoring System). Информации о данной опции для Гранты в интернете не очень много, видимо она была действительно редкой:

Данные о давлении в шинах electrics.values не предоставляет, но ее можно найти вwheels.wheels[0..N].isTireDeflated. Как можно догадаться из названия, это булево значение, отображающее сдулась конкретная шина или нет.

Теперь можно добавить блок TPMS на схему и получить ее финальную форму:

Стоит отметить, что два последних результата, это в некотором смысле "натягивание совы на глобус". Для них требуется подключение дополнений для интерфейса и использование переменных за пределами electrics.values. Тем не менее, они показывают, что при должном упорстве можно подключить практически что угодно.

Обнаруженные DID, которые удалось осмыслить, соберем в таблицу:

DID

Функция

0x0100

Тип КПП (0 = МКПП, 1 = АКПП, 2 = АМТ)

0x0101

Круиз-контроль и ограничитель скорости

0x0103

ESC

0x0104

TPMS

0x0107

Подсказка переключения для МКПП

0x010A

ABS

0x010C

SRS

А вот так выглядит итоговое "меню", со всеми используемыми индикаторами, сообщениями и переменными:

Indicator

CAN Message

BeamNG Lua Variable

speed

ECM_1F9

electrics.values.wheelspeed OR electrics.values.airspeed

RPM

ECM_180

electrics.values.rpm

coolant temp

ECM_551

electrics.values.watertemp

gear

ECM_1F9/AT_421/AMT_3F7

electrics.values.gearIndex

lowbeam

BCM_481

electrics.values.lowbeam

highbeam

BCM_481

electrics.values.highbeam

handbrake

ABS_354

electrics.values.parkingbrake

ESC/TCS

ESC_245

electrics.values.esc OR electrics.values.tcs

turnsignal L

BCM_481

electrics.values.signal_L

turnsignal R

BCM_481

electrics.values.signal_R

oil warning

ECM_551

electrics.values.oil

battery

ECM_551

electrics.values.engineRunning

abs

ABS_354

electrics.values.abs

check engine

ECM_551

electrics.values.checkengine

coolant warning

ECM_551

electrics.values.watertemp

tire pressure

TPMS_3E2

wheels.wheels[0..N].isTireDeflated

cruise

ECM_35D

electrics.values.cruiseControlActive

Доработанные файлы:

granta_advanced.dbc
VERSION ""


NS_ :
    NS_DESC_
    CM_
    BA_DEF_
    BA_
    VAL_
    CAT_DEF_
    CAT_
    FILTER
    BA_DEF_DEF_
    EV_DATA_
    ENVVAR_DATA_
    SGTYPE_
    SGTYPE_VAL_
    BA_DEF_SGTYPE_
    BA_SGTYPE_
    SIG_TYPE_REF_
    VAL_TABLE_
    SIG_GROUP_
    SIG_VALTYPE_
    SIGTYPE_VALTYPE_
    BO_TX_BU_
    BA_DEF_REL_
    BA_REL_
    BA_DEF_DEF_REL_
    BU_SG_REL_
    BU_EV_REL_
    BU_BO_REL_
    SG_MUL_VAL_

BS_: 
BU_: ECM ABS SRS BCM ESC AMT TPMS AT 
BO_ 384 ECM_180: 8 ECM
   SG_ rpm : 7|16@0+ (0.125,0) [0|0] "rpm" Vector__XXX

BO_ 505 ECM_1F9: 8 ECM
   SG_ speed_km_h : 23|16@0+ (0.01,0) [0|0] "" Vector__XXX
   SG_ mt_gear_recommendation : 15|4@0+ (1,0) [0|1] "" Vector__XXX

BO_ 581 ESC_245: 8 ESC
   SG_ esc_off_lamp_on : 24|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ esc_lamp_on : 25|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 852 ABS_354: 8 ABS
   SG_ abs_lamp_on : 55|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ abs_and_brake_lamp_on : 53|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 861 ECM_35D: 8 ECM
   SG_ cruise_orange_lamp_on : 38|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ limiter_orange_lamp_on : 39|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ orange_lamp_blinking_flag : 33|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ green_lamp_flag : 32|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 994 TPMS_3E2: 8 TPMS
   SG_ tpms_lamp_on : 5|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ tpms_lamp_blinking : 6|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 1015 AMT_3F7: 8 AMT
   SG_ amt_state : 7|5@0+ (1,0) [0|1] "" Vector__XXX
   SG_ transmission_lamp_on : 2|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 1057 AT_421: 8 AT
   SG_ overdrive_off_lamp_on : 0|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ transmission_lamp_on : 2|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ at_state : 6|4@0+ (1,0) [0|1] "" Vector__XXX

BO_ 1153 BCM_481: 8 BCM
   SG_ turnsignal_right : 16|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ turnsignal_left : 17|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ low_beam : 6|1@0+ (1,0) [0|1] "" Vector__XXX
   SG_ high_beam : 4|1@0+ (1,0) [0|1] "" Vector__XXX

BO_ 1176 SRS_498: 1 SRS
   SG_ airbag_lamp : 4|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ seatbelt_fastened : 0|1@1+ (1,0) [0|0] "" Vector__XXX

BO_ 1361 ECM_551: 8 ECM
   SG_ coolant_temperature : 15|8@0+ (1,-40) [0|0] "" Vector__XXX
   SG_ check_engine_lamp_on : 32|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ check_engine_lamp_blinking : 33|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ oil_lamp_on : 34|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ overheat_lamp_with_sound : 35|1@1+ (1,0) [0|0] "" Vector__XXX
   SG_ battery_lamp_off : 24|1@1+ (1,0) [0|0] "" Vector__XXX

VAL_ 505 mt_gear_recommendation 3 "4_down" 4 "3_down" 5 "2_down" 6 "1_down" 9 "2_up" 10 "3_up" 11 "4_up" 12 "5_up";
VAL_ 1015 amt_state 2 "R" 3 "N" 16 "M1" 17 "M2" 18 "M3" 19 "M4" 20 "M5" 24 "A1" 25 "A2" 26 "A3" 27 "A4" 28 "A5";
VAL_ 1057 at_state 1 "P" 2 "R" 3 "N" 4 "D" 8 "1" 9 "2";
translator_advanced.py
import socket
import struct
import can
import cantools
from time import time

gear_dict = {0: 'R', 1: 'N', 2: 'M1', 3: 'M2', 4: 'M3', 5: 'M4', 6: 'M5'}

db = cantools.database.load_file('granta_advanced.dbc')
ecm_180 = db.get_message_by_name('ECM_180')
ecm_1f9 = db.get_message_by_name('ECM_1F9')
esc_245 = db.get_message_by_name('ESC_245')
abs_354 = db.get_message_by_name('ABS_354')
ecm_35d = db.get_message_by_name('ECM_35D')
tpms_3e2 = db.get_message_by_name('TPMS_3E2')
amt_3f7 = db.get_message_by_name('AMT_3F7')
bcm_481 = db.get_message_by_name('BCM_481')
srs_498 = db.get_message_by_name('SRS_498')
ecm_551 = db.get_message_by_name('ECM_551')

bus = can.interface.Bus(bustype='pcan', channel='PCAN_USBBUS1', bitrate=500000)

def socket_stuff():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(('127.0.0.1', 4444))

    data = ecm_180.encode({'rpm': 0})
    ecm_180_msg = can.Message(arbitration_id=ecm_180.frame_id, data=data, is_extended_id=False)
    ecm_180_task = bus.send_periodic(ecm_180_msg, 0.100)

    data = ecm_1f9.encode({'speed_km_h': 0, 
                           'mt_gear_recommendation': 0})
    ecm_1f9_msg = can.Message(arbitration_id=ecm_1f9.frame_id, data=data, is_extended_id=False)
    ecm_1f9_task = bus.send_periodic(ecm_1f9_msg, 0.100)

    data = esc_245.encode({'esc_off_lamp_on': 0, 
                           'esc_lamp_on': 0})
    esc_245_msg = can.Message(arbitration_id=esc_245.frame_id, data=data, is_extended_id=False)
    esc_245_task = bus.send_periodic(esc_245_msg, 0.100)

    data = abs_354.encode({'abs_lamp_on': 0, 
                           'abs_and_brake_lamp_on': 0})
    abs_354_msg = can.Message(arbitration_id=abs_354.frame_id, data=data, is_extended_id=False)
    abs_354_task = bus.send_periodic(abs_354_msg, 0.100)

    data = ecm_35d.encode({'cruise_orange_lamp_on': 0, 
                           'limiter_orange_lamp_on': 0, 
                           'green_lamp_flag': 1, 
                           'orange_lamp_blinking_flag': 0})
    ecm_35d_msg = can.Message(arbitration_id=ecm_35d.frame_id, data=data, is_extended_id=False)
    ecm_35d_task = bus.send_periodic(ecm_35d_msg, 0.200)

    data = tpms_3e2.encode({'tpms_lamp_blinking': 0, 
                            'tpms_lamp_on': 0})
    tpms_3e2_msg = can.Message(arbitration_id=tpms_3e2.frame_id, data=data, is_extended_id=False)
    tpms_3e2_task = bus.send_periodic(tpms_3e2_msg, 0.200)

    data = amt_3f7.encode({'amt_state': 'N', 
                           'transmission_lamp_on':0})
    amt_3f7_msg = can.Message(arbitration_id=amt_3f7.frame_id, data=data, is_extended_id=False)
    amt_3f7_task = bus.send_periodic(amt_3f7_msg, 0.100)

    data = bcm_481.encode({'turnsignal_right': 0, 
                           'turnsignal_left': 0, 
                           'low_beam': 0, 
                           'high_beam': 0})
    bcm_481_msg = can.Message(arbitration_id=bcm_481.frame_id, data=data, is_extended_id=False)
    bcm_481_task = bus.send_periodic(bcm_481_msg, 0.100)

    data = srs_498.encode({'airbag_lamp': 0, 
                           'seatbelt_fastened': 1})
    srs_498_msg = can.Message(arbitration_id=srs_498.frame_id, data=data, is_extended_id=False)
    srs_498_task = bus.send_periodic(srs_498_msg, 0.200)

    data = ecm_551.encode({'coolant_temperature': 0,
                           'check_engine_lamp_on': 0,
                           'check_engine_lamp_blinking': 0,
                           'oil_lamp_on': 0,
                           'overheat_lamp_with_sound': 0,
                           'battery_lamp_off': 1})
    ecm_551_msg = can.Message(arbitration_id=ecm_551.frame_id, data=data, is_extended_id=False)
    ecm_551_task = bus.send_periodic(ecm_551_msg, 0.100)

    start_ms = int(time() * 1000)

    while True:
        data = sock.recv(256)

        if not data:
            break

        elapsed_ms = int(time() * 1000) - start_ms
        if elapsed_ms >= 50:
            start_ms = int(time() * 1000)
            outgauge_pack = struct.unpack('I3sxH2B7f2I3f15sx15sxi', data)
            gear = int(outgauge_pack[3])
            speed = int(outgauge_pack[5])
            rpm = int(outgauge_pack[6])
            engtemp = int(outgauge_pack[8])
            showlights = int(outgauge_pack[13])

            ecm_180_msg.data = ecm_180.encode({'rpm': rpm})
            ecm_180_task.modify_data(ecm_180_msg)

            ecm_1f9_msg.data = ecm_1f9.encode({'speed_km_h': speed * 3.6, 
                                               'mt_gear_recommendation': 0})
            ecm_1f9_task.modify_data(ecm_1f9_msg)

            esc_245_msg.data = esc_245.encode({'esc_off_lamp_on': 0, 
                                               'esc_lamp_on': (showlights >> 4) & 1})
            esc_245_task.modify_data(esc_245_msg)

            abs_354_msg.data = abs_354.encode({'abs_lamp_on': (showlights >> 10) & 1 , 
                                               'abs_and_brake_lamp_on': (showlights >> 2) & 1})
            abs_354_task.modify_data(abs_354_msg)

            ecm_35d_msg.data = ecm_35d.encode({'cruise_orange_lamp_on': (showlights >> 14) & 1, 
                                               'limiter_orange_lamp_on': 0, 
                                               'green_lamp_flag': 1, 
                                               'orange_lamp_blinking_flag': 0})
            ecm_35d_task.modify_data(ecm_35d_msg)

            tpms_3e2_msg.data = tpms_3e2.encode({'tpms_lamp_blinking': 0, 
                                                 'tpms_lamp_on': (showlights >> 12) & 1})
            tpms_3e2_task.modify_data(tpms_3e2_msg)

            amt_3f7_msg.data = amt_3f7.encode({'amt_state': gear_dict[gear], 
                                               'transmission_lamp_on':0})
            amt_3f7_task.modify_data(amt_3f7_msg)

            bcm_481_msg.data = bcm_481.encode({'turnsignal_right': (showlights >> 6) & 1, 
                                               'turnsignal_left': (showlights >> 5) & 1, 
                                               'low_beam': (showlights >> 0) & 1, 
                                               'high_beam': (showlights >> 1) & 1})
            bcm_481_task.modify_data(bcm_481_msg)

            ecm_551_msg.data = ecm_551.encode({'coolant_temperature': engtemp, 
                                               'check_engine_lamp_on': (showlights >> 11) & 1, 
                                               'check_engine_lamp_blinking': 0, 
                                               'oil_lamp_on': (showlights >> 8) & 1, 
                                               'overheat_lamp_with_sound': (showlights >> 13) & 1, 
                                               'battery_lamp_off': 1 ^ ((showlights >> 9) & 1)})
            ecm_551_task.modify_data(ecm_551_msg)

    sock.close()

socket_stuff()

В скрипте outgauge.lua были отредактированы возможные значения переменной dashLights и логика обнаружения системы ESC. Оригинальный скрипт адекватно включал лампу ESC только на определенных моделях, например, Hirochi Sunburst.

outgauge.lua (modified)
-- This Source Code Form is subject to the terms of the bCDDL, v. 1.1.
-- If a copy of the bCDDL was not distributed with this
-- file, You can obtain one at http://beamng.com/bCDDL-1.1.txt

-- this file serves two purposes:
-- A) generic outgauge implementation: used to use for example custom made dashboard hardware and alike
-- B) update the user interface for the remote control app directly via the sendPackage function
-- please note that use case A and B exclude each other for now.

local M = {}

local ip = "127.0.0.1"
local port = 4444

local udpSocket = nil

local ffi = require("ffi")

local function declareOutgaugeStruct()
  -- the documentation can be found at LFS/docs/InSim.txt
  ffi.cdef [[
  typedef struct outgauge_t  {
      unsigned       time;            // time in milliseconds (to check order)
      char           car[4];          // Car name
      unsigned short flags;           // Info (see OG_x below)
      char           gear;            // Reverse:0, Neutral:1, First:2...
      char           plid;            // Unique ID of viewed player (0 = none)
      float          speed;           // M/S
      float          rpm;             // RPM
      float          turbo;           // BAR
      float          engTemp;         // C
      float          fuel;            // 0 to 1
      float          oilPressure;     // BAR
      float          oilTemp;         // C
      unsigned       dashLights;      // Dash lights available (see DL_x below)
      unsigned       showLights;      // Dash lights currently switched on
      float          throttle;        // 0 to 1
      float          brake;           // 0 to 1
      float          clutch;          // 0 to 1
      char           display1[16];    // Usually Fuel
      char           display2[16];    // Usually Settings
      int            id;              // optional - only if OutGauge ID is specified
  } outgauge_t;
  ]]
end
pcall(declareOutgaugeStruct)

local OG_KM = 16384
local OG_BAR = 32768
local OG_TURBO = 8192

local DL_LOWBEAM = 2 ^ 0
local DL_HIGHBEAM = 2 ^ 1
local DL_HANDBRAKE = 2 ^ 2
local DL_TC = 2 ^ 4
local DL_SIGNAL_L = 2 ^ 5
local DL_SIGNAL_R = 2 ^ 6
local DL_OILWARN = 2 ^ 8
local DL_BATTERY = 2 ^ 9
local DL_ABS = 2 ^ 10
local DL_CHECKENGINE = 2 ^ 11
local DL_TPMS = 2 ^ 12
local DL_OVERHEAT = 2 ^ 13
local DL_CRUISE = 2 ^ 14

local hasShiftLights = false

local function sendPackage(ip, port, id)
  --log('D', 'outgauge', 'sendPackage: '..tostring(ip) .. ':' .. tostring(port))

  if not electrics.values.watertemp then
    -- vehicle not completly initialized, skip sending package
    return
  end

  local o = ffi.new("outgauge_t")
  -- set the values
  o.time = 0 -- not used atm
  o.car = "beam"
  o.flags = OG_KM + OG_BAR + (electrics.values.turboBoost and OG_TURBO or 0)
  o.gear = electrics.values.gearIndex + 1 -- reverse = 0 here
  o.plid = 0
  o.speed = electrics.values.wheelspeed or electrics.values.airspeed
  o.rpm = electrics.values.rpm or 0
  o.turbo = (electrics.values.turboBoost or 0) / 14.504

  o.engTemp = electrics.values.watertemp or 0
  o.fuel = electrics.values.fuel or 0
  o.oilPressure = 0 -- TODO
  o.oilTemp = electrics.values.oiltemp or 0

  -- the lights
  o.dashLights = bit.bor(o.dashLights, DL_LOWBEAM)
  if electrics.values.lowbeam ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_LOWBEAM)
  end

  o.dashLights = bit.bor(o.dashLights, DL_HIGHBEAM)
  if electrics.values.highbeam ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_HIGHBEAM)
  end

  o.dashLights = bit.bor(o.dashLights, DL_HANDBRAKE)
  if electrics.values.parkingbrake ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_HANDBRAKE)
  end

  o.dashLights = bit.bor(o.dashLights, DL_SIGNAL_L)
  if electrics.values.signal_L ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_SIGNAL_L)
  end

  o.dashLights = bit.bor(o.dashLights, DL_SIGNAL_R)
  if electrics.values.signal_R ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_SIGNAL_R)
  end

  local hasABS = electrics.values.hasABS or false
  if hasABS then
    o.dashLights = bit.bor(o.dashLights, DL_ABS)
    if electrics.values.abs ~= 0 then
      o.showLights = bit.bor(o.showLights, DL_ABS)
    end
  end

  o.dashLights = bit.bor(o.dashLights, DL_OILWARN)
  if electrics.values.oil ~= 0 then
    o.showLights = bit.bor(o.showLights, DL_OILWARN)
  end

  o.dashLights = bit.bor(o.dashLights, DL_BATTERY)
  if electrics.values.engineRunning == 0 then
    o.showLights = bit.bor(o.showLights, DL_BATTERY)
  end

  local hasESC = (electrics.values.esc ~= nil) or (electrics.values.tcs ~= nil)
  if hasESC then
    o.dashLights = bit.bor(o.dashLights, DL_TC)
    if electrics.values.esc ~= 0 or electrics.values.tcs ~= 0 then
      o.showLights = bit.bor(o.showLights, DL_TC)
    end
  end

  o.dashLights = bit.bor(o.dashLights, DL_CHECKENGINE)
  if electrics.values.checkengine == true then
    o.showLights = bit.bor(o.showLights, DL_CHECKENGINE)
  end

  o.dashLights = bit.bor(o.dashLights, DL_TPMS)
  if wheels.wheels[0].isTireDeflated or 
     wheels.wheels[1].isTireDeflated or 
     wheels.wheels[2].isTireDeflated or 
     wheels.wheels[3].isTireDeflated then
    o.showLights = bit.bor(o.showLights, DL_TPMS)
  end

  o.dashLights = bit.bor(o.dashLights, DL_OVERHEAT)
  if electrics.values.watertemp > 115 then
    o.showLights = bit.bor(o.showLights, DL_OVERHEAT)
  end

  local hasCC = (electrics.values.cruiseControlActive ~= nil)
  if hasCC then
    o.dashLights = bit.bor(o.dashLights, DL_CRUISE)
    if electrics.values.cruiseControlActive ~= 0 then
      o.showLights = bit.bor(o.showLights, DL_CRUISE)
    end
  end

  o.throttle = electrics.values.throttle
  o.brake = electrics.values.brake
  o.clutch = electrics.values.clutch
  o.display1 = "" -- TODO
  o.display2 = "" -- TODO
  o.id = id

  local packet = ffi.string(o, ffi.sizeof(o)) --convert the struct into a string
  udpSocket:sendto(packet, ip, port)
  --log("I", "", "SendPackage for ID '"..dumps(id).."': "..dumps(electrics.values.rpm))
end

local function updateGFX(dt)
  if not playerInfo.firstPlayerSeated then
    return
  end
  sendPackage(ip, port, 0)
end

local function onExtensionLoaded()
  if not ffi then
    log("E", "outgauge", "Unable to load outgauge module: Lua FFI required")
    return false
  end

  if not udpSocket then
    udpSocket = socket.udp()
  end

  ip = settings.getValue("outgaugeIP")
  port = tonumber(settings.getValue("outgaugePort"))

  log("I", "", "Outgauge initialized for: " .. tostring(ip) .. ":" .. tostring(port))

--  local shiftLightControllers = controller.getControllersByType("shiftLights")
--  hasShiftLights = shiftLightControllers and #shiftLightControllers > 0
  return true
end

local function onExtensionUnloaded()
  if udpSocket then
    udpSocket:close()
  end
  udpSocket = nil
end

-- public interface
M.onExtensionLoaded = onExtensionLoaded
M.onExtensionUnloaded = onExtensionUnloaded
M.updateGFX = updateGFX

M.sendPackage = sendPackage

return M

Заключение

Вот так изначальная идея поиграться c CAN-шиной разрослась до аппаратного дополнения к BeamNG. Лень мотивировала использовать минимальное количество железа, а любопытство - найти побольше полезных сообщений.

Из недостатков стоит упомянуть проблемы с таймингом сообщений (особенно это бывает заметно для ритма поворотников) и невозможность полноценного подключения некоторых сигналов (уровень топлива и ручник). Впрочем, это не сильно сказывается на впечатлениях от геймплея.

Заодно в процессе получилось чуть лучше понять, как устроен подопытный автомобиль, и некоторые особенности BeamNG.