Добрый день, в контексте моего хобби по схемотехнике и программированию микроконтроллеров появилась идея реализовать устройство для дистанционного запуска предпускового котла Webasto, в моем случае это “Webasto Thermo Top Evo 5”. Вероятно предложенный материал подойдет и для реализации устройств управления для схожих отопителей данной марки работающих по протоколу W-bus.

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

Аппаратная часть

Я логически разделил схему на две части: питание и управление.

Узел питания построен на линейном стабилизаторе напряжения LM7805, импользуемый мною позволяет пропускать до 500 мА ток в теории, на практике - рекомендую брать корпус ТО-220 и крепить к нему радиатор, так как потребление микроконтроллера в среднем будет 50 мА в активном состоянии и 150 мА будет потреблять WiFi или LoRa в момент передачи, опять же нужно смотреть конкретные даташиты. В моем случае без радиатора летом - стабилизатор грелся и тротлил.

Обвязка микросхемы L9637D в целом не сложная, в качестве микроконтроллера можно взять любой удобный, важно наличие UART интерфейса. К Vcc(3) ноге микросхемы L9637D важно подключить питание, уровень которого соответствует уровню логике, тоесть если логи 3.3v и UART приниамет 3.3v, то и к Vcc нужно подключать 3.3v. В моем случае 5v идет на саму “dev” плату, а 3.3v уже получается находящимся на плате линейным регулятором.

Подключение к Webasto

Тут есть несколько вариантов.

Если у вас уже есть штатный таймер в салоне, то в таком случае необходимые +12v, GND, W-bus подключения у вас есть, можно подключиться к ним (красный +12v; коричневый - GND; желтый - W-bus).

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

Программная часть

Тут лучше начать с краткого описания протокола (нужно понимать, что описаное в данной статье было получено опытнум путем)

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

адресация длина пакета данные контрольная сумма | 1-байт | 1-байт | N-байт | 1-байт |

Видно, что один пакет не может иметь длину больше чем 255 байт. А данные не могут превышать длину в 252 байта.

Теперь немного подробнее о протоколе:

1й байт - это адресация, 0x4 - Webasto; 0x3 - таймер; 0x2 - Telestart; 0xF - тестовый клиент. Первые 4 бита это отправитель, вторые это получатель. Пример 0xF4 - отправляем от клиентскогго устройства к Webasto; 0x4F - получаем ответ от Webasto клиентскому устройству.

2й байт - длина пакета, считается как оставшаяся длина данных + контрольная самма, иными словами это общая длина всех передаваемых данных за минусом двух байт

3й-N байт - непосредственно данные, тут важно отметить, что первый байт в данных является командой и в ответ со стороны Webasto приходит тоже число + выставленный в 1 старший бит. Пример отправляем 0x10 (команда на остановку котла), ответ будет 0x90, тоесть в ответ прибавляется 0x80 к команде.

последний байт - контрольная сумма, считается как последовательная XOR операция, от 1го байта до последнего байта данных

Приведу несколько примеров для основных команд управления котлом:

Остановка котла:
Запрос [ 34 02 10 26 ]
Ответ [ 43 02 90 D1 ]

Запуск котла:
Запрос [ 34 03 21 1E 08 ]
4й байт в данном случае это время - 30min
Ответ [ 43 03 A1 1E FF ]
3й байт в ответе это команда 0x21 + 0x80 (старший бит выставленный в 1)

Hartbeat котла:
Необходимо отправлять каждые 10-15 сек. после запуска котла, в противном случае при отсутствии hartbeat - котел остановится.
Эту команду можно так-же использовать для получения статус - запущен ли котел.
Запрос [ 34 04 44 21 00 55 ]
4й байт в запросе это команда для которой мы отправляем Hartbeat 
Ответ [ 43 03 C4 00 84 ]
4й байт в ответе это результат Hartbeat (0 - если котел запущен и работает в штатном режиме, 1 - если котел не находится в режиме запуска определенной команды, например 21)

