Files
Main/99 Work/0 OneSec/OneSecNotes/10 Projects/TeensyFlightcontroller/Parameter System Desing Document.md
Obsidian-MBPM4 852a8fa643 vault backup: 2025-09-23 09:14:05
Affected files:
.obsidian/workspace.json
99 Work/0 OneSec/OneSecNotes/10 Projects/TeensyFlightcontroller/Parameter System Desing Document.md
2025-09-23 09:14:05 +02:00

21 KiB
Raw Blame History

title, created_date, updated_date, aliases, tags
title created_date updated_date aliases tags
Parameter System Desing Document 2025-09-23 2025-09-23

Parameter System Desing Document

Teensy 4.0 • Teensyduino/Arduino framework • No RTOS • ETL containers • Chirp protocol for messaging

Scope: Flight Controller (FC) side only, with name-as-canonical parameters and immediate apply (no staging yet).

Enums such as TypeId, ParamStatus, ParamOp come from the Chirp side. Include the relevant Chirp headers; do not redefine them here.


1) Current Rules / Constraints

  • Platform: Teensy 4.0, Arduino/Teensyduino framework, no RTOS.

  • Transport: Chirp is the sole protocol for Get/Set requests and responses.

  • Design principles

    1. Name is canonical. We identify parameters by a unique, human-readable path.
    2. Shallow ownership tree. Each module owns a single sub-tree; keep names shallow: module.param.
    3. Module-facing interface. Modules expose parameters via light interfaces; the manager never “reaches in” to private variables.
  • Non-module params: System-level, logging, build info, flight mode selectors, serial settings, etc. must also fit the same model.

Current Chirp Message Structure

<message name="ParameterRequest">
    <enum name="ParamOp" underlying_type="uint8_t">
        <entry name="Get" value="0" />
        <entry name="Set" value="1" />
        <entry name="GetFullList" value="2" />
    </enum>
    <field name="timestamp" type="uint64_t" />
    <field name="source_id" type="uint8_t" />
    <field name="transaction_id" type="uint16_t" />
    <field name="target_component" type="uint8_t" />
    <field name="op" type="ParamOp" />
    <field name="id" type="uint32_t" />
    <field name="name" type="uint8_t" size="64" />
    <field name="name_size" type="uint8_t" />
    <field name="type" type="TypeId" />
    <field name="length" type="uint16_t" />
    <field name="value" type="uint8_t" size="256" />
</message>

<message name="ParameterResponse">
    <enum name="ParamStatus" underlying_type="uint8_t">
        <entry name="Ok" value="0" />
        <entry name="NotFound" value="1" />
        <entry name="InvalidType" value="2" />
        <entry name="InvalidValue" value="3" />
        <entry name="AccessDenied" value="4" />
        <entry name="RebootRequired" value="5" />
        <entry name="InternalError" value="6" />
    </enum>
    <field name="timestamp" type="uint64_t" />
    <field name="source_id" type="uint8_t" />
    <field name="transaction_id" type="uint16_t" />
    <field name="status" type="ParamStatus" />
    <field name="id" type="uint32_t" />
    <field name="name" type="uint8_t" size="64" />
    <field name="name_size" type="uint8_t" />
    <field name="type" type="TypeId" />
    <field name="length" type="uint16_t" />
    <field name="value" type="uint8_t" size="256" />
</message>


2) Naming

  • Hierarchical dot paths: (Either with group.name combination, or directly for name) Examples: attitudectrl.kp_roll, attitudectrl.torque_limit, servo_left_flap.offset_angle, serial_pilot.baud_rate, i2c_line1.clock, logger.sink_interface, system.fw_version.

  • Uniqueness: Names are case-sensitive and must be unique within the FC.

  • Ownership: The root token (e.g., attitudectrl, logger, serial_pilot) identifies which module/adapter owns the parameter.


3) Core Components

3.1 ParamRegistry (all descriptors)

A single in-memory catalog describing every configurable parameter on the FC. The registry answers what exists (metadata), how to read it, and how to apply a new value—without the manager touching module internals.

