Skip to main content

Module node_config_base

Module node_config_base 

Source
Expand description

The shared base config contract every node’s resolved config composes with.

§Problem

Node configs are validated by two separate, independent mechanisms today: a built-in node’s config goes through a hardcoded match over its node type (mecha10_schema_registry’s node_type_schema), a custom (project-authored) node’s config instead falls back to a file-lookup (mecha10_core::schema_validation::validate’s resolve_custom_node_schema_file). Neither path has anywhere for fields that are common/reserved across every node’s config to live - the one that already exists, RelayTopicsField, only reaches a node’s config by that node’s author remembering to embed it themselves via #[serde(flatten)], with nothing enforcing that every node actually does.

§Design

NodeConfigBase is that single shared place: every node’s config struct embeds it via #[serde(flatten)] pub base: NodeConfigBase at the top level, the same pattern RelayTopicsField already uses on its own - this struct simply becomes the one place that field (and any other base/reserved field) is declared, instead of every node’s config re-declaring it individually.

§What else lives here, and why (LAB-4384 follow-up)

Besides relay_topics, this struct also now owns:

  • topics (Topics) - every single built-in node declares a topics field of this exact same shared type (per-role publish/subscribe wiring). That universality is exactly the test this module’s own docs use for “belongs on NodeConfigBase”, so it moved too, not just relay_topics.
  • model (NodeModelConfig) - object-detector, image-classifier, and rl each declared their own near-identical “which model does this node use” struct; now a single shared type. llm-command’s analogous “which model”/“which backend” concept used to be two separate fields (llm_model: Option<String>, provider: Option<Provider>), but both are facets of the same “which model does this node use” concept - llm-command reads/writes model.name/model.provider via its own llm_model()/provider() accessors instead of declaring two more fields here.
  • task - the task a node self-reports it implements (e.g. a trained imitation-learning policy’s task name), surfaced over this node’s /system/health heartbeat (LAB-4825) so the dashboard can show which tasks currently have a live node, without a separate request/response protocol. Unlike the fields above, this isn’t a field moved off an existing node - no built-in node sets it yet; it’s shared plumbing for a future task-running node.
  • status_rate_hz - camera/motor/teleop each declared an identical “how often, in Hz, to publish node status updates” field with a per-node default (camera: 1.0, motor/teleop: 10.0) - same shared-shape-with-per-node-default treatment as model.
  • controller - imu/motor each declare a {"type": "mock"|<hardware-name>, ...} tagged-union field selecting a mock vs. real hardware backend. Unlike model/topics, there’s no single Rust type to share here - every field beyond type is already a per-node, per-variant free-form shape (imu’s mpu6050 has i2c_bus/i2c_address, motor’s l298n has different pins entirely), so this is carried as an opaque serde_json::Value rather than a shared enum. Each node deserializes it into its own strongly-typed enum via its own controller() accessor (mirroring the model() pattern) - this field only relocates where the JSON key lives, not how it’s validated.
  • orientation - physical-mounting correction, for the nodes that have this concept (camera, servo, pan-tilt). Same opaque-serde_json::Value treatment as controller and for the same reason: camera’s shape (flip_horizontal/flip_vertical, an image transform) and servo/pan-tilt’s shape (invert/offset_rad, an angle pre-transform applied before the existing angle->pulse-width calibration) are fundamentally different payloads with nothing in common beyond the field name.

All of these are Option/empty-default at this type’s level - this struct never bakes in any particular node’s real default value (e.g. object-detector’s default model name differs from rl’s), it only recognizes the field exists at the top level so split_base_and_custom doesn’t drop it. Each node resolves its own real default from an absent/empty value itself - see each affected node’s own model()/status_rate_hz()/ controller() accessor.

Each node’s own config-specific fields live nested under a custom key in the actual resolved config JSON, not flattened alongside the base fields - so a base field and a node-specific field can never collide on the same name.

This struct’s schemars-derived JSON Schema (schemars::schema_for!(NodeConfigBase), exported as base.schema.json) is the schema every node’s full config schema composes with (LAB-4387’s compose_node_schema). LAB-4388 wired that composition into a built-in node’s own <name>.schema.json file, and LAB-4389 deleted the hardcoded per-built-in-type dispatch mecha10_core::schema_validation::validate used to consult first, leaving the <name>.schema.json file lookup every node (built-in or custom) now shares as the only resolution path. Migrating any existing node’s config struct onto this type is LAB-4386. This is a hard breaking change for existing on-disk node configs once that work lands - there is deliberately no dual-read/legacy fallback for the pre-custom-key shape.

Structs§

NodeConfigBase
Fields common to every node’s configuration, reserved across all node types.

Functions§

default_frame_skip
Shared default for a frame_skip: u32 field (process every frame) - used by any node config that has one (e.g. object-detector, rl) so they don’t each declare an identical private fn default_frame_skip() -> u32 { 1 }. Not part of NodeConfigBase itself - each node still declares its own frame_skip field; this is only the shared default-value helper.