Реализация

Данная реализация использует фреймфорк esp-idf, но в целом не составит большого труда перенести ее на Arduino или любой другой. В данной статье я представлю основные критические секции кода, раскрывающие основной принцип работы. Параметры UART следующие: 2400_8E1

Для начала определим наш объект для хранения состояния

typedef struct wbus_io_request {
    uint8_t tx_buffer[WBUS_IO_TX_BUFFER_SIZE];
    size_t tx_buffer_size;
} wbus_io_request_t;

typedef struct wbus_io_sensors {
    wbus_io_param_float_t battery;
    wbus_io_param_int8_t coolant;
} wbus_io_sensors_t;

typedef struct wbus_io {
    int resource_id;
    uint32_t tx_timestamp;
    volatile uint8_t transport_state;
    volatile uint8_t heater_state;
    volatile uint32_t heater_started_time;
    volatile uint32_t heater_duration_time;
    volatile bool enabled;
    wbus_io_sensors_t sensors;
    wbus_io_stats_err_t stats_err;
    uint8_t error_buffer[WBUS_IO_ERROR_LIST_SIZE * sizeof(wbus_io_error_t)];
    ring_buffer_t errors;

    // esp-idf (переменные и типы специфичные для фрейморка)
    SemaphoreHandle_t mutex;
    esp_event_loop_handle_t event_loop;
    uint8_t request_queue_buffer[WBUS_IO_REQUEST_QUEUE_SIZE * sizeof(wbus_io_request_t)];
    StaticQueue_t request_queue_ctx;
} wbus_io_t;

#define WBUS_ADDR_RX     0x4F
#define WBUS_ADDR_TX     0xF4
#define WBUS_CMD_OFF     0x10
#define WBUS_CMD_ON_PH   0x21
#define WBUS_CMD_CHK     0x44
#define WBUS_CMD_QUERY   0x50
#define WBUS_QUERY_PARAMS 0x30
#define WBUS_RESP_APPROVE_FLAG_MASK 0x80
#define WBUS_PARAM_BATTERY_VOLTAGE 0x0E
#define WBUS_PARAM_COOLING_LIQUID_TEMPERATURE 0x0C
#define WBUS_PROTO_ADDR         0
#define WBUS_PROTO_SIZE         1
#define WBUS_PROTO_CMD          2
#define WBUS_PROTO_BODY_START   3
#define WBUS_CMD_CHK_REPLY_OK   0

Далее определим доп. функции, в нашем случае это рассчет контрольной суммы и функцию подготовки пакета

uint8_t checksum(uint8_t *buf, uint8_t len, uint8_t chk) {
    for (; len != 0; len--) {
        chk ^= *buf++;
    }
    return chk;
}

static wbus_err_t query_prepare(wbus_io_t *ctx, uint8_t command, uint8_t tx_size, wbus_io_request_t *request) {
    request->tx_buffer_size = tx_size;

    request->tx_buffer[0] = WBUS_ADDR_TX;                   // адресация
    request->tx_buffer[1] = request->tx_buffer_size - 2;    // длина пакета
    request->tx_buffer[2] = command;                        // команда
    request->tx_buffer[request->tx_buffer_size - 1] = checksum(
        (uint8_t*)request->tx_buffer,
        request->tx_buffer_size - 1,
        0);                                                   // контрльная сумма

    return xQueueSend((QueueHandle_t) &ctx->request_queue_ctx, request, 0) == pdTRUE ?
        WBUS_IO_ERR_NONE :
        WBUS_IO_ERR_INVALID_STATE;
}

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

