Skip to content

Commit 618d32c

Browse files
committed
feat(vehicle-components): add bidirectional ESC connections and frame class import
Rename ESC "FC Connection" to "FC->ESC Connection" to clarify signal direction, and add a separate "ESC->FC Telemetry" path to model the return telemetry channel independently. Backward compatibility is preserved via automatic migration of legacy "FC Connection" data on load. BREAKING CHANGE: ESC component path "FC Connection" is replaced by "FC->ESC Connection"; existing vehicle_components.json files are migrated automatically on first load.
1 parent 5c08529 commit 618d32c

13 files changed

Lines changed: 164 additions & 48 deletions

ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def wipe_component_info(self) -> None:
403403
},
404404
},
405405
"ESC": {
406-
"FC Connection": {"Type": "Main Out", "Protocol": "Normal"},
406+
"FC->ESC Connection": {"Type": "Main Out", "Protocol": "Normal"},
407407
},
408408
"Motors": {
409409
"Specifications": {"Poles": 14},

ardupilot_methodic_configurator/data_model_template_overview.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def __init__(self, components_data: dict) -> None:
2929
self.prop_diameter_inches = components_data.get("Propellers", {}).get("Specifications", {}).get("Diameter_inches", "")
3030
self.rc_protocol = components_data.get("RC Receiver", {}).get("FC Connection", {}).get("Protocol", "")
3131
self.telemetry_model = components_data.get("Telemetry", {}).get("Product", {}).get("Model", "")
32-
self.esc_protocol = components_data.get("ESC", {}).get("FC Connection", {}).get("Protocol", "")
32+
self.esc_protocol = components_data.get("ESC", {}).get("FC->ESC Connection", {}).get("Protocol", "")
3333
self.gnss_model = components_data.get("GNSS Receiver", {}).get("Product", {}).get("Model", "")
3434
self.gnss_connection = components_data.get("GNSS Receiver", {}).get("FC Connection", {}).get("Type", "")
3535

ardupilot_methodic_configurator/data_model_vehicle_components_base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,13 @@ def update_json_structure(
278278
components = self._data.setdefault("Components", {})
279279
components["GNSS Receiver"] = components.pop("GNSS receiver")
280280

281+
# Handle legacy ESC connection path rename (old projects might use FC Connection)
282+
esc_component = self._data.get("Components", {}).get("ESC")
283+
if isinstance(esc_component, dict) and "FC Connection" in esc_component:
284+
esc_connection = esc_component.pop("FC Connection")
285+
esc_component.setdefault("FC->ESC Connection", {}).update(esc_connection)
286+
self._data["Components"]["ESC"] = esc_component
287+
281288
# Handle legacy battery monitor protocol migration for protocols that don't need hardware connections
282289
# This is a local import to avoid a circular import dependency
283290
from ardupilot_methodic_configurator.data_model_vehicle_components_validation import ( # pylint: disable=import-outside-toplevel, cyclic-import # noqa: PLC0415

ardupilot_methodic_configurator/data_model_vehicle_components_import.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,10 @@ def _set_serial_type_from_fc_parameters( # pylint: disable=too-many-branches,to
303303
elif component == "ESC":
304304
if esc == 1:
305305
# Only set component values for the first ESC
306-
self.set_component_value(("ESC", "FC Connection", "Type"), serial)
307-
self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol)
306+
self.set_component_value(("ESC", "FC->ESC Connection", "Type"), serial)
307+
self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol)
308+
self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), serial)
309+
self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), protocol)
308310
# Count all ESC components
309311
esc += 1
310312

@@ -323,18 +325,18 @@ def _set_esc_type_from_fc_parameters(self, fc_parameters: dict[str, float], doc:
323325

324326
# if any element of main_out_functions is in [33, 34, 35, 36] then ESC is connected to main_out
325327
if any(servo_function in {33, 34, 35, 36} for servo_function in main_out_functions):
326-
self.set_component_value(("ESC", "FC Connection", "Type"), "Main Out")
328+
self.set_component_value(("ESC", "FC->ESC Connection", "Type"), "Main Out")
327329
else:
328-
self.set_component_value(("ESC", "FC Connection", "Type"), "AIO")
330+
self.set_component_value(("ESC", "FC->ESC Connection", "Type"), "AIO")
329331

330332
if "MOT_PWM_TYPE" in doc and "values" in doc["MOT_PWM_TYPE"]:
331333
protocol = doc["MOT_PWM_TYPE"]["values"].get(str(mot_pwm_type), "")
332334
if protocol:
333-
self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol)
335+
self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol)
334336
# Fallback to MOT_PWM_TYPE_DICT if doc is not available
335337
elif str(mot_pwm_type) in MOT_PWM_TYPE_DICT:
336338
protocol = str(MOT_PWM_TYPE_DICT[str(mot_pwm_type)]["protocol"])
337-
self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol)
339+
self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol)
338340