Descriptor structure (Globally defined across every system in drone)

  • participant : which device owns this parameter set (here: "flight_controller"). Note: other devices (autopilot, co-pilot) will have their own registries in their repos.

  • group : high-level bucket within the FC, typically the module or subsystem root ("attitudectrl", "logger", "system", "serial_copilot", "rc", "imu_leftwing").

  • name : either the full name of the parameter, e.g., "attitudectrl.kp_roll" , or partial name so that group + name results in the unique name.

  • type : TypeId (shared enum across Chirp, e.g., Float, Uint16, …).

  • length : size in bytes for validation and arrays.

  • default_value : bytes for default initialization at boot.

  • flags : bitmask. Possible flags:

    • Writable — if unset, parameter is read-only.
    • RebootRequired — write accepted but takes effect after reboot.
    • ImmediateApply — explicitly safe for immediate side-effects.
  • Accessors (delegates):

    • get(name) : bytes — read current value from the owning module.
    • set(name, bytes) : status — parse/validate module-local rules and update the module.

The registry owns only metadata and function delegates. Actual values remain inside modules.

Example C++ sketch


struct ParamDescriptor {
  const char* participant;       // "flight_controller"
  const char* group;             // "attitudectrl", "logger", ...
  const char* name;              // "attitudectrl.kp_roll" (canonical, unique)
  TypeId      type;
  uint16_t    length;            // sizeof(float) etc.
  uint8_t     flags;             // bitmask of ParamFlag
  const uint8_t* default_bytes;  // pointer to default value bytes

  // Delegates to module-owned code:
  etl::delegate<ParamStatus(uint8_t* out, uint16_t& out_len)> getter;
  etl::delegate<ParamStatus(const uint8_t* in, uint16_t in_len, bool& reboot_required)> setter;
};

class ParamRegistry {
public:
  // Fixed capacity
  bool register_param(const ParamDescriptor& d);     // returns false on duplicate name or full
  const ParamDescriptor* find(const char* name) const; // or find(group, name) pair depending on full name implementation

private:
  etl::unordered_map<etl::string<64>, ParamDescriptor, NUM_PARAMS> m_by_name; // pre-sized buckets
};

Note: TypeId , ParamStatus definitions exist in relevant chirp header files.

Getter/Setter notes

  • getter returns the current committed value from the module into out.
  • setter validates and updates the module, sets reboot_required=true if the new value wont take effect until reboot.

3.2 ParameterManager (global singleton)

The single entry point for Get/Set operations and the Chirp callback endpoint.

Responsibilities

  • Own the ParamRegistry.

  • Expose a simple API used by both:

    • Internal code, and
    • The Chirp handler (by registering a callback for ParameterRequest).
  • Apply changes unless the descriptor is RebootRequired.

Minimal API sketch

class ParameterManager {
public:
  static ParameterManager& instance();

  void init(ChirpHandler& chirp); // registers ParamRequest callback
  ParamRegistry& registry();

  // Internal helpers
  ParamStatus get_by_name(const char* name, uint8_t* out, uint16_t& out_len, TypeId& out_type) const;
  ParamStatus set_by_name(const char* name, const uint8_t* in, uint16_t in_len, TypeId type, bool& reboot_required);

  // Chirp callback (wired to opcode for ParameterRequest)
  void chirp_param_request_callback(const chirp::ParameterRequest& req);

private:
  ParameterManager() = default;
  ParamRegistry m_reg;
};

Key behaviors

  • Name resolution via registry.
  • Immediate apply: set_by_name calls the descriptors setter. If success:
    • If RebootRequired flag OR setter sets reboot_required=true → reply RebootRequired.
    • Else reply Ok.
  • Get calls the descriptors getter.
  • Chirp bridge: chirp_param_request_callback maps Chirp fields to the API above and produces a ParameterResponse.

4) Module Integration via IFlightModule

Grouping flight controller modules under a capability interface like IFlightModule can be useful:

  • Keeps a clean boundary: modules declare what they own and how to apply changes—no global manager poking internal data.
  • Enables testability: I can unit-test a modules param parsing/validation in PC native tests without wiring the entire FC.
  • Simplifies registration: each module lists its parameters and provides delegates once, during init.

Example interface

struct IFlightModule {
  virtual const char* root_namespace() const = 0;  // e.g., "attitudectrl"
  virtual void enumerate_params(ParamRegistry& reg) = 0; // Optional. modules can expose dedicated getters/setters per param as how it is currently.
  virtual ~IFlightModule() = default;
};

How its used

  • On boot, each module calls enumerate_params(reg).
  • Inside enumerate_params, the module registers its parameters by building ParamDescriptors with delegates bound to specific setter/getter methods of this.

