Skip to content

vmichal/CANEPP

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

44 Commits
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CANEPP - CAN External Parameterization & Procedures

Written by VojtΔ›ch Michal (Discord vojtechmichal), 2025.

CANEPP enables fast and simple parameterization of eForce ECUs without the need to recompile and flash the ECU firmware. Supports getting and setting values (int, unsigned, float, bool, enums) of objects exposed by firmware through automatic enumeration mechanism. Supports execution of int(*)() functions. Objects can be stored in nonvolatile memory.

Further resources:

πŸ“– Key CANEPP facts

  • CANEPP wraps objects around various parameters, measurements or procedures.
    • Parameters provide control over input mapping, decision thresholds (state machines, safety checks), controller parameters etc.
    • Measurements provide simple access to raw sensor readings e.g. for calibration and initial ECU testing.
    • Procedures enable performing specific actions, e.g. bypassing missing trigger conditions during debugging or starting a calibration sequence.
  • The CAN protocol provides point to point communication between a single slave (the target ECU) and a master (currently only a PC with cantools).
    • Multiple slaves may be present on the same CAN bus without interference as each is identified by a unique enumerator from https://eforce1.feld.cvut.cz/candb-dev/enum-types/572
    • Multiple masters can theoretically be on the same CAN bus as long as no message clashes occur but this is not an intended use case.
  • The CAN communication relies solely on three messages:
    • Request - from master to slave, initiates all communication transactions. Identifies a specific operation to perform and a target object (not needed for "global" operations such as writing object values to ECU flash).
    • Response - from slave to master, carries the result of performed operation (object value, error code etc).
    • Manifest - from slave to master, contains packetized object manifest, i.e. all information about the given object (type, name, descriptions, access methods...)
  • All CANEPP objects are defined purely in ECU firmware, there are no databases like CANdb to manage by the master.
    • Before performing any meaningful operation with the slave, the master must first enumerate the slave's objects.
    • During the enumeration process, the number of CANEPP objects exposed by the slave is requested first, followed by individual object manifests.
    • To save communication bandwidth, all CAN messages refer to objects by object ID consisting of integer index and subindex (i.e. array offset).
    • Mapping of object IDs in the slave is determined by the order of static constructors (determined by implementation details of the toolchain) and hence can never be relied on. The master obtains its copy of object ID mapping during object enumeration.
    • Compiling and flashing new binary may (and most likely will) invalidate current object ID mapping and hence will require re-enumeration.
  • Each CANEPP object represents either a quantity or a procedure:
    • Procedures are executable only and do not accept any parameters at the moment (this may change in the future).
    • Quantities can be scalars or single dimensional arrays:
      • Arrays define non-negative size, individual elements are accessible using object subindex.
    • Quantities are of bool, enum or numeric type:
      • All CANEPP types exposed to the user preserve strong typing and work with int, unsigned, float, bool or user-defined enum or enum class.
      • CAN messages Request and Response have room for a single 32 bit unsigned int value. The library code uses memcpy to convert between individual data types and their underlying bits.
  • Each object has:
    • a user-friendly name that acts as the primary identifier. Dots in the name naturally create nested parameter structures that are fully transparent, i.e. they are only used for sorting and visualization but have no semantic significance.
    • a short and (optionally) long description. Short description is always shown in the GUI, the long desc. is shown as tooltip.
    • a set of function pointers that define the object's behavior (see later).
    • (numeric object only) a physical unit such as V or mA
    • (numeric object only) optional upper and lower bounds on the object's value. Any attempt to write a value outside of provided bounds is ignored.
    • (enum object only) an enumerator documentation, i.e. a list of valid values of said enumeration with name, numeric value and short description.
  • CANEPP quantity objects (i.e. other than procedures) wrap various function pointers:
    • get - the only one required.
    • set - omitted for read-only objects such as measurements. Providing a setter makes the object writeable and provides extra functionality.
    • get/set size - for arrays only
  • Values of writeable (i.e. with defined setter) objects can be written to ECU flash to preserve e.g. calibration for future use. These values are loaded early in main (but not before static constructors) and they overwrite default initializer provided by the source code.
  • Values of all writeable objects can be saved to JSON file as a snapshot of ECU configuration. This snapshot can be later loaded from JSON file and written to the ECU for quick and simple complete reparametrization.
    • See the documentation of CANEPP GUI (when I create it) for more details.

