Implementing the OpenEPT Charger Firmware

Implementing the OpenEPT Charger Firmware
In our previous development update, we presented the hardware design of the OpenEPT Charger Board, including the BQ25180 battery charger, STM32L476 microcontroller, battery-monitoring circuitry, configuration EEPROM, isolated USB interface, and the dedicated connection to the OpenEPT Energy Profiler Probe. With the hardware platform in place, the next development stage focused on implementing the firmware required to initialize, configure, monitor, and control the Charger Board.
The Charger firmware follows the same architecture already adopted across the OpenEPT project. The firmware is organized into several layers, with hardware and device-specific functionality placed at the lower levels and application-level functionality coordinated through a set of top-level modules referred to as services. This approach keeps hardware-specific details, such as BQ25180 register access or STM32 peripheral configuration, outside the application logic. At the same time, the service layer provides clearly defined interfaces for charger control, configuration management, communication, logging, and overall system coordination.
This post focuses specifically on these firmware implementation aspects, starting with the overall software organization and then examining the implementation of the charging functionality and its integration with the rest of the firmware.
The complete OpenEPT Charger firmware is available as open-source software in the OpenEPT Charger repository.
In This Update
This development update focuses on three main implementation areas:
- Firmware Architecture – the layered organization of the Charger firmware and the role of the service layer.
- Charger Service – implementation of the charging functionality, FreeRTOS-based execution, and integration of the BQ25180 charger.
- Control and Communication – integration of the Charger functionality with the external command and communication interfaces.
Firmware Architecture
The OpenEPT Charger firmware runs on the STM32L476 microcontroller and follows the layered software organization already used across the OpenEPT project.TAt the top of this architecture are the services which use lower-level device and platform drivers to interact with the actual hardware. A service represents a complete application-level responsibility rather than a particular hardware peripheral. For example, the Charger service is responsible for the charging process, while the System service coordinates functionality that belongs to the device as a whole.
One consequence of this organization is that the application entry point remains intentionally small. The main() function does not initialize the BQ25180, access the EEPROM, process commands, or implement the charging state machine. Its responsibility is limited to initializing the core platform functionality and transferring control to the System service:
int main(void)
{
if(DRV_SYSTEM_InitCoreFunc() != DRV_SYSTEM_STATUS_OK)
{
while(1);
}
if(SYSTEM_Init() != SYSTEM_STATUS_OK)
{
while(1);
}
if(SYSTEM_Start() != SYSTEM_STATUS_OK)
{
while(1);
}
while(1);
}After the low-level core initialization is complete, SYSTEM_Init() initializes the application-level environment, while SYSTEM_Start() starts normal firmware execution.
This design keeps the entry point independent of the actual Charger functionality. Adding or modifying a service therefore does not require moving application logic into main.c.
Service Layer
The service layer contains the main firmware orchestrators. Each service owns a clearly defined part of the application and exposes a public interface to the remaining software.
The Charger firmware relies on several major services:
- System for application initialization, global state, and system-level coordination;
- Charger for control and monitoring of the charging process;
- Configuration for runtime and persistent parameter management;
- Control for processing commands received through the communication interface;
- Logging for centralized diagnostic output.
Most of these services execute in their own FreeRTOS context. This allows operations that require synchronization, communication with external devices, or processing of asynchronous events to remain inside the service responsible for them.
Below the service layer are device-specific modules and platform drivers. Device-specific modules encapsulate the implementation required to operate individual external components, while platform drivers provide access to STM32 peripherals such as I²C, GPIO, ADC, timers, and communication interfaces.
For example, the charging functionality is implemented through a dedicated BQ25180 driver, while persistent board configuration is accessed through the AT24CS01 EEPROM driver.
The important architectural rule is that higher layers operate in terms of application functionality. A service requests a charging current or reads a configuration parameter; it does not need to know which STM32 register or BQ25180 bit field is involved in implementing that operation.
System Service
Responsibility: System initialization, service orchestration, error reporting, operational-state management, and system-level functionality.
Source files:
The System service is responsible for a collection of system-level functions that do not naturally belong to any of the specialized Charger services. Part of this functionality is closely related to the underlying hardware and includes initialization and control of the RGB status LED, handling of the system button, and monitoring of the signal used to detect the presence of the Energy Profiler Probe. The service also maintains device-level information such as the device name and the current operational state.
In addition to these low-level and device-wide functions, the System service provides the common mechanism for reporting system errors and maintains the operational state used to represent the current behavior of the Charger. This includes normal operation, constant-current and constant-voltage charging, interrupted charging, completed charging, and error conditions. The operational state is also used to control the visual indication provided by the RGB LED.
The central part of the System service is its FreeRTOS task, which brings these individual responsibilities together and acts as the main firmware orchestrator. The task is organized around three states: INIT, SERVICE, and ERROR. During INIT, it initializes the platform functionality and starts the remaining firmware services in the required order. Once initialization is complete, it enters SERVICE, where it processes system events, monitors the Energy Profiler connection, updates the device status indication, and coordinates operations that involve other services. Any fatal initialization failure moves the task into the ERROR state.
Its overall execution flow can be summarized as:
SYSTEM task:
INIT:
initialize platform drivers
initialize PWM, system button and status signals
initialize Configuration service
initialize Logging service
initialize Charger service
register Charger status callback
initialize Control service
set initial operating state
signal initialization complete
-> SERVICE
SERVICE:
wait for system events
update Energy Profiler presence status
update RGB indication according to operating state
if charging-state change requested:
request charging state change from Charger service
ERROR:
set operating state to ERROR
report system error
stop normal executionAlthough the System task coordinates the complete firmware startup and several device-level operations, it does not take over the responsibilities of the specialized services. For example, a charging-state request is forwarded to the Charger service through CHARGER_SetChargingState(), while configuration, command processing, and logging remain handled by their corresponding services.
Charger Service
Responsibility: Configuration, control, and monitoring of the battery charging process and integration of the BQ25180 charger IC.
Source files:
The Charger service contains the main application logic associated with battery charging. It sits between the rest of the application and the lower-level BQ25180 implementation and provides the interface through which charging parameters and operating states are controlled. The main advantage of introducing this service is that the rest of the firmware does not operate directly on BQ25180 registers. Instead, charging is controlled using physical parameters and application-level states. For example, the public Charger interface provides operations for enabling or disabling charging and configuring charging current, termination current, and termination voltage:
charger_status_t CHARGER_SetChargingState(
charger_charging_state_t state,
uint32_t initTimeout);
charger_status_t CHARGER_SetChargingCurrent(
uint16_t current,
uint32_t initTimeout);
charger_status_t CHARGER_SetChargingTermCurrent(
uint16_t current,
uint32_t initTimeout);
charger_status_t CHARGER_SetChargingTermVoltage(
float voltage,
uint32_t initTimeout);A caller can therefore request, for example, a new charging current in milliamperes without knowing how that value is represented internally by the BQ25180. Internally, the service maintains the current charging configuration and uses the BQ25180 driver to apply the requested values to the charger IC. The service is responsible for the charging logic, while the device driver remains responsible for communication with the charger hardware.
Charger Task
The Charger service runs as a dedicated FreeRTOS task. Its execution is organized around initialization, normal service operation, and error handling. During initialization, the service first initializes the BQ25180 interface and verifies that communication with the charger can be established. Communication is retried before a failure is promoted to a Charger service error, preventing a single unsuccessful transaction during startup from immediately stopping initialization.
Once communication is available, the service obtains the required board configuration and prepares the BQ25180 according to the active Charger parameters. The initialization process also prepares charger interrupt handling before the service transitions to normal operation.
After initialization, the task remains in its service state and processes requests generated through the public Charger API as well as asynchronous events generated by the charger hardware. This arrangement is important because the caller of CHARGER_SetChargingCurrent(), for example, does not become responsible for the complete I²C transaction and Charger state update. Instead, the requested operation remains owned and processed by the Charger service.
Handling Asynchronous Charger Events
The BQ25180 can signal charging-related events through its interrupt output. The Charger firmware handles these events without moving charger-processing logic into the interrupt context. The interrupt callback only notifies the Charger task:
static void prvCHARGER_CB()
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xTaskNotifyFromISR(
prvCHARGER_DATA.taskHandle,
CHARGER_TASK_PROCESS_INT,
eSetBits,
&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}The actual event processing is then performed inside the Charger task.
This keeps the interrupt handler short and ensures that both externally requested operations and hardware-generated events are processed within the same service context. BQ25180 communication and Charger state changes therefore remain centralized instead of being distributed between task and interrupt code.
BQ25180 Integration
The Charger service uses a dedicated BQ25180 driver for communication with the charger IC. The driver is responsible for the device-specific details required to configure the BQ25180, while the Charger service determines which configuration should be applied based on the current application state and configuration parameters. This separation is particularly visible for parameters such as charging current and termination voltage. The Charger service works with values meaningful to the application, while the BQ25180 implementation performs the conversion and register operations required by the device. As a result, BQ25180 register addresses, masks, and bit-field representations remain outside the public Charger interface.
The same approach is used for charger status and interrupts. The device driver provides access to the status and interrupt information exposed by the BQ25180, while the Charger service integrates this information into the charging state used by the rest of the application.
Configuration Service
Responsibility: Provides the Charger configuration parameters and integrates them with the common OpenEPT configuration mechanism.
Source files:
EEPROM implementation:
The Configuration service follows the same parameter-management approach already introduced and described in the previous OpenEPT firmware development update. Rather than repeating the complete implementation here, this section focuses only on the Charger-specific configuration introduced for the new board. The Charger firmware defines the following configuration parameters:
| Parameter | Description | Default value |
|---|---|---|
HW_SER |
Charger hardware serial number | 0123456789 |
FW_VER |
Charger firmware version | 1.0.0 |
CH_CUR |
Charging current | 100 |
TERM_VOLT |
Charging termination voltage | 4.2 |
TERM_CUR |
Charging termination current setting | 3 |
MAX_CUR |
Maximum charging current setting | 5 |
The parameter definitions are maintained in configurationDef.c, while the common Configuration service provides access to their runtime values.
Board-specific configuration is stored in the on-board AT24CS01 EEPROM. Low-level EEPROM access is implemented in at24cs01.c and at24cs01.h.
The underlying configuration mechanism, including parameter representation, runtime access, serialization, and persistent storage handling, follows the mechanism already described in our previous firmware development update.
[TODO: Add link to the previous OpenEPT firmware development blog.]
Control and Communication
Control Service
Responsibility: Reception and processing of control commands and translation of those commands into calls to the corresponding firmware services.
Source files:
The Control service forms the command-processing boundary between external communication and the internal Charger services.
Its responsibility is not to implement charging or system functionality itself. Instead, it receives a request, parses the command and its arguments, invokes the appropriate service API, and prepares the response.
This distinction keeps the communication protocol independent from the implementation of individual functions.
For example, when a command requests a change to a system-level property, the Control service delegates the operation to the System service:
if(SYSTEM_SetDeviceName(value.value) != SYSTEM_STATUS_OK)
{
prvCONTROL_PrepareErrorResponse(response, responseSize);
return;
}Charger-related commands follow the same principle. The Control service interprets the command and calls the appropriate CHARGER_*() interface instead of accessing the BQ25180 directly.
The same architecture is used for read operations. When information is requested by an external application, the Control service obtains it from the service that owns that information and converts the result into the corresponding protocol response.
This creates a clear boundary in the firmware: communication commands can evolve without moving hardware-control logic into the protocol implementation, while Charger functionality can change internally without requiring the external interface to know how an operation is implemented.
USB Communication
For communication with a directly connected host, the Control service is integrated with the USB CDC communication layer.
Received data is passed to the command-processing mechanism, while responses are returned through the communication interface after the requested operation has been processed.
From the Control service perspective, USB is primarily a transport mechanism. Command parsing and dispatch remain part of the Control service, while USB-specific data transfer is handled by the corresponding lower-level driver.
This separation also makes the command-processing mechanism less dependent on a particular physical communication interface.
Logging Service
Responsibility: Centralized runtime, diagnostic, warning, and error logging for the Charger firmware.
Source files:
The Logging service provides a common mechanism for reporting diagnostic information from different parts of the firmware.
Rather than having each service implement its own output mechanism, services generate messages through the common logging interface and associate them with the appropriate severity level.
The Charger service, for example, reports failures detected during BQ25180 initialization and communication through this interface:
LOGGING_Write(
"Charger service",
LOGGING_MSG_TYPE_ERROR,
"Unable to initialize BQ25180\r\n");Logging is also used for less severe conditions such as warnings, initialization information, and successful operations.
Centralizing logging is particularly useful in a FreeRTOS-based application where several independent services execute concurrently. Instead of coupling those services to a particular diagnostic output, they all use the same logging mechanism.
Bringing the Services Together
Although each service has a clearly defined responsibility, the complete Charger functionality is implemented through their cooperation.
During startup, the System service coordinates initialization of the application environment. The Configuration service provides the Charger-specific parameters, while the Charger service initializes the BQ25180 and applies the active charging configuration. The Control service exposes this functionality through the external command interface, while the Logging service provides diagnostic information throughout the process.
This separation is maintained during normal operation as well. A command that changes the charging current is received and interpreted by the Control service, but the charging operation itself remains owned by the Charger service. Configuration information is obtained through the Configuration service, while the actual BQ25180 register operations remain confined to the dedicated device driver.
Persistent configuration follows the same principle. The Configuration service manages the parameters and their representation, while the AT24CS01 driver provides the low-level EEPROM access.
The same separation applies to asynchronous events. A hardware interrupt generated by the BQ25180 is forwarded to the Charger task, processed there, and only then reflected in the state visible to the rest of the application.
The result is a firmware architecture in which each component has a well-defined responsibility and communication between components occurs through explicit interfaces rather than through direct access to another module's internal state or underlying hardware.
Closing Thoughts
The OpenEPT Charger firmware extends the software architecture already established within the OpenEPT project rather than introducing a separate implementation model for the new board.
The top-level functionality is organized around services, with the System service coordinating the complete application and specialized Charger, Configuration, Control, and Logging services handling their respective responsibilities. Below this layer, device-specific and platform drivers isolate the application from the details of the BQ25180, AT24CS01 EEPROM, STM32 peripherals, and communication hardware.
This organization is particularly important for the Charger because charging control spans several different concerns: device configuration, persistent parameters, asynchronous hardware events, communication, and overall system state. Keeping these concerns separated makes it possible to extend individual parts of the firmware without introducing hardware-specific dependencies throughout the application.