Example — Attitude Controller registers kp_roll

class AttitudeController : public IFlightModule {
public:
  const char* root_namespace() const override { return "attitudectrl"; }

  void enumerate_params(ParamRegistry& reg) override {
    static const float DEFAULT_KP_ROLL = 10.0f; // Either defined here or as a member variable
    static const uint8_t* def = reinterpret_cast<const uint8_t*>(&DEFAULT_KP_ROLL);

    ParamDescriptor d {
      "flight_controller",
      "attitudectrl",
      "kp_roll", // or "attitudectrl.kp_roll"
      TypeId::Float,
      (uint16_t)(sizeof(float)),
      (uint8_t)(ParamFlag::Writable | ParamFlag::ImmediateApply),
      def,
      // getter: copy current kp into out
      etl::delegate<ParamStatus(uint8_t*, uint16_t&)>::create<AttitudeController, &AttitudeController::get_kp_roll>(this),
      // setter: parse float, validate, assign
      etl::delegate<ParamStatus(const uint8_t*, uint16_t, bool&)>::create<AttitudeController, &AttitudeController::set_kp_roll>(this)
    };
    reg.register_param(d);
  }

  // Example getter/setter implementations:
  ParamStatus get_kp_roll(uint8_t* out, uint16_t& out_len) {
    const uint16_t needed_length = static_cast<uint16_t>(sizeof(float));
    if (out_len < needed_length) return ParamStatus::InvalidType;
    memcpy(out, &m_kp_roll, needed_length);
    out_len = needed_length;
    return ParamStatus::Ok;
  }

  ParamStatus set_kp_gains(const uint8_t* in, uint16_t in_len, bool& reboot_required) {
    const uint16_t needed_length = static_cast<uint16_t>(sizeof(float));
    if (in_len != needed_length) return ParamStatus::InvalidType;
    float v;
    memcpy(&v, in, needed_length);
    
    if (v[i] < min || v[i] > max) return ParamStatus::InvalidValue;

    reboot_required = false; // takes effect immediately
    return ParamStatus::Ok;
  }

/* rest of the class definition ... */

private:
  float m_kp_roll = 10.0f;
  ...
};

This pattern can be replicated this pattern for other attitude controller params like torque_limit, indi related params etc.

Alternatively, we can alter the structure of ParamDescriptor in the example without redefining the current parameter structure of attitude controller, keeping the array structure for kp, kd gains, torque limits etc. Design supports multiple value arrays as single parameter:

  ParamDescriptor d {
    "flight_controller",
    "attitudectrl",
    "kp_gains", // or "attitudectrl.kp_gains"
    TypeId::Float,
    (uint16_t)(3 * sizeof(float)),
    (uint8_t)(ParamFlag::Writable | ParamFlag::ImmediateApply),
    def,
    // getter: copy current kp into out
    etl::delegate<ParamStatus(uint8_t*, uint16_t&)>::create<AttitudeController, &AttitudeController::get_kp_gains>(this),
    // setter: parse float, validate, assign
    etl::delegate<ParamStatus(const uint8_t*, uint16_t, bool&)>::create<AttitudeController, &AttitudeController::set_kp_gains>(this)
  };

where kp_gains is defined as:

private:
  etl::array<float, 3> m_kp_gains;

5) Hardware Parameters via IHardwareParamAdapter

Some parameters belong to hardware drivers (IMUs, serial baud, PWM ranges). I dont want the ParameterManager to depend on driver APIs, and most drivers are used by multiple flight modules.

The adapter idea: a tiny object owned by the module that uses the driver (or owned centrally) which exposes parameters for that hardware subtree, using the same pattern as a module:

struct IHardwareParamAdapter {
  virtual const char* root_namespace() const = 0;   // e.g., "serial" or "imu"
  virtual void enumerate_params(ParamRegistry& reg) = 0;
  virtual ~IHardwareParamAdapter() = default;
};

// Example: Serial adapter exposing baud rate
class SerialParamAdapter : public IHardwareParamAdapter {
public:
  const char* root_namespace() const override { return m_root_namespace; }

  void set_root_namespace(char* root_namespace) { m_root_namespace = root_namespace; } // These setters can be needed for multiple instances of serial e.g `serial_pilot` , `serial_copilot`