βš™οΈ CANEPP library setup

Whenever unsure, check my (VoMi's) reference implementations in AMS, Disruptor or PDL (the following guide is based on PDL implementation). Do not worry to ask on Discord πŸ™‹.

Make sure you meet these requirements: - You are able to modify your linker script. - UFSEL is already a submodule of yours and you use UFSEL/Assertion, UFSEL/BitOperations, UFSEL/Math, UFSEL/Memory and UFSEL/Traits.

  1. Add this repository as submodule to the root of your firmware. Unless you have very specific needs, place the submodule directory into the repository root. To ensure the submodule is found by CI, either manually edit .gitmodules to use relative path or specify GIT_SUBMODULE_FORCE_HTTPS: "true" in your .gitlab-ci.yml (see Gitlab Docs)
$ cd ecu-sw # The root of firmware repository where CMakeLists.txt is located
$ git submodule add ssh://[email protected]:2020/fse/canepp.git

# If needed, manually edit .gitmodules and change path to relative like this:
# [submodule "canepp"]
#    path = canepp
#    url = ../../fse/canepp.git

CHECKPOINT: You may commit and push the repository. If your CI pipeline does not fail when cloning the submodule, you have done it correctly.

  1. Modify CANdb (always prefer candb-dev).
    In case your unit does not normally transmit CAN messages, lacks CANdb record or there is anything else that would hinder you from proceeding, consult it with someone experienced.
    1. Make sure the list of Buses on the overview page of Accessory::Canepp link contains all buses listed in the overview page of formula generation (package) your unit belongs to. For example when adding CANEPP to a unit affiliated with CTU25, make sure that the bootloader contains reference to both CTU25 - CAN1 and CTU25 - CAN2.
    2. Add message Request owned by Accessory::Canepp to messages received by your unit and add Response and Manifest to the list of sent messages.
    3. CHECKPOINT: Generate C code for your unit and check that header/source files contain the definition of Canepp_Response, Canepp_Manifest and Canepp_Request. Generate JSON v2 for your package and check that it contains the unit Accessory::Canepp.
    4. Manually update the CANdb generated code to send messages Canepp_Response and Canepp_Manifest to the bus where we last received Canepp_Request. You should change 4 locations as follows:
int Canepp_send_Response_s(const Canepp_Response_t* data) {
		 // ...
		 int rc = txSendCANMessage(Canepp_Request_get_rx_bus(), Canepp_Response_id, buffer, sizeof(buffer));
}
  1. Modify your CMakeLists.txt: add canepp/src/canepp.cpp to the list of compiled sources for the project and canepp/include to include paths.
# CMakeLists.txt

set(SRC
	canepp/src/canepp.cpp

	# ... Other sources
	# bootloader/API/BLdriver.cpp

	# CANdb/can_PDL.c

	# PDL/main.cpp
	# BSP/gpio.cpp
	# ... Other sources

	)

# ... Other include directories
# include_directories(SYSTEM .)
# include_directories(SYSTEM CANdb)
include_directories(SYSTEM canepp/include)
  1. Modify (or create if it does not exist) your configuration file (recommended name config.hpp) to provide constants listed below to CANEPP for use. This file should also include the CANdb-generated can_XXX.h that contains definitions of messages such as Canepp_Request.
// config.hpp

//Configuration of various submodules and libraries.
#pragma once

#include <ufsel/assert.hpp> // for assert
#include <ufsel/units.hpp> // for KiB literal
#include <can_PDL.h> // Brings Canepp messages

#include <PDL/options.hpp> // Brings debug_printf_0 to scope

namespace config {

	// Configuration of other libraries (e.g. xalloc)

	namespace canepp {

		#define CANEPP_USE_COMMON_OBJECTS 1
	
		constexpr auto unit_identifier = Canepp_ECU_PDL;
		constexpr auto nonvolatile_storage_size = 8_KiB;
		// Write granularity of flash memory. 8 B for STM32G4, 16 B for STM32H7
		constexpr auto nonvolatile_storage_granularity = 8_B;
		// Controls the verbosity of prints.
		// Prefer this reduced verbosity (i.e. only print on set and execute) unless you are debugging
		constexpr bool print_manifest_on_creation = false;
		constexpr bool print_manifest_on_creation_full = false;
		constexpr bool print_value_on_get = false;
		constexpr bool print_value_on_set = true;
		constexpr bool print_size_on_get = false;
		constexpr bool print_size_on_set = true;
		constexpr bool print_on_execute = true;
		
		#define canepp_printf debug_printf_0
			
	} // end namespace canepp

} // end namespace config
  1. Modify the CMake toolchain file(s) (i.e. files with extension .cmake) to provide the path to your configuration file to CANEPP by defining macro CANEPP_CONFIGURATION_FILE. See the example below (this firmware uses UFSEL as well):
# toolchain.cmake

# ... 
SET(CONFIG_FILES "-DUFSEL_CONFIGURATION_FILE=\"<ufsel-configuration.hpp>\" -DCANEPP_CONFIGURATION_FILE=\"<config.hpp>\"")

SET(COMMON_FLAGS "${DEVICE_FLAGS} ${OPTIMIZATIONS_FLAGS} ${DEFINES} ${VALIDATION_FLAGS} ${CONFIG_FILES}")
# ... 
  1. Provide implementation (see the reference implementation below) of non-volatile memory storage manipulation functions forward declared in the header canepp.hpp. These functions provide access to storage address range, its erassure, (un)locking and writing new data:
// canepp/include/canepp.hpp

namespace canepp {

	////////////////////////////////////////
	// Functions implemented by the user
	////////////////////////////////////////

	// Functions accessing the nonvolatile storage defined in main.cpp
	[[nodiscard]]
	std::uintptr_t get_nonvolatile_storage_begin();
	[[nodiscard]]
	std::size_t get_nonvolatile_storage_size();
	// Returns true when success, false on failure
	[[nodiscard]]
	bool erase_nonvolatile_address_range();
	// Only called with lengths an integer multiple of flash granularity (feel free to assert on it) 
	[[nodiscard]]
	bool write_to_nonvolatile_storage(std::uintptr_t address, std::span<std::uint8_t const> data);
	void lock_nonvolatile_storage();
	void unlock_nonvolatile_storage();
	// Returns false when reboot is not possible (e.e.g due to some uninterruptible task)
	[[nodiscard]]
	bool reboot();

	///////////////////////////////////////////////
	// End of functions implemented by the user
	///////////////////////////////////////////////

}

This typically requires slight cooperation between C++ code and the linker script. C++ implements individual functions and defines a large buffer manually placed to a particular section during compilation. The linker script then defines this particular section and reserves space for it. Reference implementation is as follows:

// e.g. in main.cpp

// Implementation of flash storage for CANEPP
namespace canepp {

	__attribute__((section(".caneppSection")))
	char flash_storage[config::canepp::nonvolatile_storage_size.toBytes()];

	std::uintptr_t get_nonvolatile_storage_begin() {
		return reinterpret_cast<std::uintptr_t>(&flash_storage);
	}

	std::size_t get_nonvolatile_storage_size() {
		return sizeof(flash_storage);
	}

	bool erase_nonvolatile_address_range() {
		std::uintptr_t const begin = get_nonvolatile_storage_begin();
		std::size_t const size = get_nonvolatile_storage_size();
		bsp::flash::RAII_unlocker const _;
		bsp::flash::EraseRange(begin, begin + size);
		return true; // TODO can this go wrong?
	}

	bool write_to_nonvolatile_storage(std::uintptr_t address, std::span<std::uint8_t const> data) {
		assert(data.size() % sizeof(bsp::flash::native_t) == 0);
		std::span<bsp::flash::native_t const> native_data{
			reinterpret_cast<bsp::flash::native_t const *>(data.data()), 
			data.size() / sizeof(bsp::flash::native_t)
		};
		return bsp::flash::Write(address, native_data) == bsp::flash::WriteStatus::Ok;
	}

	void lock_nonvolatile_storage() {
		bsp::flash::lock();
	}

	void unlock_nonvolatile_storage() {
		if (ufsel::bit::all_set(FLASH->CR, FLASH_CR_LOCK))
			bsp::flash::unlock();
	}

	bool reboot() {
		NVIC_SystemReset();
		return true;
	}
} // end namespace canepp
// linker_script.ld

/* ... */

/* Specify the memory areas */
MEMORY
{
	FLASH_Firmware (rx)             : ORIGIN = ORIGIN(ApplicationFlash), LENGTH = LENGTH(ApplicationFlash) - 8K
	FLASH_CaneppSector(rw)          : ORIGIN = ORIGIN(FLASH_Firmware) + LENGTH(FLASH_Firmware), LENGTH = 8K
}

/* Define output sections */
SECTIONS
{

/* ... stack, .text, .data etc*/
.caneppSection (NOLOAD):
{
	caneppFlashStart = .;
	*(.caneppSection)
	caneppFlashEnd = .;

} > FLASH_CaneppSector
 /* ... */
}

To verify your implementation is correct, disassemble the binary and check that the C++-defined buffer was placed to the end of flash memory, as requested by the linker script.

  1. Setup RX callback for Canepp_Request and load values of parameters from non-volatile memory.
// typically in main.cpp

#include <canepp/canepp.hpp>

void setup_can_callbacks() {
	Bootloader_Ping_on_receive(BootloaderDriver::pingReceivedCallback);

	Canepp_Request_on_receive([](Canepp_Request_t * msg) -> int {
		return canepp::manager.handle_request(msg);
	});

	// ... other callbacks
}

void main() {
	// ... initialization code
	canepp::manager.load_from_nonvolatile_storage();
	// ... more initialization code
}
  1. CHECKPOINT You are good to go! πŸŽ‰ Now #include <canepp/canepp.hpp> in the source files of your project and use it.
// main.cpp

#include <canepp/canepp.hpp>

// Declare global (i.e. at file level, not local to some function) canepp object. This creates a proxy, exposing the wrapped parameter externally as a parameterization point
canepp::Numeric _reg_current_Idmax_A /* the name of variable does not really matter, can be autogenerated */ {
	"mcA.Id_max", // human friendly name
	"Maximal D axis current for channel A", // short description
	"Maximal magnitude of the field-weakening current.", // longer (more detailed) description. May be nullptr
	"A", // physical unit
	+[] { return drt::mcA.fw().pid_fw_.clamp_min(); },
	+[](float f) { drt::mcA.fw().pid_fw_.clamp_min() = f; },
	-60, 0 // lower and upper bounds
};

🎁 Common CANEPP objects

The header canepp/canepp.hpp may define many convenience macros for commonly used objects if you #define CANEPP_USE_COMMON_OBJECTS 1 in your configuration file.

Common objects are:

  • family of CANEPP_ADC_CALIB that wrap ufsel::math::converted_filtered_measurement (itself in turn wrapping ADC measurements) and exposes their correction gain (float) and correction offset (strongly typed, e.g. Voltage, Current etc).
  • CANEPP_ADC_RAW provides read-only access to raw ADC channel reading through the bsp::adc::ReadoutRaw function

Common operations (function pointers) are:

  • family of CANEPP_GET_SET that provide simple get/set implementation suitable for booleans, plain numbers but also individual strongly types ufsel::units.

Other convenience macros:

  • CANEPP_AUTO_NAME simplifies the creation of variables holding CANEPP objects by providing (mostly) unique names of the form static _canepp_declaration_xxx_yyy where xxx represent the line number obtained by __LINE__ and yyy is the continuously incremented __COUNTER__. Since the preprocessor runs for each compilation unit individually, this mechanism can't provide complete guarantee of uniqueness of generated identifiers, hence it also marks the object as static. These objects shall never appear in a header file.

I suggest using common CANEPP objects since they significantly shorten repetitive typing, hence you will see a lot of macros. None of these macros are complicated, so don't be afraid.

Examples

This section lists many examples how to define CANEPP objects. Most examples assume that you use common objects. Please note that in practice, many objects are needed since there are multiple channels (DRT), sensors (PDL, MBOX) etc. It is most natural to start defining simple macros or to manually duplicate object definitions. Once there are more than two similar objects, macros are highly recommended.

Bool

// From AMS, (de)activate printing status of Vicor DCDC
namespace options::vicor {
	bool print_status = false;
}

canepp::Bool reg_vicor_dump_status {
	"vicor.print_status",
	"Print status bits of Vicor",
	nullptr,
	CANEPP_GET_SET(options::vicor::print_status)
};
// From PDL, BPPS enable. Example of accessing members of global struct
struct BPPC {
	bool enabled_;
} inline bppc;

canepp::Bool _reg_bppc_enabled {
	"BPPC.enable",
	"Enables Brake Pedal Plausibility Check",
	"When BPPC is active, brake pedal overrides the accelerator - when the driver starts braking, APPS reading is forced to zero in PDL::Accelerator.",
	+[] { return bppc.enabled_; },
	+[](bool new_val) { bppc.enabled_ = new_val; },
};

Procedure

// from PDL, takes the current STW reading and calibrates it as the new STW center
canepp::Procedure _reg_calibrate_stw_center {
	"stw.calibrate_center",
	"Accept current sensor reading as new STW center. You must manually store results to flash!",
	nullptr,
	+[] {
		int const prev_value = stw.calibrated_center_lsb_;
		stw.calibrated_center_lsb_ = stw.unfiltered_reading();
		debug_printf_0("Calibrated new STW center %d lsb (was %d lsb). Store to flash to save.\n", stw.calibrated_center_lsb_, prev_value);
		return 0;
	}
};
// from AMS, resets the BMS state machine to reinitialize communication
canepp::Procedure _reg_retry_comm {
	"bms.debug.reset_communication",
	"Reset all BMS state machine",
	nullptr,
	+[] {
		ams::bmsFaultCount = 0;
		ams::numErrors = 0;
		ams::commResetCount = 0;
		ams::stateHigh = ams::BMS::HighState::pendingInit;
		return 0;
	}
};

Enumeration

// from PDL, allocation (mapping) of individual brake pressure sensors
DEFINE_ENUM_DOC(BP_location_doc,
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_hydraulics, "front hydraulic circuit (BP5)"),
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_hydraulics, "rear hydraulic circuit (BP6)"),
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_before_valve, "EBS FRONT before release valve (BP1)"),
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_before_valve, "EBS REAR before release valve (BP2)"),
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_after_valve, "EBS FRONT after release valve (BP3)"),
	DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_after_valve, "EBS REAR after release valve (BP4)"),
);

