From 2944fd2b68dec3e2f0eaae7fbd3c04be714c7b4e Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 13 Sep 2026 15:26:35 +0200 Subject: [PATCH 1/3] feat(ccp): generic CCP 2.1 calibration-protocol master New ESPHome external component `ccp`: a CAN Calibration Protocol master on can_gateway (single-frame CRO/DTO, no ISO-TP). Full command set + high-level read_memory/write_memory helpers, ESPHome actions, on_connected/on_response/ on_error/on_daq triggers, and a pure host-testable ccp_proto.h codec. Every field (command/response CAN ids, station address, byte order, timeout) is configurable; the schema defaults and every test/example address are generic placeholders, not tied to any one ECU. Observe-first safety: no auto-connect, allow_write gates every write command; emits USE_CAN_GATEWAY_OBSERVE. Gates green: script/check.sh (14 ccp schema tests, both build fixtures, clang-format, esphome config); make -C tests/host (5 ccp_proto cases incl. little-endian SET_MTA and 12->5/5/2 read chunking). --- components/ccp/__init__.py | 196 +++++++++++ components/ccp/ccp.cpp | 324 +++++++++++++++++++ components/ccp/ccp.h | 223 +++++++++++++ components/ccp/ccp_proto.h | 196 +++++++++++ tests/build/ccp/.gitignore | 5 + tests/build/ccp/common.yaml | 48 +++ tests/build/ccp/common_write.yaml | 14 + tests/build/ccp/test-write.esp32-c6-idf.yaml | 14 + tests/build/ccp/test.esp32-c6-idf.yaml | 14 + tests/ccp/__init__.py | 1 + tests/ccp/common.py | 22 ++ tests/ccp/test_schema.py | 59 ++++ tests/host/test_ccp_proto.cpp | 66 ++++ 13 files changed, 1182 insertions(+) create mode 100644 components/ccp/__init__.py create mode 100644 components/ccp/ccp.cpp create mode 100644 components/ccp/ccp.h create mode 100644 components/ccp/ccp_proto.h create mode 100644 tests/build/ccp/.gitignore create mode 100644 tests/build/ccp/common.yaml create mode 100644 tests/build/ccp/common_write.yaml create mode 100644 tests/build/ccp/test-write.esp32-c6-idf.yaml create mode 100644 tests/build/ccp/test.esp32-c6-idf.yaml create mode 100644 tests/ccp/__init__.py create mode 100644 tests/ccp/common.py create mode 100644 tests/ccp/test_schema.py create mode 100644 tests/host/test_ccp_proto.cpp diff --git a/components/ccp/__init__.py b/components/ccp/__init__.py new file mode 100644 index 0000000..bddc8fc --- /dev/null +++ b/components/ccp/__init__.py @@ -0,0 +1,196 @@ +# CCP 2.1 single-frame master ESPHome codegen and automation registration. +"""CCP 2.1 single-frame master over a can_gateway observation port.""" + +from __future__ import annotations + +from esphome import automation +import esphome.codegen as cg +from esphome.components.can_gateway import GatewayPort +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv + +CODEOWNERS = ["@kipp-ing"] +DEPENDENCIES = ["can_gateway"] +MULTI_CONF = True + +ccp_ns = cg.esphome_ns.namespace("ccp") +CcpHub = ccp_ns.class_("CcpHub", cg.Component) +CcpConnectAction = ccp_ns.class_("CcpConnectAction", automation.Action) +CcpDisconnectAction = ccp_ns.class_("CcpDisconnectAction", automation.Action) +CcpGetVersionAction = ccp_ns.class_("CcpGetVersionAction", automation.Action) +CcpExchangeIdAction = ccp_ns.class_("CcpExchangeIdAction", automation.Action) +CcpSetMtaAction = ccp_ns.class_("CcpSetMtaAction", automation.Action) +CcpUploadAction = ccp_ns.class_("CcpUploadAction", automation.Action) +CcpShortUploadAction = ccp_ns.class_("CcpShortUploadAction", automation.Action) +CcpDownloadAction = ccp_ns.class_("CcpDownloadAction", automation.Action) +CcpSelectCalPageAction = ccp_ns.class_("CcpSelectCalPageAction", automation.Action) +CcpReadMemoryAction = ccp_ns.class_("CcpReadMemoryAction", automation.Action) +CcpWriteMemoryAction = ccp_ns.class_("CcpWriteMemoryAction", automation.Action) +CcpDaqAction = ccp_ns.class_("CcpDaqAction", automation.Action) +CcpRawAction = ccp_ns.class_("CcpRawAction", automation.Action) +CcpSetEnabledAction = ccp_ns.class_("CcpSetEnabledAction", automation.Action) + +CONF_CAN_GATEWAY_ID = "can_gateway_id" +CONF_COMMAND_ID = "command_id" +CONF_RESPONSE_ID = "response_id" +CONF_STATION_ADDRESS = "station_address" +CONF_BYTE_ORDER = "byte_order" +CONF_RESPONSE_TIMEOUT = "response_timeout" +CONF_ALLOW_WRITE = "allow_write" +CONF_ON_CONNECTED = "on_connected" +CONF_ON_RESPONSE = "on_response" +CONF_ON_ERROR = "on_error" +CONF_ON_DAQ = "on_daq" +CONF_DATA = "data" +CONF_END = "end" +CONF_MTA = "mta" +CONF_EXT = "ext" +CONF_ADDRESS = "address" +CONF_SIZE = "size" +CONF_LENGTH = "length" +CONF_ENABLED = "enabled" + + +def _can_id(value): + value = cv.hex_uint32_t(value) + if value > 0x7FF: + raise cv.Invalid("must be an 11-bit CAN identifier (0x000..0x7FF)") + return value + + +def _byte_order(value): + return cv.one_of("little", "big", lower=True)(value) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(CcpHub), + cv.Required(CONF_CAN_GATEWAY_ID): cv.use_id(GatewayPort), + cv.Optional(CONF_COMMAND_ID, default=0x700): _can_id, + cv.Optional(CONF_RESPONSE_ID, default=0x701): _can_id, + cv.Optional(CONF_STATION_ADDRESS, default=0x0001): cv.hex_uint16_t, + cv.Optional(CONF_BYTE_ORDER, default="little"): _byte_order, + cv.Optional(CONF_RESPONSE_TIMEOUT, default="100ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ALLOW_WRITE, default=False): cv.boolean, + cv.Optional(CONF_ON_CONNECTED): automation.validate_automation(), + cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(), + cv.Optional(CONF_ON_DAQ): automation.validate_automation(), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config): + """One CCP master owns a port, avoiding independent strict-response slots on it.""" + hubs = fv.full_config.get().get("ccp", []) + this_id = str(config[CONF_ID]) + port = str(config[CONF_CAN_GATEWAY_ID]) + for hub in hubs: + if str(hub[CONF_ID]) != this_id and str(hub[CONF_CAN_GATEWAY_ID]) == port: + raise cv.Invalid(f"CCP hub port '{port}' is already used by another CCP hub") + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + port = await cg.get_variable(config[CONF_CAN_GATEWAY_ID]) + var = cg.new_Pvariable(config[CONF_ID], port) + await cg.register_component(var, config) + cg.add_define("USE_CAN_GATEWAY_OBSERVE") + if config[CONF_ALLOW_WRITE]: + cg.add_define("USE_CCP_WRITE") + cg.add(var.set_command_id(config[CONF_COMMAND_ID])) + cg.add(var.set_response_id(config[CONF_RESPONSE_ID])) + cg.add(var.set_station_address(config[CONF_STATION_ADDRESS])) + cg.add(var.set_byte_order(cg.RawExpression("ccp::ByteOrder::LITTLE" if config[CONF_BYTE_ORDER] == "little" else "ccp::ByteOrder::BIG"))) + cg.add(var.set_response_timeout(config[CONF_RESPONSE_TIMEOUT].total_milliseconds)) + cg.add(var.set_allow_write(config[CONF_ALLOW_WRITE])) + for conf in config.get(CONF_ON_CONNECTED, []): + await automation.build_callback_automation(var, "add_on_connected_callback", [], conf) + for conf in config.get(CONF_ON_RESPONSE, []): + await automation.build_callback_automation(var, "add_on_response_callback", [(cg.uint8, "command"), (cg.uint8, "return_code"), (cg.std_vector.template(cg.uint8), "data")], conf) + for conf in config.get(CONF_ON_ERROR, []): + await automation.build_callback_automation(var, "add_on_error_callback", [(cg.uint8, "command"), (cg.uint8, "return_code")], conf) + for conf in config.get(CONF_ON_DAQ, []): + await automation.build_callback_automation(var, "add_on_daq_callback", [(cg.uint8, "pid"), (cg.std_vector.template(cg.uint8), "data")], conf) + + +_HUB = {cv.Required(CONF_ID): cv.use_id(CcpHub)} +_BYTES = cv.templatable(cv.ensure_list(cv.hex_uint8_t)) +_U8 = lambda default=None: cv.templatable(cv.int_range(min=0, max=0xFF)) +_U16 = lambda: cv.templatable(cv.int_range(min=0, max=0xFFFF)) +_U32 = lambda: cv.templatable(cv.int_range(min=0, max=0xFFFFFFFF)) + + +async def _action(config, action_id, template_arg, cls): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action("ccp.connect", CcpConnectAction, cv.Schema(_HUB), synchronous=True) +async def ccp_connect(config, action_id, template_arg, args): return await _action(config, action_id, template_arg, CcpConnectAction) + +@automation.register_action("ccp.disconnect", CcpDisconnectAction, cv.Schema({**_HUB, cv.Optional(CONF_END, default=False): cv.templatable(cv.boolean)}), synchronous=True) +async def ccp_disconnect(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpDisconnectAction); cg.add(var.set_end(await cg.templatable(config[CONF_END], args, bool))); return var + +@automation.register_action("ccp.get_version", CcpGetVersionAction, cv.Schema(_HUB), synchronous=True) +async def ccp_version(config, action_id, template_arg, args): return await _action(config, action_id, template_arg, CcpGetVersionAction) + +@automation.register_action("ccp.exchange_id", CcpExchangeIdAction, cv.Schema(_HUB), synchronous=True) +async def ccp_exchange_id(config, action_id, template_arg, args): return await _action(config, action_id, template_arg, CcpExchangeIdAction) + +@automation.register_action("ccp.set_mta", CcpSetMtaAction, cv.Schema({**_HUB, cv.Optional(CONF_MTA, default=0): _U8(), cv.Required(CONF_EXT): _U8(), cv.Required(CONF_ADDRESS): _U32()}), synchronous=True) +async def ccp_set_mta(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpSetMtaAction) + cg.add(var.set_mta(await cg.templatable(config[CONF_MTA], args, cg.uint8))); cg.add(var.set_ext(await cg.templatable(config[CONF_EXT], args, cg.uint8))); cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint32))); return var + +@automation.register_action("ccp.upload", CcpUploadAction, cv.Schema({**_HUB, cv.Required(CONF_SIZE): cv.templatable(cv.int_range(min=1, max=5))}), synchronous=True) +async def ccp_upload(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpUploadAction); cg.add(var.set_size(await cg.templatable(config[CONF_SIZE], args, cg.uint8))); return var + +@automation.register_action("ccp.short_upload", CcpShortUploadAction, cv.Schema({**_HUB, cv.Required(CONF_SIZE): cv.templatable(cv.int_range(min=1, max=5)), cv.Required(CONF_EXT): _U8(), cv.Required(CONF_ADDRESS): _U32()}), synchronous=True) +async def ccp_short_upload(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpShortUploadAction) + cg.add(var.set_size(await cg.templatable(config[CONF_SIZE], args, cg.uint8))); cg.add(var.set_ext(await cg.templatable(config[CONF_EXT], args, cg.uint8))); cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint32))); return var + +async def _data_action(config, action_id, template_arg, args, cls): + var = await _action(config, action_id, template_arg, cls) + if cg.is_template(config[CONF_DATA]): cg.add(var.set_data_template(await cg.templatable(config[CONF_DATA], args, cg.std_vector.template(cg.uint8)))) + else: cg.add(var.set_data_static(config[CONF_DATA])) + return var + +@automation.register_action("ccp.download", CcpDownloadAction, cv.Schema({**_HUB, cv.Required(CONF_DATA): _BYTES}), synchronous=True) +async def ccp_download(config, action_id, template_arg, args): return await _data_action(config, action_id, template_arg, args, CcpDownloadAction) + +@automation.register_action("ccp.select_cal_page", CcpSelectCalPageAction, cv.Schema(_HUB), synchronous=True) +async def ccp_select_page(config, action_id, template_arg, args): return await _action(config, action_id, template_arg, CcpSelectCalPageAction) + +@automation.register_action("ccp.read_memory", CcpReadMemoryAction, cv.Schema({**_HUB, cv.Required(CONF_EXT): _U8(), cv.Required(CONF_ADDRESS): _U32(), cv.Required(CONF_LENGTH): cv.templatable(cv.int_range(min=1, max=0xFFFF))}), synchronous=True) +async def ccp_read_memory(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpReadMemoryAction) + cg.add(var.set_ext(await cg.templatable(config[CONF_EXT], args, cg.uint8))); cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint32))); cg.add(var.set_length(await cg.templatable(config[CONF_LENGTH], args, cg.uint16))); return var + +@automation.register_action("ccp.write_memory", CcpWriteMemoryAction, cv.Schema({**_HUB, cv.Required(CONF_EXT): _U8(), cv.Required(CONF_ADDRESS): _U32(), cv.Required(CONF_DATA): _BYTES}), synchronous=True) +async def ccp_write_memory(config, action_id, template_arg, args): + var = await _data_action(config, action_id, template_arg, args, CcpWriteMemoryAction) + cg.add(var.set_ext(await cg.templatable(config[CONF_EXT], args, cg.uint8))); cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint32))); return var + +@automation.register_action("ccp.start_daq", CcpDaqAction, cv.Schema(_HUB), synchronous=True) +async def ccp_start_daq(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpDaqAction); cg.add(var.set_start(await cg.templatable(True, args, bool))); return var + +@automation.register_action("ccp.stop_daq", CcpDaqAction, cv.Schema(_HUB), synchronous=True) +async def ccp_stop_daq(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpDaqAction); cg.add(var.set_start(await cg.templatable(False, args, bool))); return var + +@automation.register_action("ccp.raw", CcpRawAction, cv.Schema({**_HUB, cv.Required(CONF_DATA): _BYTES}), synchronous=True) +async def ccp_raw(config, action_id, template_arg, args): return await _data_action(config, action_id, template_arg, args, CcpRawAction) + +@automation.register_action("ccp.set_enabled", CcpSetEnabledAction, cv.Schema({**_HUB, cv.Required(CONF_ENABLED): cv.templatable(cv.boolean)}), synchronous=True) +async def ccp_enabled(config, action_id, template_arg, args): + var = await _action(config, action_id, template_arg, CcpSetEnabledAction); cg.add(var.set_enabled(await cg.templatable(config[CONF_ENABLED], args, bool))); return var diff --git a/components/ccp/ccp.cpp b/components/ccp/ccp.cpp new file mode 100644 index 0000000..cedb019 --- /dev/null +++ b/components/ccp/ccp.cpp @@ -0,0 +1,324 @@ +// CCP 2.1 master implementation: one outstanding CRO, DTO response/DAQ routing, and memory helpers. +#include "ccp.h" + +#include + +namespace esphome::ccp { + +static const char *const TAG = "ccp"; + +void CcpHub::setup() { this->port_->subscribe_consumer(this->response_id_, false, this); } + +void CcpHub::loop() { + if (this->in_flight_ && static_cast(millis() - this->deadline_) >= 0) { + ESP_LOGW(TAG, "CCP 0x%03X response timeout for command 0x%02X", static_cast(this->response_id_), + this->in_flight_command_); + this->finish_(false, CCP_TIMEOUT, nullptr, 0); + } +} + +void CcpHub::dump_config() { + ESP_LOGCONFIG(TAG, "CCP master:"); + ESP_LOGCONFIG(TAG, " CRO: 0x%03X, DTO: 0x%03X, station: 0x%04X", static_cast(this->command_id_), + static_cast(this->response_id_), this->station_address_); + ESP_LOGCONFIG(TAG, " Byte order: %s, response timeout: %u ms, writes: %s", + this->byte_order_ == ByteOrder::LITTLE ? "little" : "big", + static_cast(this->response_timeout_), this->allow_write_ ? "enabled" : "disabled"); +} + +bool CcpHub::write_allowed_(uint8_t command) const { + if (!is_write_command(command)) + return true; +#ifdef USE_CCP_WRITE + if (this->allow_write_) + return true; +#endif + ESP_LOGW(TAG, "CCP write command 0x%02X refused: allow_write is false (or this binary was built without CCP writes)", + command); + return false; +} + +bool CcpHub::send_(Cro cro, CommandCallback callback) { + if (!this->enabled_) { + ESP_LOGW(TAG, "CCP command 0x%02X refused: component disabled", cro.data[0]); + return false; + } + if (!this->write_allowed_(cro.data[0])) + return false; + if (this->in_flight_) { + ESP_LOGW(TAG, "CCP command 0x%02X refused: command 0x%02X is still in flight", cro.data[0], + this->in_flight_command_); + return false; + } + const uint8_t ctr = this->ctr_++; + cro.data[1] = ctr; + if (!this->port_->inject(this->command_id_, false, false, cro.data, CCP_FRAME_LEN)) { + ESP_LOGW(TAG, "CCP command 0x%02X refused: CAN port cannot transmit", cro.data[0]); + return false; + } + this->in_flight_ = true; + this->in_flight_ctr_ = ctr; + this->in_flight_command_ = cro.data[0]; + this->deadline_ = millis() + this->response_timeout_; + this->command_callback_ = std::move(callback); + return true; +} + +void CcpHub::finish_(bool success, uint8_t code, const uint8_t *data, uint8_t len) { + const uint8_t command = this->in_flight_command_; + CommandCallback callback = std::move(this->command_callback_); + this->command_callback_ = {}; + this->in_flight_ = false; + if (success) { + std::vector payload(data, data + len); + this->response_callback_.call(command, code, payload); + if (command == CONNECT) { + this->connected_ = true; + this->connected_callback_.call(); + } + if (command == DISCONNECT) + this->connected_ = false; + } else { + this->error_callback_.call(command, code); + } + if (callback) + callback(success, code, data, len); +} + +void CcpHub::on_frame(const can_gateway::FrameView &frame) { + if (frame.extended || frame.rtr) + return; + const Dto dto = decode_dto(frame.data, frame.dlc); + if (dto.kind == DtoKind::DAQ) { + this->daq_callback_.call(dto.pid, std::vector(dto.data, dto.data + dto.data_len)); + return; + } + if (dto.kind != DtoKind::RESPONSE || !this->in_flight_ || !matches_response(dto, this->in_flight_ctr_)) + return; + this->finish_(dto.return_code == 0, dto.return_code, dto.data, dto.data_len); +} + +bool CcpHub::connect(uint16_t station) { + return this->send_(ccp::connect(0, station == 0 ? this->station_address_ : station, this->byte_order_)); +} +bool CcpHub::disconnect(bool end) { + return this->send_(ccp::disconnect(0, end, this->station_address_, this->byte_order_)); +} +bool CcpHub::get_ccp_version(uint8_t main, uint8_t release) { + Cro c = command(GET_CCP_VERSION, 0); + c.data[2] = main; + c.data[3] = release; + return this->send_(c); +} +bool CcpHub::exchange_id() { return this->send_(command(EXCHANGE_ID, 0)); } +bool CcpHub::get_seed(uint8_t resource) { + Cro c = command(GET_SEED, 0); + c.data[2] = resource; + return this->send_(c); +} +bool CcpHub::unlock(const uint8_t *key, uint8_t len) { + if (len > 6) + return false; + Cro c = command(UNLOCK, 0); + if (key != nullptr) + std::memcpy(c.data + 2, key, len); + return this->send_(c); +} +bool CcpHub::set_mta(uint8_t mta, uint8_t ext, uint32_t address) { + return this->send_(ccp::set_mta(0, mta, ext, address, this->byte_order_)); +} +bool CcpHub::dnload(const uint8_t *data, uint8_t len) { + if (len == 0 || len > CCP_MAX_TRANSFER) + return false; + return this->send_(ccp::dnload(0, data, len)); +} +bool CcpHub::dnload6(const uint8_t *data) { return data != nullptr && this->send_(ccp::dnload6(0, data)); } +bool CcpHub::upload(uint8_t size) { return size > 0 && size <= CCP_MAX_TRANSFER && this->send_(ccp::upload(0, size)); } +bool CcpHub::short_up(uint8_t size, uint8_t ext, uint32_t address) { + return size > 0 && size <= CCP_MAX_TRANSFER && this->send_(ccp::short_up(0, size, ext, address, this->byte_order_)); +} +bool CcpHub::select_cal_page() { return this->send_(command(SELECT_CAL_PAGE, 0)); } +bool CcpHub::get_active_cal_page() { return this->send_(command(GET_ACTIVE_CAL_PAGE, 0)); } +bool CcpHub::get_daq_size(uint8_t list, uint32_t dto_id) { + Cro c = command(GET_DAQ_SIZE, 0); + c.data[2] = list; + put_u32(c.data + 4, dto_id, this->byte_order_); + return this->send_(c); +} +bool CcpHub::set_daq_ptr(uint8_t list, uint8_t odt, uint8_t element) { + Cro c = command(SET_DAQ_PTR, 0); + c.data[2] = list; + c.data[3] = odt; + c.data[4] = element; + return this->send_(c); +} +bool CcpHub::write_daq(uint8_t size, uint8_t ext, uint32_t address) { + Cro c = command(WRITE_DAQ, 0); + c.data[2] = size; + c.data[3] = ext; + put_u32(c.data + 4, address, this->byte_order_); + return this->send_(c); +} +bool CcpHub::start_stop(uint8_t mode, uint8_t list, uint8_t last_odt, uint8_t event, uint16_t rate) { + Cro c = command(START_STOP, 0); + c.data[2] = mode; + c.data[3] = list; + c.data[4] = last_odt; + c.data[5] = event; + put_u16(c.data + 6, rate, ByteOrder::BIG); + return this->send_(c); +} +bool CcpHub::start_stop_all(uint8_t mode) { + Cro c = command(START_STOP_ALL, 0); + c.data[2] = mode; + return this->send_(c); +} +bool CcpHub::set_s_status(uint8_t status) { + Cro c = command(SET_S_STATUS, 0); + c.data[2] = status; + return this->send_(c); +} +bool CcpHub::get_s_status() { return this->send_(command(GET_S_STATUS, 0)); } +bool CcpHub::build_checksum(uint32_t size) { return this->send_(sized_u32(BUILD_CHKSUM, 0, size, this->byte_order_)); } +bool CcpHub::move(uint32_t size) { return this->send_(sized_u32(MOVE, 0, size, this->byte_order_)); } +bool CcpHub::test(uint16_t station) { + Cro c = command(TEST, 0); + put_u16(c.data + 2, station == 0 ? this->station_address_ : station, this->byte_order_); + return this->send_(c); +} +bool CcpHub::clear_memory() { return this->send_(command(CLEAR_MEMORY, 0)); } +bool CcpHub::program() { return this->send_(command(PROGRAM, 0)); } +bool CcpHub::program6(const uint8_t *data) { + Cro c = command(PROGRAM_6, 0); + if (data == nullptr) + return false; + std::memcpy(c.data + 2, data, 6); + return this->send_(c); +} +bool CcpHub::diag_service(const uint8_t *data, uint8_t len) { + if (len > 6) + return false; + Cro c = command(DIAG_SERVICE, 0); + if (data != nullptr) + std::memcpy(c.data + 2, data, len); + return this->send_(c); +} +bool CcpHub::action_service(const uint8_t *data, uint8_t len) { + if (len > 6) + return false; + Cro c = command(ACTION_SERVICE, 0); + if (data != nullptr) + std::memcpy(c.data + 2, data, len); + return this->send_(c); +} +bool CcpHub::raw(const uint8_t *cro, uint8_t len) { + if (cro == nullptr || len < 1 || len > CCP_FRAME_LEN) + return false; + Cro c{}; + std::memcpy(c.data, cro, len); + return this->send_(c); +} + +bool CcpHub::connect_and(std::function callback) { + if (this->connected_) { + callback(true); + return true; + } + this->connect_callback_ = std::move(callback); + return this->send_(ccp::connect(0, this->station_address_, this->byte_order_), + [this](bool ok, uint8_t, const uint8_t *, uint8_t) { + auto callback = std::move(this->connect_callback_); + this->connect_callback_ = {}; + if (callback) + callback(ok); + }); +} + +bool CcpHub::read_memory(uint8_t ext, uint32_t address, uint16_t len, + std::function)> callback) { + if (len == 0 || this->in_flight_ || this->read_remaining_ != 0) + return false; + this->read_callback_ = std::move(callback); + this->read_data_.clear(); + this->read_data_.reserve(len); + this->read_remaining_ = len; + return this->send_(ccp::set_mta(0, 0, ext, address, this->byte_order_), + [this](bool ok, uint8_t, const uint8_t *, uint8_t) { + if (!ok) { + this->fail_sequence_(); + return; + } + const uint8_t n = read_chunk(this->read_remaining_); + this->send_(ccp::upload(0, n), [this](bool success, uint8_t, const uint8_t *data, uint8_t len) { + this->continue_read_(success, data, len); + }); + }); +} +void CcpHub::continue_read_(bool success, const uint8_t *data, uint8_t len) { + if (!success || len < read_chunk(this->read_remaining_)) { + this->fail_sequence_(); + return; + } + const uint8_t n = read_chunk(this->read_remaining_); + this->read_data_.insert(this->read_data_.end(), data, data + n); + this->read_remaining_ -= n; + if (this->read_remaining_ == 0) { + auto callback = std::move(this->read_callback_); + this->read_callback_ = {}; + if (callback) + callback(this->read_data_); + this->read_data_.clear(); + return; + } + const uint8_t next = read_chunk(this->read_remaining_); + this->send_(ccp::upload(0, next), [this](bool ok, uint8_t, const uint8_t *response, uint8_t response_len) { + this->continue_read_(ok, response, response_len); + }); +} +bool CcpHub::write_memory(uint8_t ext, uint32_t address, const std::vector &data, + std::function callback) { + if (data.empty() || this->in_flight_ || !this->write_allowed_(DNLOAD)) + return false; + this->write_data_ = data; + this->write_offset_ = 0; + this->write_callback_ = std::move(callback); + return this->send_(ccp::set_mta(0, 0, ext, address, this->byte_order_), + [this](bool ok, uint8_t, const uint8_t *, uint8_t) { this->continue_write_(ok); }); +} +void CcpHub::continue_write_(bool success) { + if (!success) { + this->fail_sequence_(); + return; + } + if (this->write_offset_ == this->write_data_.size()) { + auto callback = std::move(this->write_callback_); + this->write_callback_ = {}; + this->write_data_.clear(); + if (callback) + callback(true); + return; + } + const size_t remaining = this->write_data_.size() - this->write_offset_; + const uint8_t n = remaining >= 6 ? 6 : static_cast(remaining); + Cro c = n == 6 ? ccp::dnload6(0, this->write_data_.data() + this->write_offset_) + : ccp::dnload(0, this->write_data_.data() + this->write_offset_, n); + this->write_offset_ += n; + this->send_(c, [this](bool ok, uint8_t, const uint8_t *, uint8_t) { this->continue_write_(ok); }); +} +void CcpHub::fail_sequence_() { + if (this->read_remaining_ != 0) { + this->read_remaining_ = 0; + this->read_data_.clear(); + this->read_callback_ = {}; + } + if (!this->write_data_.empty()) { + this->write_data_.clear(); + this->write_offset_ = 0; + auto callback = std::move(this->write_callback_); + this->write_callback_ = {}; + if (callback) + callback(false); + } +} + +} // namespace esphome::ccp diff --git a/components/ccp/ccp.h b/components/ccp/ccp.h new file mode 100644 index 0000000..ef113f7 --- /dev/null +++ b/components/ccp/ccp.h @@ -0,0 +1,223 @@ +// ESPHome CCP 2.1 master hub and YAML automation actions over can_gateway. +#pragma once + +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "../can_gateway/can_gateway.h" +#include "ccp_proto.h" + +namespace esphome::ccp { + +class CcpHub : public Component, public can_gateway::CanGatewayFrameConsumer { + public: + explicit CcpHub(can_gateway::GatewayPort *port) : port_(port) {} + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA - 1.0f; } + void set_command_id(uint32_t id) { command_id_ = id; } + void set_response_id(uint32_t id) { response_id_ = id; } + void set_station_address(uint16_t address) { station_address_ = address; } + void set_byte_order(ByteOrder order) { byte_order_ = order; } + void set_response_timeout(uint32_t timeout) { response_timeout_ = timeout; } + void set_allow_write(bool allow) { allow_write_ = allow; } + void set_enabled(bool enabled) { enabled_ = enabled; } + bool is_connected() const { return connected_; } + bool is_enabled() const { return enabled_; } + + bool connect(uint16_t station = 0); + bool disconnect(bool end = false); + bool get_ccp_version(uint8_t desired_main = 2, uint8_t desired_release = 1); + bool exchange_id(); + bool get_seed(uint8_t resource); + bool unlock(const uint8_t *key, uint8_t len); + bool set_mta(uint8_t mta, uint8_t ext, uint32_t address); + bool dnload(const uint8_t *data, uint8_t len); + bool dnload6(const uint8_t *data); + bool upload(uint8_t size); + bool short_up(uint8_t size, uint8_t ext, uint32_t address); + bool select_cal_page(); + bool get_active_cal_page(); + bool get_daq_size(uint8_t list, uint32_t dto_id); + bool set_daq_ptr(uint8_t list, uint8_t odt, uint8_t element); + bool write_daq(uint8_t size, uint8_t ext, uint32_t address); + bool start_stop(uint8_t mode, uint8_t list, uint8_t last_odt, uint8_t event, uint16_t rate); + bool start_stop_all(uint8_t mode); + bool set_s_status(uint8_t status); + bool get_s_status(); + bool build_checksum(uint32_t size); + bool move(uint32_t size); + bool test(uint16_t station = 0); + bool clear_memory(); + bool program(); + bool program6(const uint8_t *data); + bool diag_service(const uint8_t *data, uint8_t len); + bool action_service(const uint8_t *data, uint8_t len); + bool raw(const uint8_t *cro, uint8_t len); + bool read_memory(uint8_t ext, uint32_t address, uint16_t len, std::function)> callback); + bool write_memory(uint8_t ext, uint32_t address, const std::vector &data, + std::function callback = {}); + bool connect_and(std::function callback); + void on_frame(const can_gateway::FrameView &frame) override; + + template void add_on_connected_callback(F &&callback) { + connected_callback_.add(std::forward(callback)); + } + template void add_on_response_callback(F &&callback) { + response_callback_.add(std::forward(callback)); + } + template void add_on_error_callback(F &&callback) { error_callback_.add(std::forward(callback)); } + template void add_on_daq_callback(F &&callback) { daq_callback_.add(std::forward(callback)); } + + protected: + using CommandCallback = std::function; + bool send_(Cro cro, CommandCallback callback = {}); + bool write_allowed_(uint8_t command) const; + void finish_(bool success, uint8_t code, const uint8_t *data, uint8_t len); + void fail_sequence_(); + void continue_read_(bool success, const uint8_t *data, uint8_t len); + void continue_write_(bool success); + can_gateway::GatewayPort *port_; + uint32_t command_id_{0x700}; + uint32_t response_id_{0x701}; + uint16_t station_address_{0x0001}; + ByteOrder byte_order_{ByteOrder::LITTLE}; + uint32_t response_timeout_{100}; + bool allow_write_{false}; + bool enabled_{true}; + bool connected_{false}; + bool in_flight_{false}; + uint8_t ctr_{0}; + uint8_t in_flight_ctr_{0}; + uint8_t in_flight_command_{0}; + uint32_t deadline_{0}; + CommandCallback command_callback_{}; + std::function)> read_callback_{}; + std::vector read_data_{}; + uint16_t read_remaining_{0}; + std::vector write_data_{}; + size_t write_offset_{0}; + std::function write_callback_{}; + std::function connect_callback_{}; + LazyCallbackManager connected_callback_; + LazyCallbackManager)> response_callback_; + LazyCallbackManager error_callback_; + LazyCallbackManager)> daq_callback_; +}; + +template class CcpConnectAction : public Action, public Parented { + public: + void play(const Ts &...) override { this->parent_->connect(); } +}; +template class CcpDisconnectAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, end) void play(const Ts &...x) override { this->parent_->disconnect(this->end_.value(x...)); } +}; +template class CcpGetVersionAction : public Action, public Parented { + public: + void play(const Ts &...) override { this->parent_->get_ccp_version(); } +}; +template class CcpExchangeIdAction : public Action, public Parented { + public: + void play(const Ts &...) override { this->parent_->exchange_id(); } +}; +template class CcpSetMtaAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, mta) + TEMPLATABLE_VALUE(uint8_t, ext) TEMPLATABLE_VALUE(uint32_t, address) void play(const Ts &...x) override { + this->parent_->set_mta(this->mta_.value(x...), this->ext_.value(x...), this->address_.value(x...)); + } +}; +template class CcpUploadAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, size) void play(const Ts &...x) override { + this->parent_->upload(this->size_.value(x...)); + } +}; +template class CcpShortUploadAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, size) + TEMPLATABLE_VALUE(uint8_t, ext) TEMPLATABLE_VALUE(uint32_t, address) void play(const Ts &...x) override { + this->parent_->short_up(this->size_.value(x...), this->ext_.value(x...), this->address_.value(x...)); + } +}; +template class CcpDownloadAction : public Action, public Parented { + public: + void set_data_static(const std::vector &data) { data_ = data; } + void set_data_template(std::function(Ts...)> func) { + func_ = std::move(func); + templated_ = true; + } + void play(const Ts &...x) override { + const auto data = templated_ ? func_(x...) : data_; + this->parent_->dnload(data.data(), data.size()); + } + + protected: + bool templated_{false}; + std::function(Ts...)> func_{}; + std::vector data_{}; +}; +template class CcpSelectCalPageAction : public Action, public Parented { + public: + void play(const Ts &...) override { this->parent_->select_cal_page(); } +}; +template class CcpReadMemoryAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, ext) + TEMPLATABLE_VALUE(uint32_t, address) TEMPLATABLE_VALUE(uint16_t, length) void play(const Ts &...x) override { + this->parent_->read_memory(this->ext_.value(x...), this->address_.value(x...), this->length_.value(x...), {}); + } +}; +template class CcpWriteMemoryAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, ext) + TEMPLATABLE_VALUE(uint32_t, address) void set_data_static(const std::vector &data) { data_ = data; } + void set_data_template(std::function(Ts...)> func) { + func_ = std::move(func); + templated_ = true; + } + void play(const Ts &...x) override { + this->parent_->write_memory(this->ext_.value(x...), this->address_.value(x...), templated_ ? func_(x...) : data_); + } + + protected: + bool templated_{false}; + std::function(Ts...)> func_{}; + std::vector data_{}; +}; +template class CcpDaqAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, start) void play(const Ts &...x) override { + this->parent_->start_stop_all(this->start_.value(x...) ? 1 : 0); + } +}; +template class CcpRawAction : public Action, public Parented { + public: + void set_data_static(const std::vector &data) { data_ = data; } + void set_data_template(std::function(Ts...)> func) { + func_ = std::move(func); + templated_ = true; + } + void play(const Ts &...x) override { + const auto data = templated_ ? func_(x...) : data_; + this->parent_->raw(data.data(), data.size()); + } + + protected: + bool templated_{false}; + std::function(Ts...)> func_{}; + std::vector data_{}; +}; +template class CcpSetEnabledAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, enabled) void play(const Ts &...x) override { + this->parent_->set_enabled(this->enabled_.value(x...)); + } +}; + +} // namespace esphome::ccp diff --git a/components/ccp/ccp_proto.h b/components/ccp/ccp_proto.h new file mode 100644 index 0000000..1cd3972 --- /dev/null +++ b/components/ccp/ccp_proto.h @@ -0,0 +1,196 @@ +// CCP 2.1 single-frame CRO/DTO codec, intentionally independent of ESPHome for host tests. +#pragma once + +#include +#include +#include + +namespace esphome::ccp { + +constexpr uint8_t CCP_FRAME_LEN = 8; +constexpr uint8_t CCP_RESPONSE_MARKER = 0xFF; +constexpr uint8_t CCP_TIMEOUT = 0xFE; +constexpr uint8_t CCP_MAX_TRANSFER = 5; + +enum class ByteOrder : uint8_t { LITTLE, BIG }; +enum Command : uint8_t { + CONNECT = 0x01, + SET_MTA = 0x02, + DNLOAD = 0x03, + UPLOAD = 0x04, + TEST = 0x05, + START_STOP = 0x06, + DISCONNECT = 0x07, + START_STOP_ALL = 0x08, + GET_ACTIVE_CAL_PAGE = 0x09, + SET_S_STATUS = 0x0C, + GET_S_STATUS = 0x0D, + BUILD_CHKSUM = 0x0E, + SHORT_UP = 0x0F, + CLEAR_MEMORY = 0x10, + SELECT_CAL_PAGE = 0x11, + GET_SEED = 0x12, + UNLOCK = 0x13, + GET_DAQ_SIZE = 0x14, + SET_DAQ_PTR = 0x15, + WRITE_DAQ = 0x16, + EXCHANGE_ID = 0x17, + PROGRAM = 0x18, + MOVE = 0x19, + GET_CCP_VERSION = 0x1B, + DIAG_SERVICE = 0x20, + ACTION_SERVICE = 0x21, + PROGRAM_6 = 0x22, + DNLOAD_6 = 0x23, +}; + +struct Cro { + uint8_t data[CCP_FRAME_LEN]{}; +}; + +inline void put_u16(uint8_t *out, uint16_t value, ByteOrder order) { + if (order == ByteOrder::LITTLE) { + out[0] = static_cast(value); + out[1] = static_cast(value >> 8); + } else { + out[0] = static_cast(value >> 8); + out[1] = static_cast(value); + } +} +inline void put_u32(uint8_t *out, uint32_t value, ByteOrder order) { + for (uint8_t i = 0; i < 4; i++) { + const uint8_t shift = order == ByteOrder::LITTLE ? i : static_cast(3 - i); + out[i] = static_cast(value >> (8 * shift)); + } +} +inline uint32_t get_u32(const uint8_t *in, ByteOrder order) { + uint32_t value = 0; + for (uint8_t i = 0; i < 4; i++) { + const uint8_t shift = order == ByteOrder::LITTLE ? i : static_cast(3 - i); + value |= static_cast(in[i]) << (8 * shift); + } + return value; +} + +inline Cro command(uint8_t code, uint8_t ctr) { + Cro out{}; + out.data[0] = code; + out.data[1] = ctr; + return out; +} +inline Cro connect(uint8_t ctr, uint16_t station, ByteOrder order) { + Cro out = command(CONNECT, ctr); + put_u16(out.data + 2, station, order); + return out; +} +inline Cro disconnect(uint8_t ctr, bool end, uint16_t station, ByteOrder order) { + Cro out = command(DISCONNECT, ctr); + out.data[2] = end ? 1 : 0; + put_u16(out.data + 4, station, order); + return out; +} +inline Cro set_mta(uint8_t ctr, uint8_t mta, uint8_t ext, uint32_t address, ByteOrder order) { + Cro out = command(SET_MTA, ctr); + out.data[2] = mta; + out.data[3] = ext; + put_u32(out.data + 4, address, order); + return out; +} +inline Cro upload(uint8_t ctr, uint8_t size) { + Cro out = command(UPLOAD, ctr); + out.data[2] = size; + return out; +} +inline Cro short_up(uint8_t ctr, uint8_t size, uint8_t ext, uint32_t address, ByteOrder order) { + Cro out = command(SHORT_UP, ctr); + out.data[2] = size; + out.data[3] = ext; + put_u32(out.data + 4, address, order); + return out; +} +inline Cro dnload(uint8_t ctr, const uint8_t *data, uint8_t size) { + Cro out = command(DNLOAD, ctr); + out.data[2] = size; + if (data != nullptr && size <= CCP_MAX_TRANSFER) + std::memcpy(out.data + 3, data, size); + return out; +} +inline Cro dnload6(uint8_t ctr, const uint8_t *data) { + Cro out = command(DNLOAD_6, ctr); + if (data != nullptr) + std::memcpy(out.data + 2, data, 6); + return out; +} +inline Cro sized_u32(uint8_t code, uint8_t ctr, uint32_t size, ByteOrder order) { + Cro out = command(code, ctr); + put_u32(out.data + 2, size, order); + return out; +} + +enum class DtoKind : uint8_t { INVALID, RESPONSE, DAQ }; +struct Dto { + DtoKind kind{DtoKind::INVALID}; + uint8_t return_code{0}; + uint8_t ctr{0}; + uint8_t pid{0}; + const uint8_t *data{nullptr}; + uint8_t data_len{0}; +}; +inline Dto decode_dto(const uint8_t *data, uint8_t len) { + Dto out{}; + if (data == nullptr || len == 0) + return out; + if (data[0] != CCP_RESPONSE_MARKER) { + out.kind = DtoKind::DAQ; + out.pid = data[0]; + out.data = data + 1; + out.data_len = static_cast(len - 1); + return out; + } + if (len < 3) + return out; + out.kind = DtoKind::RESPONSE; + out.return_code = data[1]; + out.ctr = data[2]; + out.data = data + 3; + out.data_len = static_cast(len - 3); + return out; +} +inline bool matches_response(const Dto &dto, uint8_t ctr) { return dto.kind == DtoKind::RESPONSE && dto.ctr == ctr; } +inline bool is_write_command(uint8_t code) { + return code == DNLOAD || code == DNLOAD_6 || code == WRITE_DAQ || code == MOVE || code == CLEAR_MEMORY || + code == PROGRAM || code == PROGRAM_6; +} +inline const char *return_code_name(uint8_t code) { + switch (code) { + case 0x00: + return "acknowledge"; + case 0x01: + return "daq processor overload"; + case 0x10: + return "command processor busy"; + case 0x11: + return "internal timeout"; + case 0x12: + return "other"; + case 0x30: + return "unknown command"; + case 0x31: + return "command syntax"; + case 0x32: + return "parameter out of range"; + case 0x33: + return "access denied"; + case 0x34: + return "overload"; + case 0x35: + return "overload"; + default: + return "unknown return code"; + } +} +inline uint8_t read_chunk(uint16_t remaining) { + return remaining > CCP_MAX_TRANSFER ? CCP_MAX_TRANSFER : static_cast(remaining); +} + +} // namespace esphome::ccp diff --git a/tests/build/ccp/.gitignore b/tests/build/ccp/.gitignore new file mode 100644 index 0000000..d8b4157 --- /dev/null +++ b/tests/build/ccp/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/tests/build/ccp/common.yaml b/tests/build/ccp/common.yaml new file mode 100644 index 0000000..42c7c68 --- /dev/null +++ b/tests/build/ccp/common.yaml @@ -0,0 +1,48 @@ +# CCP action and trigger compile fixture; actions live in a script so boot remains observe-first. +can_gateway: + id: diag + ports: + - id: diag_bus + rx_pin: GPIO3 + tx_pin: GPIO2 + bit_rate: 500kbps + observe_queue_depth: 32 + +ccp: + id: bms_ccp + can_gateway_id: diag_bus + command_id: 0x700 + response_id: 0x701 + station_address: 0x0001 + byte_order: little + response_timeout: 100ms + on_connected: + - logger.log: "CCP connected" + on_response: + - logger.log: + format: "CCP 0x%02X rc=0x%02X (%u bytes)" + args: [command, return_code, data.size()] + on_error: + - logger.log: + format: "CCP error 0x%02X rc=0x%02X" + args: [command, return_code] + on_daq: + - logger.log: + format: "CCP DAQ PID 0x%02X (%u bytes)" + args: [pid, data.size()] + +script: + - id: exercise_ccp + then: + - ccp.connect: {id: bms_ccp} + - ccp.get_version: {id: bms_ccp} + - ccp.exchange_id: {id: bms_ccp} + - ccp.set_mta: {id: bms_ccp, mta: 0, ext: 0, address: 0x10203040} + - ccp.upload: {id: bms_ccp, size: 5} + - ccp.short_upload: {id: bms_ccp, size: 2, ext: 0, address: 0x10203040} + - ccp.select_cal_page: {id: bms_ccp} + - ccp.read_memory: {id: bms_ccp, ext: 0, address: 0x10203040, length: 12} + - ccp.start_daq: {id: bms_ccp} + - ccp.stop_daq: {id: bms_ccp} + - ccp.raw: {id: bms_ccp, data: [0x1B]} + - ccp.set_enabled: {id: bms_ccp, enabled: true} diff --git a/tests/build/ccp/common_write.yaml b/tests/build/ccp/common_write.yaml new file mode 100644 index 0000000..be6f6e1 --- /dev/null +++ b/tests/build/ccp/common_write.yaml @@ -0,0 +1,14 @@ +# CCP write fixture: explicit opt-in compiles the write-only implementation and actions. +packages: + common: !include common.yaml + +ccp: + id: bms_ccp + can_gateway_id: diag_bus + allow_write: true + +script: + - id: exercise_ccp_write + then: + - ccp.download: {id: bms_ccp, data: [0x01, 0x02, 0x03]} + - ccp.write_memory: {id: bms_ccp, ext: 0, address: 0x10203040, data: [1, 2, 3, 4, 5, 6, 7]} diff --git a/tests/build/ccp/test-write.esp32-c6-idf.yaml b/tests/build/ccp/test-write.esp32-c6-idf.yaml new file mode 100644 index 0000000..365a523 --- /dev/null +++ b/tests/build/ccp/test-write.esp32-c6-idf.yaml @@ -0,0 +1,14 @@ +# ESP32-C6 CCP write-enabled config wrapper. +esphome: + name: ccp-write-c6 +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf +logger: +external_components: + - source: + type: local + path: ../../../components +packages: + ccp: !include common_write.yaml diff --git a/tests/build/ccp/test.esp32-c6-idf.yaml b/tests/build/ccp/test.esp32-c6-idf.yaml new file mode 100644 index 0000000..509bb05 --- /dev/null +++ b/tests/build/ccp/test.esp32-c6-idf.yaml @@ -0,0 +1,14 @@ +# ESP32-C6 CCP config wrapper. +esphome: + name: ccp-c6 +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf +logger: +external_components: + - source: + type: local + path: ../../../components +packages: + ccp: !include common.yaml diff --git a/tests/ccp/__init__.py b/tests/ccp/__init__.py new file mode 100644 index 0000000..f17ce3e --- /dev/null +++ b/tests/ccp/__init__.py @@ -0,0 +1 @@ +# CCP schema and code-generation tests package. diff --git a/tests/ccp/common.py b/tests/ccp/common.py new file mode 100644 index 0000000..c7aba8a --- /dev/null +++ b/tests/ccp/common.py @@ -0,0 +1,22 @@ +# Shared CCP schema test helpers. +from __future__ import annotations + +from typing import Any + +from esphome.components.esp32.const import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32C6 +import esphome.config_validation as cv +from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework + + +def setup_c6(set_core_config) -> None: + set_core_config(PlatformFramework.ESP32_IDF, core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 4)}, platform_data={KEY_BOARD: "esp32-c6-devkitc-1", KEY_VARIANT: VARIANT_ESP32C6}) + + +def hub(**overrides: Any) -> dict[str, Any]: + value = {"id": "bms_ccp", "can_gateway_id": "diag_bus", **overrides} + return {key: item for key, item in value.items() if item is not None} + + +def validate(value: dict[str, Any]): + from esphome.components.ccp import CONFIG_SCHEMA + return CONFIG_SCHEMA(value) diff --git a/tests/ccp/test_schema.py b/tests/ccp/test_schema.py new file mode 100644 index 0000000..2e2d28a --- /dev/null +++ b/tests/ccp/test_schema.py @@ -0,0 +1,59 @@ +# CCP schema, action-registration, and externally-emitted define coverage. +"""CCP hub schema, action registration, and externally-emitted observe/write defines.""" +from __future__ import annotations + +import ast +import inspect + +import pytest +from esphome import automation, config_validation as cv + +from .common import hub, setup_c6, validate + + +def test_defaults_are_observe_first_and_little_endian(set_core_config) -> None: + setup_c6(set_core_config) + config = validate(hub()) + assert config["command_id"] == 0x700 + assert config["response_id"] == 0x701 + assert config["station_address"] == 0x0001 + assert config["byte_order"] == "little" + assert config["allow_write"] is False + assert config["response_timeout"].total_milliseconds == 100 + + +@pytest.mark.parametrize("key", ["id", "can_gateway_id"]) +def test_required_keys_rejected(set_core_config, key: str) -> None: + setup_c6(set_core_config) + with pytest.raises(cv.Invalid, match="required key not provided"): + validate(hub(**{key: None})) + + +@pytest.mark.parametrize("key", ["command_id", "response_id"]) +@pytest.mark.parametrize("value", [-1, 0x800, 0x123456]) +def test_extended_or_bad_ids_rejected(set_core_config, key: str, value: int) -> None: + setup_c6(set_core_config) + with pytest.raises(cv.Invalid): + validate(hub(**{key: value})) + + +@pytest.mark.parametrize("value", ["network", "Intel", 3]) +def test_invalid_byte_order_rejected(set_core_config, value) -> None: + setup_c6(set_core_config) + with pytest.raises(cv.Invalid): + validate(hub(byte_order=value)) + + +def test_actions_are_registered() -> None: + expected = {"ccp.connect", "ccp.disconnect", "ccp.get_version", "ccp.exchange_id", "ccp.set_mta", "ccp.upload", "ccp.short_upload", "ccp.download", "ccp.select_cal_page", "ccp.read_memory", "ccp.write_memory", "ccp.start_daq", "ccp.stop_daq", "ccp.raw", "ccp.set_enabled"} + assert expected <= set(automation.ACTION_REGISTRY) + + +def test_codegen_emits_observe_and_configured_write_defines() -> None: + import esphome.components.ccp as ccp + + tree = ast.parse(inspect.getsource(ccp)) + fn = next(node for node in tree.body if isinstance(node, ast.AsyncFunctionDef) and node.name == "to_code") + defines = [node.args[0].value for node in ast.walk(fn) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "add_define" and node.args and isinstance(node.args[0], ast.Constant)] + assert "USE_CAN_GATEWAY_OBSERVE" in defines + assert "USE_CCP_WRITE" in defines diff --git a/tests/host/test_ccp_proto.cpp b/tests/host/test_ccp_proto.cpp new file mode 100644 index 0000000..658f1cd --- /dev/null +++ b/tests/host/test_ccp_proto.cpp @@ -0,0 +1,66 @@ +// CCP CRO/DTO protocol cases, including firmware-verified Intel address order and read chunk planning. +#include "../../components/ccp/ccp_proto.h" +#include "harness.h" + +#include + +using namespace esphome::ccp; + +TEST(ccp_proto_connect_and_set_mta_are_little_endian) { + const Cro connect_frame = connect(0x12, 0x1234, ByteOrder::LITTLE); + const uint8_t expected_connect[] = {CONNECT, 0x12, 0x34, 0x12, 0, 0, 0, 0}; + CHECK_BYTES(connect_frame.data, expected_connect, CCP_FRAME_LEN); + const Cro mta = set_mta(0x13, 0, 0, 0x10203040, ByteOrder::LITTLE); + const uint8_t expected_mta[] = {SET_MTA, 0x13, 0, 0, 0x40, 0x30, 0x20, 0x10}; + CHECK_BYTES(mta.data, expected_mta, CCP_FRAME_LEN); +} + +TEST(ccp_proto_transfer_encoders) { + const Cro up = upload(2, 5); + CHECK_EQ(up.data[0], UPLOAD); + CHECK_EQ(up.data[1], 2); + CHECK_EQ(up.data[2], 5); + const Cro short_read = short_up(3, 2, 1, 0x10203040, ByteOrder::LITTLE); + const uint8_t expected_short[] = {SHORT_UP, 3, 2, 1, 0x40, 0x30, 0x20, 0x10}; + CHECK_BYTES(short_read.data, expected_short, CCP_FRAME_LEN); + const uint8_t write[] = {1, 2, 3, 4, 5}; + const Cro down = dnload(4, write, 5); + const uint8_t expected_down[] = {DNLOAD, 4, 5, 1, 2, 3, 4, 5}; + CHECK_BYTES(down.data, expected_down, CCP_FRAME_LEN); +} + +TEST(ccp_proto_decodes_response_matches_counter_and_routes_daq) { + const uint8_t response[] = {0xFF, 0x00, 0x42, 1, 2, 3, 4, 5}; + const Dto dto = decode_dto(response, sizeof(response)); + CHECK_EQ(dto.kind, DtoKind::RESPONSE); + CHECK_EQ(dto.return_code, 0); + CHECK_EQ(dto.ctr, 0x42); + CHECK_EQ(dto.data_len, 5); + CHECK(matches_response(dto, 0x42)); + CHECK(!matches_response(dto, 0x43)); + const uint8_t daq[] = {0x01, 9, 8, 7}; + const Dto sample = decode_dto(daq, sizeof(daq)); + CHECK_EQ(sample.kind, DtoKind::DAQ); + CHECK_EQ(sample.pid, 1); + CHECK_EQ(sample.data_len, 3); + const uint8_t runt[] = {0xFF, 0x00}; + CHECK_EQ(decode_dto(runt, sizeof(runt)).kind, DtoKind::INVALID); +} + +TEST(ccp_proto_return_codes_and_write_classification) { + CHECK(std::string(return_code_name(0x00)) == "acknowledge"); + CHECK(std::string(return_code_name(0x33)) == "access denied"); + CHECK(is_write_command(DNLOAD)); + CHECK(is_write_command(PROGRAM_6)); + CHECK(!is_write_command(UPLOAD)); +} + +TEST(ccp_proto_read_memory_chunks_twelve_bytes_as_five_five_two) { + uint16_t remaining = 12; + const uint8_t expected[] = {5, 5, 2}; + for (uint8_t n : expected) { + CHECK_EQ(read_chunk(remaining), n); + remaining -= n; + } + CHECK_EQ(remaining, 0); +} From 381f210c2d9fcd3aa59af16d9b6806e5f8617da7 Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 13 Sep 2026 19:16:57 +0200 Subject: [PATCH 2/3] ci: cover the new ccp component in the compile matrix Both ccp build fixtures (default read-only, allow_write:true) now compile in CI alongside the rest of the matrix, so a ccp change is gated the same as every other component before it can reach main. Claude-Session: https://claude.ai/code/session_01JpmPzXhUDovTMWBiNMzb89 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be1658f..6bc7052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,8 @@ jobs: # partition, so it proves codegen requests the partition exactly once # — add_partition() refuses a duplicate name. - tests/build/uds/test-partition.esp32-c6-idf.yaml + - tests/build/ccp/test.esp32-c6-idf.yaml + - tests/build/ccp/test-write.esp32-c6-idf.yaml steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From 8500eba3fb323bb83582ce55d69c3f441d65e749 Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 13 Sep 2026 19:17:40 +0200 Subject: [PATCH 3/3] ci: add a single required-checks gate job Branch protection on main will require this one context rather than every individual compile-matrix leg by name, so renaming or adding a build fixture never requires touching the ruleset. Claude-Session: https://claude.ai/code/session_01JpmPzXhUDovTMWBiNMzb89 --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc7052..addcca4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,3 +113,19 @@ jobs: pip install clang-format==13.0.1 find components -name '*.cpp' -o -name '*.h' -o -name '*.tcc' | \ xargs clang-format --dry-run --Werror + + required-checks: + name: Required checks + if: always() + needs: [validate, host-tests, compile, clang-format] + runs-on: ubuntu-latest + steps: + # One stable context for branch protection to require, instead of + # pinning to every individual compile-matrix leg by name — a build + # fixture can be renamed or added without touching the ruleset. + - name: All required jobs passed + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" || "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more required jobs did not succeed." + exit 1 + fi