  void enumerate_params(ParamRegistry& reg) override {
    static const uint32_t DefaultBaud = 115200;
    static const uint8_t* def = reinterpret_cast<const uint8_t*>(&DefaultBaud);

    ParamDescriptor baud {
      "flight_controller",
      m_root_namespace,
      "baud_rate",
      TypeId::Uint32,
      (uint16_t)sizeof(uint32_t),
      (uint8_t)(ParamFlag::Writable | ParamFlag::RebootRequired),
      def,
      etl::delegate<ParamStatus(uint8_t*, uint16_t&)>::create<SerialParamAdapter, &SerialParamAdapter::get_baud>(this),
      etl::delegate<ParamStatus(const uint8_t*, uint16_t, bool&)>::create<SerialParamAdapter, &SerialParamAdapter::set_baud>(this)
    };
    reg.register_param(baud);
  }

  ParamStatus get_baud(uint8_t* out, uint16_t& out_len) {
    if (out_len < sizeof(uint32_t)) return ParamStatus::InvalidType;
    memcpy(out, &m_baud, sizeof(uint32_t));
    out_len = sizeof(uint32_t);
    return ParamStatus::Ok;
  }

  ParamStatus set_baud(const uint8_t* in, uint16_t in_len, bool& reboot_required) {
    if (in_len != sizeof(uint32_t)) return ParamStatus::InvalidType;
    uint32_t v; memcpy(&v, in, sizeof(uint32_t));
    if (v < 9600 || v > 3000000) return ParamStatus::InvalidValue;
    m_baud = v;
    reboot_required = true; // takes effect after re-init
    return ParamStatus::Ok;
  }

private:
  char* m_root_namespace;
  uint32_t m_baud = 115200;
  Serial* m_serial;
};

Why its useful

  • Keeps hardware side-effects localized (re-init serial, reconfigure I2C, etc.).
  • Does not require any change to driver code
  • Multiple modules can share one adapters namespace (serial.*, imu.*).
  • Allows easy setup for multiple hardware instances (serial_pilot, imu_leftwing)
  • Adapters register like modules; the manager treats them equivalently.

6) Boot Sequence (step-by-step)

  1. Construct the global singleton:

    auto& paramManager = ParameterManager::instance();
    
  2. Initialize Chirp and register callback:

    paramManager.init(chirp_handler); // internally: chirp_handler.register_callback(OPCODE_PARAM_REQUEST, &ParameterManager::chirp_param_request_callback);
    
  3. Create modules and adapters using factory method (AttitudeController, Navigator, Logger, SerialParamAdapter, etc.).

  4. Register parameters:

    Happens when modules are created, most likely in factory class.

    attitudeController.enumerate_params(paramManager.registry());
    logger.enumerate_params(paramManager.registry());
    serialAdapter.enumerate_params(paramManager.registry());
    // ... others
    
  5. Apply defaults (optional):

    • Iterate all descriptors and call their setter(default_bytes, length) to seed modules with defaults once at boot.
  6. Ready for runtime: FC now answers Chirp Get/Set by name.


7) Runtime Update Flow (example)

Scenario: ground station sets attitudectrl.kp_roll = 18.0f.

  1. Chirp RX: ChirpHandler parses an incoming ParameterRequest with:

    • op = Set
    • name = "attitudectrl.kp_roll"
    • type = Float
    • length = 4
    • value = { bytes of 18.0f }
    • transaction_id = 42 (example)
  2. Callback dispatch: ChirpHandler calls ParameterManager::chirp_param_request_callback(req).

  3. Manager logic (simplified):

    void ParameterManager::chirp_param_request_callback(const chirp::ParameterRequest& req) {
      chirp::ParameterResponse resp{};
      resp.transaction_id = req.transaction_id;
      resp.timestamp = micros64(); // or timeManager.time() etc.
    
      if (req.op == ParamOp::Set) {
        bool reboot_required = false;
        ParamStatus st = set_by_name((const char*)req.name, req.value, req.length, req.type, reboot_required);
    
        resp.status = to_wire_status(st, reboot_required);
        resp.name_size = req.name_size;
        memcpy(resp.name, req.name, req.name_size);
        // Echo type/length; optionally include the applied value via a getter or copy directly from request
        send_parameter_response(resp);
      }
      else if (req.op == ParamOp::Get) {
        uint8_t buf[256]; uint16_t len = sizeof(buf); TypeId t;
        ParamStatus st = get_by_name((const char*)req.name, buf, len, t);
    
        resp.status = to_wire_status(st, /*reboot_required*/false);
        resp.type = t; resp.length = len;
        memcpy(resp.value, buf, len);
        send_parameter_response(resp);
      }
    }
    
  4. Validation path inside set_by_name:

    • Lookup: registry.find(name) → descriptor d; if not found → NotFound.

    • Type/length check: req.type == d.type and req.length == d.length; else → InvalidType.

    • Access check:

      • If !(d.flags & Writable)AccessDenied.
      • If is_armed() && (d.flags & FlightLocked)AccessDenied.
    • Apply:

      • Call d.setter(req.value, req.length, reboot_required).
      • If setter returns InvalidValue (e.g., out of bounds) → reply InvalidValue.
      • If success and (d.flags & RebootRequired) || reboot_required → reply RebootRequired.
      • Else → reply Ok.
  5. Module effect:

    • AttitudeControllers m_kp_roll is updated to 18.0f and will be used by the next control step.