static void query_executor(void *arg) {
    ESP_LOGI(TAG_UART_WBUS, "query task started");

    wbus_io_t *ctx = (wbus_io_t *)arg;
    wbus_io_request_t request;
    int tx_size, rx_size;
    uint8_t try_iteration;
    uint8_t _checksum;
    uint8_t rx_buffer[WBUS_IO_RX_BUFFER_SIZE];
    wbus_io_error_t error;
    bool query_successfull;

    while (true) {
        // достаем подготовленный запрос из очереди (блокирующая операция)
        if (xQueueReceive((QueueHandle_t) &ctx->request_queue_ctx, &request, portMAX_DELAY) != pdTRUE) {
            ESP_LOGI(TAG_UART_WBUS, "no requests, continue");
            continue;
        }

        xSemaphoreTake(ctx->mutex, WBUS_IO_MUTEX_LOCK_TIMEOUT);
        ctx->transport_state = WBUS_IO_TRANSPORT_STATE_BUSY;
        xSemaphoreGive(ctx->mutex);

        try_iteration = WBUS_IO_RETRY_COUNT;
        query_successfull = false;

        // так как котел может не ответить, такое может происходить не��колько раз - в цикле пытаемся отправить запрос и ждать ответа
        while (true) {
            if (try_iteration == 0) {
                ESP_LOGE(TAG_UART_WBUS, "retry attempts = 0");
                error.error_id = WBUS_IO_ERR_PACK_INCOMPLETE;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            try_iteration--;
            ESP_LOGI(TAG_UART_WBUS, "-- retries left %d --------", try_iteration);

            // перед каждой попыткой - сбрасываем данные из входного буфера (UART)
            uart_flush(ctx->resource_id);

            // update last tx timestamp
            ctx->tx_timestamp = esp_timer_get_time() / 1000;

            // сразу отправляем весь пакет
            ESP_LOGD(TAG_UART_WBUS, "Ctrl --> Heater");
            ESP_LOG_BUFFER_HEX_LEVEL(TAG_UART_WBUS, request.tx_buffer, request.tx_buffer_size, ESP_LOG_DEBUG);
            tx_size = uart_write_bytes(ctx->resource_id, request.tx_buffer, request.tx_buffer_size);
            if (tx_size != request.tx_buffer_size) {
                ESP_LOGE(TAG_UART_WBUS, "send query stage; tx_size(%d) != tx_buffer_size(%d)", tx_size, request.tx_buffer_size);
                error.error_id = WBUS_IO_ERR_TX;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            // так как коммуникауия происходит по одному проводу rx/tx закольцованы - нам нужно вычитать эхо из входящего буфера
            if (!uart_read_bytes_until(
                    ctx->resource_id,
                    rx_buffer,
                    request.tx_buffer_size,
                    (size_t *)&rx_size,
                    WBUS_IO_RX_TIMEOUT)) {
                ESP_LOGE(TAG_UART_WBUS, "read echo stage; rx_size(%d) != required(%d)", rx_size, request.tx_buffer_size);
                error.error_id = WBUS_IO_ERR_PACK_INCOMPLETE;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            // вычитываем заголово (адрес, длину пакета)
            if (!uart_read_bytes_until(
                    ctx->resource_id,
                    rx_buffer,
                    2,
                    (size_t *)&rx_size,
                    WBUS_IO_RX_TIMEOUT)) {
                ESP_LOGE(TAG_UART_WBUS, "read header stage; timeout rx_size(%d) != required(%d)", rx_size, 2);
                continue;
            }
            ESP_LOGD(TAG_UART_WBUS, "Heater --> Ctrl (header)");
            ESP_LOG_BUFFER_HEX_LEVEL(TAG_UART_WBUS, rx_buffer, rx_size, ESP_LOG_DEBUG);

            if (rx_buffer[WBUS_PROTO_ADDR] != WBUS_ADDR_RX) {
                ESP_LOGE(TAG_UART_WBUS, "read header stage; addr(%0X) != %0X", rx_buffer[0], WBUS_ADDR_RX);
                error.error_id = WBUS_IO_ERR_PACK;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            if (rx_buffer[WBUS_PROTO_SIZE] == 0) {
                ESP_LOGE(TAG_UART_WBUS, "read header stage; size = 0");
                error.error_id = WBUS_IO_ERR_PACK;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            // вычитываем оставшийся ответ (данные, контрльная сумма)
            if (!uart_read_bytes_until(
                    ctx->resource_id,
                    &rx_buffer[2],
                    rx_buffer[WBUS_PROTO_SIZE],
                    (size_t *)&rx_size,
                    WBUS_IO_RX_TIMEOUT)) {
                ESP_LOGE(TAG_UART_WBUS, "read body stage; timeout rx_size(%d) != required(%d)", rx_size, rx_buffer[WBUS_PROTO_SIZE]);
                continue;
            }
            ESP_LOGD(TAG_UART_WBUS, "Heater --> Ctrl (body)");
            ESP_LOG_BUFFER_HEX_LEVEL(TAG_UART_WBUS, &rx_buffer[2], rx_size, ESP_LOG_DEBUG);

            // проверяем контрльную сумму и команду которую нам в ответ пслал котел

            rx_size = rx_size + 2;
            _checksum = checksum(rx_buffer, rx_size - 1, 0);

            if (rx_buffer[rx_size - 1] != _checksum) {
                ESP_LOGE(TAG_UART_WBUS, "read body stage; checksum faild; recevied(%0X) != actual(%0X)", rx_buffer[rx_size - 1], _checksum);
                error.error_id = WBUS_IO_ERR_CHECKSUM;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            if (rx_buffer[WBUS_PROTO_CMD] - WBUS_RESP_APPROVE_FLAG_MASK != request.tx_buffer[WBUS_PROTO_CMD]) {
                ESP_LOGE(TAG_UART_WBUS, "read body stage; command; recevied(%0X) != actual(%0X)", rx_buffer[WBUS_PROTO_CMD] - WBUS_RESP_APPROVE_FLAG_MASK, request.tx_buffer[WBUS_PROTO_CMD]);
                error.error_id = WBUS_IO_ERR_CMD;
                ring_buffer_write(&ctx->errors, &error);
                break;
            }

            query_successfull = true;
            ESP_LOGI(TAG_UART_WBUS, "response successfully receviced and checked");
            break;
        }

        xSemaphoreTake(ctx->mutex, WBUS_IO_MUTEX_LOCK_TIMEOUT);
        ctx->transport_state = WBUS_IO_TRANSPORT_STATE_READY;
        xSemaphoreGive(ctx->mutex);

        if (query_successfull) {
            // если все проверки прошли успешно - обрабатываем ответ котла
            query_handle_response(ctx, rx_buffer, rx_size);
        } else {
            ESP_LOGE(TAG_UART_WBUS, "query failed");
            post_event(ctx, WBUS_IO_EVENT_ERR_QUERY);
        }
    }
}

Далее определим верхнеуровневые функции которые формируют специфичные для конкретной операции данные для отправки

// команда остановки котла
wbus_err_t wbus_io_cmd_stop(wbus_io_t *ctx) {
    if (!ctx->enabled) {
        return WBUS_IO_ERR_DISABLED;
    }

    wbus_io_request_t request;
    return query_prepare(ctx, WBUS_CMD_OFF, 4, &request);
}

// команда запуска котла
wbus_err_t wbus_io_cmd_start(wbus_io_t *ctx, uint8_t minutes) {
    if (!ctx->enabled) {
        return WBUS_IO_ERR_DISABLED;
    }

    wbus_io_request_t request;
    request.tx_buffer[3] = minutes;
    return query_prepare(ctx, WBUS_CMD_ON_PH, 5, &request);
}

// команда отправки Hartbeat
wbus_err_t wbus_io_cmd_check(wbus_io_t *ctx) {
    if (!ctx->enabled) {
        return WBUS_IO_ERR_DISABLED;
    }

    wbus_io_request_t request;
    request.tx_buffer[3] = WBUS_CMD_ON_PH;
    request.tx_buffer[4] = 0;
    return query_prepare(ctx, WBUS_CMD_CHK, 6, &request);
}