339341
def _set_battery_type_from_fc_parameters(self, fc_parameters: dict[str, float]) -> None: # pylint: disable=too-many-branches
340342
"""Process battery monitor parameters and update the data model."""

ardupilot_methodic_configurator/data_model_vehicle_components_validation.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) ->
8484
("RC Receiver", "FC Connection", "Type"),
8585
("Telemetry", "FC Connection", "Type"),
8686
("Battery Monitor", "FC Connection", "Type"),
87-
("ESC", "FC Connection", "Type"),
87+
("ESC", "FC->ESC Connection", "Type"),
88+
("ESC", "ESC->FC Telemetry", "Type"),
8889
("GNSS Receiver", "FC Connection", "Type"),
8990
]
9091

@@ -237,6 +238,15 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) ->
237238
"65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16
238239
}
239240

241+
# ESC->FC telemetry connections
242+
ESC_TELEMETRY_DICT: dict[str, dict[str, Union[list[str], str]]] = {
243+
"0": {"type": ["None"], "protocol": "None"},
244+
"1": {"type": PWM_OUT_PORTS, "protocol": "BDShot"},
245+
"2": {"type": CAN_PORTS, "protocol": "DroneCAN"},
246+
"3": {"type": SERIAL_PORTS, "protocol": "DShot"},
247+
"4": {"type": SERIAL_PORTS, "protocol": "FETtec OneWire"},
248+
}
249+
240250