8) Validation Layers (where checks live)

  1. Manager-level (generic)

    • Name exists in registry.
    • Type/length match the descriptor.
    • Flags / access policy (Writable, FlightLocked).
  2. Module-level (semantic)

    • Bounds and relationships specific to the parameter. (Outside of min-max range, must be non-zero etc.)

Tiny helper for type/length check

static bool check_wire_matches_desc(TypeId wire_type, uint16_t wire_len, const ParamDescriptor& d) {
  return (wire_type == d.type) && (wire_len == d.length);
}

9) Example: Wiring it Together

Register the Chirp callback once

void ParameterManager::init(ChirpHandler& chirp) {
  chirp.register_callback(chirp::Opcode::ParameterRequest,
    etl::delegate<void(const chirp::MessageUnpacked&)>::create<ParameterManager, &ParameterManager::on_any_message>(this));
}

void ParameterManager::on_any_message(const chirp::MessageUnpacked& msg) {
  // translate msg → ParameterRequest or ignore if not ParamRequest
  chirp::ParameterRequest req;
  if (!decode_parameter_request(msg, req)) return;
  chirp_param_request_callback(req);
}

Set/Get APIs

ParamStatus ParameterManager::set_by_name(const char* name, const uint8_t* in, uint16_t in_len, TypeId type, bool& reboot_req) {
  reboot_req = false;
  const ParamDescriptor* d = m_reg.find(name);
  if (!d) return ParamStatus::NotFound;

  if (!check_wire_matches_desc(type, in_len, *d)) return ParamStatus::InvalidType;

  if (!(d->flags & (uint8_t)ParamFlag::Writable)) return ParamStatus::AccessDenied;
  if (is_armed() && (d->flags & (uint8_t)ParamFlag::FlightLocked)) return ParamStatus::AccessDenied;

  ParamStatus st = d->setter(in, in_len, reboot_req);
  return st;
}

ParamStatus ParameterManager::get_by_name(const char* name, uint8_t* out, uint16_t& out_len, TypeId& out_type) const {
  const ParamDescriptor* d = m_reg.find(name);
  if (!d) return ParamStatus::NotFound;
  out_type = d->type;
  return d->getter(out, out_len);
}

10) Non-Module/System Parameters

Treat these as their own roots or grouped under system.* / logger.* / build.*, etc.

  • logger.level — writable, immediate.
  • system.flight_mode — writable, may be FlightLocked.
  • build.git_sha — read-only (no Writable flag).
  • serial_pilot.baud_rateRebootRequired (or requires port re-init), adapter-owned.

They register exactly like module parameters (same ParamDescriptor pattern).


11) Future Considerations

  • Parameter Store and StagingBuffer (two-phase commit). → initial version applies immediately; I'd be a good idea to consider staging the changes and applying in a safe state for later versions.

  • Full-list enumeration & chunking (GetFullList) for sharing the parameter list with other devices.

  • Persistence to Flash/EEPROM


12) Checklist to Implement

  1. Implement ParamRegistry with ETL containers.
  2. Create ParameterManager singleton; wire to ChirpHandler.
  3. Convert 12 modules (e.g., attitudectrl, navigator) to implement enumerate_params and delegate setters/getters.
  4. Add a SerialParamAdapter as a reference hardware example.
  5. Bring up Get/Set by name end-to-end and test on PC and on Teensy.

TODOs

Add the full list of current parameters in flight controller, to this document.

Try to fit them in currently proposed structure with proper naming, flags.