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 atopicsfield 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 onNodeConfigBase”, so it moved too, not justrelay_topics.model(NodeModelConfig) -object-detector,image-classifier, andrleach 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-commandreads/writesmodel.name/model.providervia its ownllm_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/healthheartbeat (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/teleopeach 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 asmodel.controller-imu/motoreach declare a{"type": "mock"|<hardware-name>, ...}tagged-union field selecting a mock vs. real hardware backend. Unlikemodel/topics, there’s no single Rust type to share here - every field beyondtypeis already a per-node, per-variant free-form shape (imu’smpu6050hasi2c_bus/i2c_address,motor’sl298nhas different pins entirely), so this is carried as an opaqueserde_json::Valuerather than a shared enum. Each node deserializes it into its own strongly-typed enum via its owncontroller()accessor (mirroring themodel()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::Valuetreatment ascontrollerand 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§
- Node
Config Base - Fields common to every node’s configuration, reserved across all node types.
Functions§
- default_
frame_ skip - Shared default for a
frame_skip: u32field (process every frame) - used by any node config that has one (e.g.object-detector,rl) so they don’t each declare an identical privatefn default_frame_skip() -> u32 { 1 }. Not part ofNodeConfigBaseitself - each node still declares its ownframe_skipfield; this is only the shared default-value helper.