241251
class ComponentDataModelValidation(ComponentDataModelBase):
242252
"""
@@ -338,16 +348,29 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]:
338348
),
339349
("Battery Monitor", "FC Connection", "Type"): get_connection_types(BATT_MONITOR_CONNECTION),
340350
("Battery Monitor", "FC Connection", "Protocol"): get_combobox_values("BATT_MONITOR"),
341-
("ESC", "FC Connection", "Type"): (*PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS),
342-
("ESC", "FC Connection", "Protocol"): self._mot_pwm_types,
351+
("ESC", "FC->ESC Connection", "Type"): (*PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS),
352+
("ESC", "FC->ESC Connection", "Protocol"): self._mot_pwm_types,
343353
("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS),
344354
("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION),
345355
("Battery", "Specifications", "Chemistry"): BatteryCell.chemistries(),
346356
}
347357
for component in ["RC Receiver", "Telemetry", "Battery Monitor", "ESC", "GNSS Receiver"]:
348-
if component in self._data["Components"]:
358+
if component not in self._data.get("Components", {}):
359+
continue
360+
361+
if component == "ESC":
362+
self._update_possible_choices_for_path(
363+
("ESC", "FC->ESC Connection", "Type"),
364+
self.get_component_value(("ESC", "FC->ESC Connection", "Type")),
365+
)
366+
self._update_possible_choices_for_path(
367+
("ESC", "ESC->FC Telemetry", "Type"),
368+
self.get_component_value(("ESC", "ESC->FC Telemetry", "Type")),
369+
)
370+
else:
349371
self._update_possible_choices_for_path(
350-
(component, "FC Connection", "Type"), self.get_component_value((component, "FC Connection", "Type"))
372+
(component, "FC Connection", "Type"),
373+
self.get_component_value((component, "FC Connection", "Type")),
351374
)
352375

353376
def _update_possible_choices_for_path( # pylint: disable=too-many-branches

ardupilot_methodic_configurator/vehicle_components_schema.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"description": "Main power source for the vehicle"
5353
},
5454
"ESC": {
55-
"$ref": "#/definitions/connectionComponent",
55+
"$ref": "#/definitions/escComponent",
5656
"description": "Electronic Speed Controller for the motors"
5757
},
5858
"Motors": {
@@ -242,6 +242,29 @@
242242
}
243243
]
244244
},
245+
"escComponent": {
246+
"allOf": [
247+
{ "$ref": "#/definitions/baseComponent" },
248+
{
249+
"properties": {
250+
"Firmware": {
251+
"$ref": "#/definitions/firmware",
252+
"description": "ESC firmware information",
253+
"x-is-optional": true
254+
},
255+
"FC->ESC Connection": {
256+
"$ref": "#/definitions/fcConnection",
257+
"description": "Data path from flight controller to ESC"
258+
},
259+
"ESC->FC Telemetry": {
260+
"$ref": "#/definitions/fcConnection",
261+
"description": "Telemetry path from ESC to flight controller (if applicable)"
262+
}
263+
},
264+
"description": "Electronic Speed Controller component with (optional) telemetry"
265+
}
266+
]
267+
},
245268
"battery": {
246269
"allOf": [
247270
{ "$ref": "#/definitions/baseComponent" },

tests/acceptance_template_import_from_params.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,8 +730,8 @@ def get_inferable_fields() -> list[tuple[str, str, str]]:
730730
("Battery", "Specifications", "Capacity mAh"),
731731
("Battery Monitor", "FC Connection", "Type"),
732732
("Battery Monitor", "FC Connection", "Protocol"),
733-
("ESC", "FC Connection", "Type"),
734-
("ESC", "FC Connection", "Protocol"),
733+
("ESC", "FC->ESC Connection", "Type"),
734+
("ESC", "FC->ESC Connection", "Protocol"),
735735
("GNSS Receiver", "FC Connection", "Type"),
736736
("GNSS Receiver", "FC Connection", "Protocol"),
737737
("RC Receiver", "FC Connection", "Type"),

tests/test_data_model_vehicle_components_base.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,36 @@ def test_system_recreates_missing_battery_component(self, realistic_model) -> No
606606
assert "Battery" in realistic_model._data["Components"]
607607
assert "Specifications" in realistic_model._data["Components"]["Battery"]
608608

609+
def test_system_migrates_legacy_esc_fc_connection_path(self) -> None:
610+
"""
611+
System migrates legacy ESC FC Connection to FC->ESC Connection when loading old files.
612+
613+
GIVEN: A model with old ESC path data
614+
WHEN: update_json_structure is called
615+
THEN: ESC data is moved to the new FC->ESC Connection path and old path removed
616+
"""
617+
initial_data = {
618+
"Components": {
619+
"ESC": {
620+
"Product": {"Manufacturer": "Test"},
621+
"FC Connection": {"Type": "Main Out", "Protocol": "DShot600"},
622+
}
623+
},
624+
"Format version": 1,
625+
}
626+
627+
vehicle_components = VehicleComponents()
628+
schema = VehicleComponentsJsonSchema(vehicle_components.load_schema())
629+
component_datatypes = schema.get_all_value_datatypes()
630+
model = ComponentDataModelBase(initial_data, component_datatypes, schema)
631+
632+
model.update_json_structure()
633+
634+
assert "FC Connection" not in model._data["Components"]["ESC"]
635+
assert "FC->ESC Connection" in model._data["Components"]["ESC"]
636+
assert model._data["Components"]["ESC"]["FC->ESC Connection"]["Type"] == "Main Out"
637+
assert model._data["Components"]["ESC"]["FC->ESC Connection"]["Protocol"] == "DShot600"
638+
609639
def test_system_recreates_missing_flight_controller_specifications(self, realistic_model) -> None:
610640
"""
611641
System recreates missing Flight Controller Specifications sub-section.
@@ -882,7 +912,7 @@ def test_system_handles_sequential_access_to_different_components(self, basic_mo
882912
(("Battery", "Specifications", "Capacity mAh"), 2000),
883913
(("Frame", "Specifications", "Weight Kg"), 1.5),
884914
(("Flight Controller", "Product", "Manufacturer"), "TestCorp"),
885-
(("ESC", "FC Connection", "Protocol"), "DShot600"),
915+
(("ESC", "FC->ESC Connection", "Protocol"), "DShot600"),
886916
(("Motors", "Specifications", "Poles"), 14),
887917
]
888918

tests/test_data_model_vehicle_components_import.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,8 @@ def test_system_detects_multiple_serial_esc_connections(self, realistic_model) -
229229

230230
result = realistic_model._set_serial_type_from_fc_parameters(fc_parameters)
231231

