firmware-base/docs/di.md

10 KiB

Dependency Injection in Polymech Firmware

This document outlines dependency injection (DI) patterns applicable to the Polymech firmware, with a focus on lightweight ("cheap") techniques suitable for an embedded C++ environment.

1. What is Dependency Injection?

Dependency Injection is a design pattern where a component (an object) receives its dependencies from an external source rather than creating them internally. This promotes loose coupling, making the system more modular, easier to test, and more maintainable.

In an embedded context, DI techniques must be "cheap" in terms of:

  • CPU Overhead: The mechanism should not consume significant processing time.
  • Memory Footprint: The solution should not require large amounts of RAM or flash.
  • Cognitive Overhead: The pattern should be simple to understand and use.

2. Current Approach: Manual Constructor Injection

The firmware currently uses Manual Constructor Injection. The central application class (e.g., PHApp) instantiates components and their dependencies, and then "injects" the dependencies into the components by passing them as pointers to the constructor.

Example: Plunger Component

The instantiation of the Plunger component in src/PHApp.cpp illustrates this pattern perfectly:

// In PHApp, dependencies are created first:
vfd_0 = new SAKO_VFD(this, MB_SAKO_VFD_SLAVE_ID);
joystick_0 = new Joystick(this, PIN_JOYSTICK_UP, ...);
pot_0 = new POT(this, MB_ANALOG_0);
pot_1 = new POT(this, MB_ANALOG_1);

// Then, the Plunger is created with its dependencies injected via the constructor:
plunger_0 = new Plunger(this, vfd_0, joystick_0, pot_0, pot_1);

The Plunger class constructor receives and stores pointers to the objects it depends on:

// lib/polymech-base/src/components/Plunger.h
class Plunger : public Component {
public:
    Plunger(Component* owner, SAKO_VFD* vfd, Joystick* joystick, POT* speedPot, POT* torquePot);
    // ...
private:
    SAKO_VFD* _vfd;
    Joystick* _joystick;
    POT* _speedPot;
    POT* _torquePot;
    // ...
};

Advantages

  • Explicit Dependencies: A component's dependencies are clear from its constructor signature.
  • Compile-Time Safety: Missing dependencies will result in a compile-time error.
  • Zero Runtime Overhead: It's just a function call. No lookups or dynamic resolutions are needed.
  • Simplicity: The pattern is easy to understand and implement without any special libraries.

Disadvantages

  • Boilerplate: The main application class can become large and cluttered with object creation logic as the application grows.
  • Initialization Order: The developer must manually manage the correct order of object creation.

3. Alternative "Cheap" DI Options

While the current approach is effective, here are some alternative patterns that can be considered.

Option 1: Factory Pattern

This pattern encapsulates the creation logic for a set of related objects into a separate Factory class. The main application asks the factory for a component, and the factory handles creating the component and its dependencies.

Example

class ComponentFactory {
public:
    ComponentFactory(App* owner) : _owner(owner) {}

    Plunger* createPlunger() {
        SAKO_VFD* vfd = new SAKO_VFD(_owner, MB_SAKO_VFD_SLAVE_ID);
        Joystick* joystick = new Joystick(_owner, ...);
        // ... create other dependencies
        return new Plunger(_owner, vfd, joystick, ...);
    }
private:
    App* _owner;
};

// In main app:
auto factory = new ComponentFactory(this);
plunger_0 = factory->createPlunger();
  • Pros: Cleans up the main application logic, centralizes creation logic.
  • Cons: Adds another layer of abstraction and more classes to maintain.

Option 2: Service Locator

A Service Locator is a central registry where dependencies ("services") are registered and can be requested by components.

Example

// A simple Service Locator
class ServiceLocator {
public:
    static SAKO_VFD* getVFD() { return _vfd; }
    static void registerVFD(SAKO_VFD* vfd) { _vfd = vfd; }
private:
    static SAKO_VFD* _vfd;
};

// In main app:
vfd_0 = new SAKO_VFD(...);
ServiceLocator::registerVFD(vfd_0);

// In a component's constructor or setup:
_vfd = ServiceLocator::getVFD();
  • Pros: Can simplify constructors, decouples components from the main app.
  • Cons: (Anti-Pattern Warning) Hides dependencies, making the code harder to understand and test. It's essentially a glorified global variable, which can lead to maintenance issues. It also introduces runtime lookup overhead.

Option 3: Compile-Time DI with Templates

This advanced technique uses C++ templates to inject dependencies at compile-time. The dependency is a type parameter, not an object instance passed to the constructor.

Example