#define CANEPP_BP_SENSOR_LOCATION(adc_signal_name, AIN) \
canepp::Enum _reg_brakes_mapping_ ## adc_signal_name { \
	"brakes.mapping." #adc_signal_name, \
	"BP sensor connected to input " #adc_signal_name  " (" #AIN " in CTU24 PDL)", \
	nullptr, \
	BP_location_doc, \
	+[] { \
		auto const iter = std::ranges::find(pdl::sensors, bsp::adc::Signal:: adc_signal_name, &pdl::PressureSensor::adc_signal); \
		assert(iter != std::ranges::end(pdl::sensors)); \
		return iter->location(); \
		}, \
	+[](pdl::BP_location loc) { \
		auto const iter = std::ranges::find(pdl::sensors, bsp::adc::Signal:: adc_signal_name, &pdl::PressureSensor::adc_signal); \
		assert(iter != std::ranges::end(pdl::sensors)); \
		iter->set_location(loc); \
		pdl::brakes.get_sensor_references(); \
		pdl::ebs.get_sensor_references(); \
	} \
};

CANEPP_BP_SENSOR_LOCATION(BRK_PRS_1, AIN7);
CANEPP_BP_SENSOR_LOCATION(BRK_PRS_2, AIN8);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_1, AIN1);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_2, AIN2);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_3, AIN3);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_4, AIN4);

Numeric

// From PDL, sets the width of APPS lower deadzone
canepp::Numeric _reg_apps_deadzone_low {
	"accelerator.deadzone_lower",
	"Width of deadzone around APPS (accel/regen) zero normalized reading.",
	nullptr,
	"%",
	+[] { return options::APPS::lower_deadzone_width * 100; },
	+[](float new_value) { options::APPS::lower_deadzone_width = new_value / 100; },
	0, 100
};
// from DRT, configures the maximal current used for field weakening separately on channel A and B
#define CANEPP_REG_IDMAX(channel) \
canepp::Numeric _reg_current_Idmax_ ## channel { \
	"mc" #channel ".Id_max", \
	"Maximal D axis current for channel " #channel, \
	"Maximal magnitude of the field-weakening current.", \
	"A", \
	+[] { return drt::mc ## channel .fw().pid_fw_.clamp_max(); }, \
	+[](float f) { drt::mc ## channel .fw().pid_fw_.clamp_max() = f; }, \
	0, 60 \
};
CANEPP_REG_IDMAX(A);
CANEPP_REG_IDMAX(B);

About

Library for fast and simple parameterization of eForce Prague Formula ECUs at runtime.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •  

Languages