232-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
233-
esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol"))
232+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
233+
esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
234234
assert esc_type == "SERIAL1"
235235
assert esc_protocol == "ESC Telem"
236236
assert result is True # Multiple ESCs
@@ -273,8 +273,8 @@ def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None:
273273

274274
realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc)
275275

276-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
277-
esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol"))
276+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
277+
esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
278278
assert esc_type == "Main Out"
279279
assert esc_protocol == "DShot600"
280280

@@ -299,11 +299,35 @@ def test_system_imports_esc_aio_configuration(self, realistic_model) -> None:
299299

300300
realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc)
301301

302-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
303-
esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol"))
302+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
303+
esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
304304
assert esc_type == "AIO"
305305
assert esc_protocol == "DShot600"
306306

307+
def test_user_can_import_esc_connection_and_telemetry_from_serial_fc(self, realistic_model) -> None:
308+
"""
309+
Import ESC serial config into FC->ESC Connection and ESC->FC Telemetry.
310+
311+
GIVEN: Flight controller serial port protocol maps to ESC.
312+
313+
WHEN: User imports serial port configuration.
314+
315+
THEN: ESC FC->ESC Connection and ESC->FC Telemetry should be populated.
316+
"""
317+
fc_parameters = {"SERIAL1_PROTOCOL": 38}
318+
319+
realistic_model._set_serial_type_from_fc_parameters(fc_parameters)
320+
321+
esc_conn_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
322+
esc_conn_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
323+
esc_telemetry_type = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Type"))
324+
esc_telemetry_protocol = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Protocol"))
325+
326+
assert esc_conn_type == "SERIAL1"
327+
assert esc_conn_protocol == "FETtecOneWire"
328+
assert esc_telemetry_type == "SERIAL1"
329+
assert esc_telemetry_protocol == "FETtecOneWire"
330+
307331
def test_user_can_import_battery_monitor_configuration(self, realistic_model) -> None:
308332
"""
309333
User can import battery monitor configuration.
@@ -447,7 +471,7 @@ def test_system_skips_disabled_serial_ports(self, realistic_model) -> None:
447471

448472
realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc)
449473

450-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
474+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
451475
assert esc_type == "AIO" # Should default to AIO when no main out functions
452476

453477
def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model) -> None:
@@ -468,7 +492,7 @@ def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model)
468492

469493
realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc)
470494

471-
esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol"))
495+
esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
472496
assert esc_protocol == "DShot600"
473497

474498
def test_system_handles_esc_protocol_not_found(self, realistic_model) -> None:
@@ -972,8 +996,8 @@ def test_user_can_import_complete_vehicle_configuration(self, realistic_model, s
972996
assert realistic_model.get_component_value(("GNSS Receiver", "FC Connection", "Type")) == "SERIAL2"
973997
assert realistic_model.get_component_value(("RC Receiver", "FC Connection", "Type")) == "SERIAL3"
974998
assert realistic_model.get_component_value(("RC Receiver", "FC Connection", "Protocol")) == "CRSF"
975-
assert realistic_model.get_component_value(("ESC", "FC Connection", "Type")) == "Main Out"
976-
assert realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) == "DShot600"
999+
assert realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) == "Main Out"
1000+
assert realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) == "DShot600"
9771001
assert realistic_model.get_component_value(("Motors", "Specifications", "Poles")) == 14
9781002
assert (
9791003
realistic_model.get_component_value(("Battery Monitor", "FC Connection", "Protocol"))
@@ -1005,8 +1029,8 @@ def test_system_prioritizes_serial_esc_over_pwm_esc(self, realistic_model, sampl
10051029
realistic_model.process_fc_parameters(fc_parameters, doc)
10061030

10071031
# Should use serial ESC, not PWM ESC
1008-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
1009-
esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol"))
1032+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
1033+
esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol"))
10101034
assert esc_type == "SERIAL1"
10111035
assert esc_protocol == "FETtecOneWire"
10121036

@@ -1186,7 +1210,7 @@ def test_system_detects_main_out_vs_aio_from_servo_functions(self, realistic_mod
11861210

11871211
realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc)
11881212

1189-
esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type"))
1213+
esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type"))
11901214
assert esc_type == expected_esc_type, f"Failed for servo functions {servo_functions}"
11911215

11921216
def test_gps1_type_parameter_support(self, realistic_model) -> None:

0 commit comments

Comments
 (0)