template<typename VfdType, typename JoystickType>
class Plunger : public Component {
public:
    Plunger(Component* owner) {
        // Here we assume VfdType and JoystickType can be instantiated
        // or accessed somehow. This is a very simplified example.
    }
    // ...
};
  • Pros: Maximum performance (zero runtime overhead), strong type safety.
  • Cons: Can be complex to implement and lead to verbose compiler errors ("template vomit"). Can increase compile times.

4. Recommendation

The current Manual Constructor Injection pattern is robust, simple, and highly efficient. It is well-suited for the project and should remain the primary DI pattern.

  • Stick with Manual Constructor Injection for its clarity and performance.
  • Consider using the Factory Pattern if the object creation logic in PHApp becomes overly complex and difficult to manage. This can help organize the setup phase without adding significant runtime overhead.
  • Avoid the Service Locator pattern. While it may seem convenient, it obscures dependencies and can lead to less maintainable code in the long run.
  • Template-based DI is a powerful but complex option. It could be considered for highly performance-critical sections where every CPU cycle counts, but it's likely overkill for general component wiring.

5. Compile-Time DI via Feature Flags

In addition to runtime object injection, the firmware uses a powerful form of compile-time dependency management using preprocessor feature flags.

How It Works

  1. Configuration (src/config.h): The central config.h file contains a series of #define statements that act as feature flags (e.g., #define ENABLE_PLUNGER).
  2. Conditional Compilation: Throughout the codebase, these flags are used in #ifdef blocks to conditionally compile code. This includes object instantiation, method calls, and even member variable declarations. If a flag is not defined, the corresponding code is completely excluded from the final binary.
  3. Dependency Validation (src/config-validate.h): A validation header checks for logical inconsistencies in the configuration at compile time. For example, it ensures that if ENABLE_PLUNGER is active, its own dependencies like ENABLE_SAKO_VFD are also active, throwing a compiler error if they are not.

This mechanism ensures that only the necessary components are compiled into the firmware, which is a highly effective way to manage dependencies and resource usage on an embedded system.

The menu.tsx Configuration Tool

To simplify the management of these feature flags, the project includes a command-line tool located at cli-ts/src/commands/menu.tsx.

  • Functionality: This tool provides a terminal-based interactive menu for editing src/config.h. It parses the header file to find feature flags and their associated settings.
  • Technology: It uses the tree-sitter-cpp library to accurately parse the C++ header file syntax and identify #define directives, including those that are commented out.
  • Role in DI: The menu.tsx script acts as a user-friendly interface for the compile-time DI system. It allows a developer to easily enable or disable high-level features.

It is important to note that the menu.tsx script is a configuration editor, not a dependency resolver. It does not automatically enable ENABLE_SAKO_VFD when you enable ENABLE_PLUNGER. The developer is still responsible for managing these dependencies, with config-validate.h serving as the safety net during the build process.

6. Potential Enhancement: Automated Dependency Analysis

The menu.tsx script could be evolved from a simple configuration editor into a dependency-aware provisioning tool. By leveraging tree-sitter-cpp more deeply, it could automatically analyze and resolve dependencies between components.

Conceptual Workflow

  1. Build a Component Map: The script would first parse src/features.h to build a comprehensive map linking feature flags (e.g., ENABLE_PLUNGER) to the header files they include (e.g., components/Plunger.h) and the primary class names defined within them (e.g., Plunger).

  2. Analyze Component Dependencies: When a feature is selected in the menu, the script would parse the corresponding header file. It would traverse the Abstract Syntax Tree to identify dependency clues, such as:

    • The types of pointers passed into the class constructor (SAKO_VFD* vfd).
    • The types of member variables.
    • Local #include directives.
  3. Resolve and Report: The script would then cross-reference these discovered dependency class names (e.g., SAKO_VFD) with the component map from step 1 to find their associated feature flags (ENABLE_SAKO_VFD).

This would allow the tool to generate a dependency object for any given component. For example, analyzing Plunger could produce:

{
  "feature": "ENABLE_PLUNGER",
  "header": "src/components/Plunger.h",
  "dependencies": [
    {
      "className": "SAKO_VFD",
      "feature": "ENABLE_SAKO_VFD",
      "header": "src/components/SAKO_VFD.h"
    },
    {
      "className": "Joystick",
      "feature": "ENABLE_JOYSTICK",
      "header": "src/components/Joystick.h"
    }
  ]
}

Benefits

With this capability, the menu.tsx script could proactively assist the developer. For instance, upon enabling ENABLE_PLUNGER, it could automatically detect that ENABLE_SAKO_VFD is also required and either enable it automatically or prompt the user to do so. This would prevent many config-validate.h errors before the compilation stage, streamlining the development process significantly.