From dc24a3ba57eca27a653d80b41b32926dcc75c1ab Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Mon, 23 Mar 2026 19:22:39 +0100 Subject: [PATCH 1/3] feat(vehicle_components): add bidirectional ESC connections 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. Improve support for Rover ESCs Improve Heli support as well BREAKING CHANGE: ESC component path "FC Connection" is replaced by "FC->ESC Connection"; existing vehicle_components.json files are migrated automatically on first load. --- .../annotate_params.py | 1 + .../backend_filesystem_vehicle_components.py | 3 +- .../configuration_steps_ArduCopter.json | 4 +- .../configuration_steps_ArduPlane.json | 4 +- .../configuration_steps_Heli.json | 4 +- .../configuration_steps_Rover.json | 25 +- .../data_model_template_overview.py | 2 +- .../data_model_vehicle_components_base.py | 14 + .../data_model_vehicle_components_import.py | 161 ++++++--- ...ata_model_vehicle_components_validation.py | 328 ++++++++++++------ .../vehicle_components_schema.json | 25 +- .../configuration_steps_ArduPlane.json | 4 +- .../Heli/OMP_M4/configuration_steps_Heli.json | 4 +- update_vehicle_templates.py | 19 + 14 files changed, 416 insertions(+), 182 deletions(-) diff --git a/ardupilot_methodic_configurator/annotate_params.py b/ardupilot_methodic_configurator/annotate_params.py index dab6257e1..2668d7485 100755 --- a/ardupilot_methodic_configurator/annotate_params.py +++ b/ardupilot_methodic_configurator/annotate_params.py @@ -580,6 +580,7 @@ def get_fallback_xml_url(vehicle_type: str, firmware_version: str) -> str: "ArduPlane": "Plane-", "Rover": "Rover-", "ArduSub": "Sub-", + "Heli": "Copter-", } try: vehicle_subdir = vehicle_parm_subdir[vehicle_type] + firmware_version[0:3] diff --git a/ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py b/ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py index 99e5ffada..8de54b708 100644 --- a/ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py +++ b/ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py @@ -403,7 +403,8 @@ def wipe_component_info(self) -> None: }, }, "ESC": { - "FC Connection": {"Type": "Main Out", "Protocol": "Normal"}, + "FC->ESC Connection": {"Type": "Main Out", "Protocol": "Normal"}, + "ESC->FC Telemetry": {"Type": "None", "Protocol": "None"}, }, "Motors": { "Specifications": {"Poles": 14}, diff --git a/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json b/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json index 945150532..2ad8bfe89 100644 --- a/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json +++ b/ardupilot_methodic_configurator/configuration_steps_ArduCopter.json @@ -106,11 +106,11 @@ }, "derived_parameters": { "ESC_HW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { diff --git a/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json b/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json index 837a4f49d..3cb5a77fb 100644 --- a/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json +++ b/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json @@ -106,11 +106,11 @@ }, "derived_parameters": { "ESC_HW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { diff --git a/ardupilot_methodic_configurator/configuration_steps_Heli.json b/ardupilot_methodic_configurator/configuration_steps_Heli.json index 0a59005e3..77ddf50a1 100644 --- a/ardupilot_methodic_configurator/configuration_steps_Heli.json +++ b/ardupilot_methodic_configurator/configuration_steps_Heli.json @@ -106,11 +106,11 @@ }, "derived_parameters": { "ESC_HW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { diff --git a/ardupilot_methodic_configurator/configuration_steps_Rover.json b/ardupilot_methodic_configurator/configuration_steps_Rover.json index 3025e0a8d..4e6222afc 100644 --- a/ardupilot_methodic_configurator/configuration_steps_Rover.json +++ b/ardupilot_methodic_configurator/configuration_steps_Rover.json @@ -106,11 +106,11 @@ }, "derived_parameters": { "ESC_HW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { @@ -129,14 +129,14 @@ "BATT_FS_LOW_ACT": { "New Value": 2, "Change Reason": "Return and land at home or rally point" } }, "derived_parameters": { - "BATT_ARM_VOLT": { "New Value": "(vehicle_components['Battery']['Specifications']['Number of cells']-1)*0.1+(vehicle_components['Battery']['Specifications']['Volt per cell crit']+0.3)*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Do not allow arming below this voltage" }, - "BATT_CAPACITY": { "New Value": "(vehicle_components['Battery']['Specifications']['Capacity mAh'])", "Change Reason": "Total battery capacity specified in the component editor" }, - "BATT_CRT_VOLT": { "New Value": "(vehicle_components['Battery']['Specifications']['Volt per cell crit'])*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "(Critical voltage + 0.0) x no. of cells" }, - "BATT_LOW_VOLT": { "New Value": "(vehicle_components['Battery']['Specifications']['Volt per cell low'])*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "(Low voltage + 0.0) x no. of cells" }, + "BATT_ARM_VOLT": { "New Value": "vehicle_components['Battery']['Specifications']['Volt per cell arm']*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Only arm above this voltage, to avoid taking off with insufficient battery capacity" }, + "BATT_CAPACITY": { "New Value": "vehicle_components['Battery']['Specifications']['Capacity mAh']", "Change Reason": "Total battery capacity specified in the component editor" }, + "BATT_CRT_VOLT": { "New Value": "vehicle_components['Battery']['Specifications']['Volt per cell crit']*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Critical failsafe voltage x nr. of cells" }, + "BATT_LOW_VOLT": { "New Value": "vehicle_components['Battery']['Specifications']['Volt per cell low']*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Low failsafe voltage x nr. of cells" }, "BATT_MONITOR": { "New Value": "vehicle_components['Battery Monitor']['FC Connection']['Protocol']", "Change Reason": "Selected in component editor window" }, "BATT_I2C_BUS": { "New Value": "1 if vehicle_components['Battery Monitor']['FC Connection']['Type'] == 'I2C2' else 2 if vehicle_components['Battery Monitor']['FC Connection']['Type'] == 'I2C3' else 3 if vehicle_components['Battery Monitor']['FC Connection']['Type'] == 'I2C4' else 0", "Change Reason": "Selected in component editor window" }, - "MOT_BAT_VOLT_MAX": { "New Value": "(vehicle_components['Battery']['Specifications']['Volt per cell max']+0.0)*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "(Max voltage + 0.0) x no. of cells" }, - "MOT_BAT_VOLT_MIN": { "New Value": "(vehicle_components['Battery']['Specifications']['Volt per cell crit']+0.0)*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "(Critical voltage + 0.0) x no. of cells" } + "MOT_BAT_VOLT_MAX": { "New Value": "vehicle_components['Battery']['Specifications']['Volt per cell max']*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Scale the PIDs up when battery voltage is below this threshold" }, + "MOT_BAT_VOLT_MIN": { "New Value": "vehicle_components['Battery']['Specifications']['Volt per cell min']*vehicle_components['Battery']['Specifications']['Number of cells']", "Change Reason": "Scale the PIDs up when battery voltage is above this threshold" } }, "rename_connection": "vehicle_components['Battery Monitor']['FC Connection']['Type']", "old_filenames": [], @@ -170,7 +170,8 @@ "mandatory_text": "100% mandatory (0% optional)", "auto_changed_by": "", "derived_parameters": { - "GPS_TYPE": { "New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" } + "GPS_TYPE": { "New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" }, + "GPS1_TYPE": { "New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" } }, "rename_connection": "vehicle_components['GNSS Receiver']['FC Connection']['Type']", "old_filenames": [] @@ -269,7 +270,11 @@ "external_tool_url": "", "mandatory_text": "40% mandatory (60% optional)", "auto_changed_by": "", - "old_filenames": ["14_motor.param"] + "old_filenames": ["14_motor.param"], + "plugin": { + "name": "motor_test", + "placement": "left" + } }, "16_pid_adjustment.param": { "why": "With very large or very small vehicles the default PID values are not suitable for the first flight", diff --git a/ardupilot_methodic_configurator/data_model_template_overview.py b/ardupilot_methodic_configurator/data_model_template_overview.py index c15a35d56..d7965ea43 100644 --- a/ardupilot_methodic_configurator/data_model_template_overview.py +++ b/ardupilot_methodic_configurator/data_model_template_overview.py @@ -29,7 +29,7 @@ def __init__(self, components_data: dict) -> None: self.prop_diameter_inches = components_data.get("Propellers", {}).get("Specifications", {}).get("Diameter_inches", "") self.rc_protocol = components_data.get("RC Receiver", {}).get("FC Connection", {}).get("Protocol", "") self.telemetry_model = components_data.get("Telemetry", {}).get("Product", {}).get("Model", "") - self.esc_protocol = components_data.get("ESC", {}).get("FC Connection", {}).get("Protocol", "") + self.esc_protocol = components_data.get("ESC", {}).get("FC->ESC Connection", {}).get("Protocol", "") self.gnss_model = components_data.get("GNSS Receiver", {}).get("Product", {}).get("Model", "") self.gnss_connection = components_data.get("GNSS Receiver", {}).get("FC Connection", {}).get("Type", "") diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_base.py b/ardupilot_methodic_configurator/data_model_vehicle_components_base.py index 83abcb326..04af2f4ea 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_base.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_base.py @@ -278,6 +278,20 @@ def update_json_structure( components = self._data.setdefault("Components", {}) components["GNSS Receiver"] = components.pop("GNSS receiver") + # Handle legacy ESC connection path rename (old projects might use FC Connection) + esc_component = self._data.get("Components", {}).get("ESC") + if isinstance(esc_component, dict) and "FC Connection" in esc_component: + esc_connection = esc_component.pop("FC Connection") + esc_component.setdefault("FC->ESC Connection", {}).update(esc_connection) + self._data["Components"]["ESC"] = esc_component + + # Ensure ESC->FC Telemetry section exists for files saved before it was introduced. + # setdefault leaves any existing values untouched; the neutral defaults here are overwritten + # by process_fc_parameters() once FC parameters are available. + esc_component = self._data.get("Components", {}).get("ESC") + if isinstance(esc_component, dict) and "FC->ESC Connection" in esc_component: + esc_component.setdefault("ESC->FC Telemetry", {"Type": "None", "Protocol": "None"}) + # Handle legacy battery monitor protocol migration for protocols that don't need hardware connections # This is a local import to avoid a circular import dependency from ardupilot_methodic_configurator.data_model_vehicle_components_validation import ( # pylint: disable=import-outside-toplevel, cyclic-import # noqa: PLC0415 diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py index 6c2a95b32..ff5d0d2b9 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py @@ -27,13 +27,15 @@ from ardupilot_methodic_configurator.data_model_vehicle_components_validation import ( BATT_MONITOR_CONNECTION, CAN_PORTS, + ESC_TELEMETRY_PROTOCOLS, GNSS_RECEIVER_CONNECTION, I2C_PORTS, - MOT_PWM_TYPE_DICT, RC_PROTOCOLS_DICT, SERIAL_PORTS, SERIAL_PROTOCOLS_DICT, + SERVO_FUNCTION_ESC_CONTROL, ComponentDataModelValidation, + get_mot_pwm_type_sub_dict, ) @@ -104,6 +106,16 @@ def _reverse_key_search( logging_error(_("No values found for %s in the metadata"), param_name) return fallbacks + def _is_protocol_rename_exception(self, code_protocol: str, doc_protocol: str) -> bool: + """Check if a protocol mismatch is due to a known ArduPilot version rename.""" + return ( + (code_protocol == "Septentrio(SBF)" and doc_protocol == "SBF") + or (code_protocol == "Trimble(GSOF)" and doc_protocol == "GSOF") + or (code_protocol == "MAVLink" and doc_protocol == "MAV") + or (code_protocol == "Septentrio-DualAntenna(SBF)" and doc_protocol == "SBF-DualAntenna") + or (code_protocol == "Gimbal" and doc_protocol == "SToRM32 Gimbal Serial") + ) + def _verify_dict_is_uptodate( self, doc: dict[str, Any], dict_to_check: dict[str, dict[str, Any]], doc_key: str, doc_dict: str ) -> bool: @@ -137,7 +149,9 @@ def _verify_dict_is_uptodate( if check_key in dict_to_check: code_protocol = dict_to_check[check_key].get("protocol", None) - if code_protocol != doc_protocol: + if code_protocol is not None and code_protocol != doc_protocol: + if self._is_protocol_rename_exception(code_protocol, doc_protocol): + continue logging_warning(_("Protocol %s does not match %s in %s metadata"), code_protocol, doc_protocol, doc_key) is_valid = False else: @@ -165,7 +179,8 @@ def process_fc_parameters( self._verify_dict_is_uptodate(doc, GNSS_RECEIVER_CONNECTION, "GPS1_TYPE", "values") elif "GPS_TYPE" in doc: self._verify_dict_is_uptodate(doc, GNSS_RECEIVER_CONNECTION, "GPS_TYPE", "values") - self._verify_dict_is_uptodate(doc, MOT_PWM_TYPE_DICT, "MOT_PWM_TYPE", "values") + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + self._verify_dict_is_uptodate(doc, get_mot_pwm_type_sub_dict(fw_type), "MOT_PWM_TYPE", "values") self._verify_dict_is_uptodate(doc, RC_PROTOCOLS_DICT, "RC_PROTOCOLS", "Bitmask") # Process parameters in sequence @@ -191,11 +206,11 @@ def _set_gnss_type_from_fc_parameters(self, fc_parameters: dict) -> None: if str(gps1_type) in GNSS_RECEIVER_CONNECTION: gps1_connection_type = GNSS_RECEIVER_CONNECTION[str(gps1_type)].get("type") gps1_connection_protocol = GNSS_RECEIVER_CONNECTION[str(gps1_type)].get("protocol") - # Normalize gps1_connection_type to a list for consistent handling + # Normalize gps1_connection_type to a tuple for consistent handling if isinstance(gps1_connection_type, str): - gps1_connection_type = [gps1_connection_type] - # gps1_connection_type is now a list of possible connection types - if gps1_connection_type == ["None"]: + gps1_connection_type = (gps1_connection_type,) + # gps1_connection_type is now a tuple of possible connection types + if gps1_connection_type == ("None",): self.set_component_value(("GNSS Receiver", "FC Connection", "Type"), "None") self.set_component_value(("GNSS Receiver", "FC Connection", "Protocol"), "None") elif gps1_connection_type and any(conn_type in SERIAL_PORTS for conn_type in gps1_connection_type): @@ -231,35 +246,50 @@ def _set_gnss_type_from_fc_parameters(self, fc_parameters: dict) -> None: logging_error("%s value %u not in GNSS_RECEIVER_CONNECTION", param_name, gps1_type) self.set_component_value(("GNSS Receiver", "FC Connection", "Type"), "None") - def _set_serial_type_from_fc_parameters( # pylint: disable=too-many-branches,too-many-statements # noqa: PLR0915 - self, fc_parameters: dict - ) -> bool: + def _set_serial_type_from_fc_parameters(self, fc_parameters: dict) -> bool: """Process serial port parameters and update the data model. Returns True if ESC is serial controlled.""" + self._process_rc_protocols(fc_parameters) + return self._process_serial_components(fc_parameters) + + def _process_rc_protocols(self, fc_parameters: dict) -> None: + """Process RC_PROTOCOLS parameter and set RC receiver protocol and type.""" if "RC_PROTOCOLS" in fc_parameters: try: rc_protocols_nr = int(fc_parameters["RC_PROTOCOLS"]) except (ValueError, TypeError): logging_error(_("Invalid non-integer value for RC_PROTOCOLS %s"), fc_parameters["RC_PROTOCOLS"]) rc_protocols_nr = 0 - # RC_PROTOCOLS is a bitmask where each bit represents an enabled protocol - # Only set a specific protocol if exactly one bit is set (power of 2) - # If multiple bits are set, we can't determine which protocol is actually in use - if is_single_bit_set(rc_protocols_nr): - # Exactly one bit is set (power of 2) - use the value directly as the key - rc_value = str(rc_protocols_nr) - if rc_value in RC_PROTOCOLS_DICT: - protocol = RC_PROTOCOLS_DICT[rc_value].get("protocol") - self.set_component_value(("RC Receiver", "FC Connection", "Protocol"), str(protocol)) - elif rc_protocols_nr > 0: - # Multiple bits are set - cannot determine which protocol is active - logging_error( - _("RC_PROTOCOLS has multiple protocols enabled (%d). Cannot determine active protocol."), rc_protocols_nr - ) - rc = 1 - telem = 1 - gnss = 1 + if rc_protocols_nr > 0: + # Set the type to RCin/SBUS if not already set by serial processing + current_rc_type = self.get_component_value(("RC Receiver", "FC Connection", "Type")) + if current_rc_type is None or current_rc_type == "": + self.set_component_value(("RC Receiver", "FC Connection", "Type"), "RCin/SBUS") + + # RC_PROTOCOLS is a bitmask where each bit represents an enabled protocol + # Only set a specific protocol if exactly one bit is set (power of 2) + # If multiple bits are set, we can't determine which protocol is actually in use + if is_single_bit_set(rc_protocols_nr): + # Exactly one bit is set (power of 2) - use the value directly as the key + rc_value = str(rc_protocols_nr) + if rc_value in RC_PROTOCOLS_DICT: + protocol = RC_PROTOCOLS_DICT[rc_value].get("protocol") + self.set_component_value(("RC Receiver", "FC Connection", "Protocol"), str(protocol)) + else: + # Multiple bits are set - cannot determine which protocol is active + logging_error( + _("RC_PROTOCOLS has multiple protocols enabled (%d). Cannot determine active protocol."), + rc_protocols_nr, + ) + + def _process_serial_components(self, fc_parameters: dict) -> bool: # pylint: disable=too-many-branches + """Process serial port components and return True if ESC is serial controlled.""" + rc = telem = gnss = 1 + # esc counts FC->ESC *control* serial protocols only (FETtecOneWire, Torqeedo, CoDevESC). + # Telemetry-only protocols (ESC Telemetry=16, Scripting=28) do NOT count here because + # FC->ESC is still PWM/DShot/BDShot in those cases and _set_esc_type_from_fc_parameters must run. esc = 1 + esc_telemetry_set = False # True once ESC->FC Telemetry is populated from a serial port for serial in SERIAL_PORTS: if serial + "_PROTOCOL" not in fc_parameters: continue @@ -301,12 +331,23 @@ def _set_serial_type_from_fc_parameters( # pylint: disable=too-many-branches,to self.set_component_value(("GNSS Receiver", "FC Connection", "Type"), serial) gnss += 1 elif component == "ESC": - if esc == 1: - # Only set component values for the first ESC - self.set_component_value(("ESC", "FC Connection", "Type"), serial) - self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol) - # Count all ESC components - esc += 1 + if protocol in ESC_TELEMETRY_PROTOCOLS: + # Serial ESC->FC telemetry only (DShot with UART feedback, or Hobbywing Datalink v2). + # FC->ESC connection is still PWM/DShot; _set_esc_type_from_fc_parameters handles it. + # Do NOT increment esc so that function is still called. + if not esc_telemetry_set: + self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), serial) + self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), protocol) + esc_telemetry_set = True + else: + # FC->ESC serial control protocol (FETtecOneWire, Torqeedo, CoDevESC): + # the same serial port carries both directions. + if esc == 1: + self.set_component_value(("ESC", "FC->ESC Connection", "Type"), serial) + self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol) + self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), serial) + self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), protocol) + esc += 1 return esc >= 2 @@ -319,22 +360,43 @@ def _set_esc_type_from_fc_parameters(self, fc_parameters: dict[str, float], doc: logging_error(_("Invalid non-integer value for MOT_PWM_TYPE %s"), mot_pwm_type) mot_pwm_type = 0 - main_out_functions = [fc_parameters.get("SERVO" + str(i) + "_FUNCTION", 0) for i in range(1, 9)] + aio_functions = [fc_parameters.get("SERVO" + str(i) + "_FUNCTION", 0) for i in range(9, 15)] - # if any element of main_out_functions is in [33, 34, 35, 36] then ESC is connected to main_out - if any(servo_function in {33, 34, 35, 36} for servo_function in main_out_functions): - self.set_component_value(("ESC", "FC Connection", "Type"), "Main Out") + # if any element of aio_functions is in SERVO_FUNCTION_ESC_CONTROL then ESC is connected to AIO + if any(servo_function in SERVO_FUNCTION_ESC_CONTROL for servo_function in aio_functions): + self.set_component_value(("ESC", "FC->ESC Connection", "Type"), "AIO") else: - self.set_component_value(("ESC", "FC Connection", "Type"), "AIO") + self.set_component_value(("ESC", "FC->ESC Connection", "Type"), "Main Out") + protocol = "" if "MOT_PWM_TYPE" in doc and "values" in doc["MOT_PWM_TYPE"]: protocol = doc["MOT_PWM_TYPE"]["values"].get(str(mot_pwm_type), "") if protocol: - self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol) + self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol) # Fallback to MOT_PWM_TYPE_DICT if doc is not available - elif str(mot_pwm_type) in MOT_PWM_TYPE_DICT: - protocol = str(MOT_PWM_TYPE_DICT[str(mot_pwm_type)]["protocol"]) - self.set_component_value(("ESC", "FC Connection", "Protocol"), protocol) + else: + mot_pwm_sub = get_mot_pwm_type_sub_dict( + str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + ) + if str(mot_pwm_type) in mot_pwm_sub: + protocol = str(mot_pwm_sub[str(mot_pwm_type)]["protocol"]) + self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol) + + # Set ESC->FC Telemetry: DShot supports BDShot telemetry on the same PWM wire. + # However, if _set_serial_type_from_fc_parameters already found a dedicated serial telemetry + # port (SERIAL*_PROTOCOL=16 or 28), that takes priority and must not be overwritten. + esc_conn_type = self.get_component_value(("ESC", "FC->ESC Connection", "Type")) + current_telemetry_type = self.get_component_value(("ESC", "ESC->FC Telemetry", "Type")) + if protocol and protocol.startswith("DShot"): + if current_telemetry_type not in SERIAL_PORTS and current_telemetry_type not in CAN_PORTS: + # No dedicated serial/CAN telemetry port detected; fall back to BDShot on the PWM wire + self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), esc_conn_type) + self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), "BDShot") + elif not current_telemetry_type or ( + current_telemetry_type not in SERIAL_PORTS and current_telemetry_type not in CAN_PORTS + ): + self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), "None") + self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), "None") def _set_battery_type_from_fc_parameters(self, fc_parameters: dict[str, float]) -> None: # pylint: disable=too-many-branches """Process battery monitor parameters and update the data model.""" @@ -351,7 +413,7 @@ def _set_battery_type_from_fc_parameters(self, fc_parameters: dict[str, float]) fc_conn_protocol = BATT_MONITOR_CONNECTION[batt_monitor_str].get("protocol", "Disabled") # Handle list of possible connection types - if isinstance(fc_conn_type, list): + if isinstance(fc_conn_type, tuple): # Check if it's I2C ports - need to determine which bus if set(fc_conn_type) <= set(I2C_PORTS): # subset of I2C ports # Use BATT_I2C_BUS to determine the actual I2C bus @@ -378,7 +440,7 @@ def _set_battery_type_from_fc_parameters(self, fc_parameters: dict[str, float]) # For other list types, take first element fc_conn_type = fc_conn_type[0] - if isinstance(fc_conn_protocol, list): + if isinstance(fc_conn_protocol, tuple): fc_conn_protocol = fc_conn_protocol[0] self.set_component_value(("Battery Monitor", "FC Connection", "Type"), fc_conn_type) @@ -430,6 +492,12 @@ def _import_bat_values_from_fc(self, specs: BatteryVoltageSpecs) -> None: self.import_bat_voltage(specs, "BATT_LOW_VOLT", "Volt per cell low") self.import_bat_voltage(specs, "BATT_CRT_VOLT", "Volt per cell crit") self.import_bat_voltage(specs, "MOT_BAT_VOLT_MIN", "Volt per cell min") + else: + self.set_component_value(("Battery", "Specifications", "Volt per cell max"), "0") + self.set_component_value(("Battery", "Specifications", "Volt per cell arm"), "0") + self.set_component_value(("Battery", "Specifications", "Volt per cell low"), "0") + self.set_component_value(("Battery", "Specifications", "Volt per cell crit"), "0") + self.set_component_value(("Battery", "Specifications", "Volt per cell min"), "0") def import_bat_voltage(self, specs: BatteryVoltageSpecs, param_name: str, voltage_type: str) -> None: if param_name in specs.fc_parameters: @@ -619,7 +687,10 @@ def _set_motor_poles_from_fc_parameters(self, fc_parameters: dict[str, float]) - poles = 0.0 if "MOT_PWM_TYPE" in fc_parameters: mot_pwm_type_str = str(int(fc_parameters["MOT_PWM_TYPE"])) - if mot_pwm_type_str in MOT_PWM_TYPE_DICT and MOT_PWM_TYPE_DICT[mot_pwm_type_str].get("is_dshot", False): + mot_pwm_sub = get_mot_pwm_type_sub_dict( + str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + ) + if mot_pwm_type_str in mot_pwm_sub and mot_pwm_sub[mot_pwm_type_str].get("is_dshot", False): if fc_parameters.get("SERVO_BLH_POLES"): # DShot ESCs poles = fc_parameters["SERVO_BLH_POLES"] elif fc_parameters.get("SERVO_FTW_MASK") and fc_parameters.get("SERVO_FTW_POLES"): diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py index 3187515a8..796cf8a76 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py @@ -26,15 +26,18 @@ ) # Port definitions -ANALOG_PORTS = ["Analog"] -SERIAL_PORTS = ["SERIAL1", "SERIAL2", "SERIAL3", "SERIAL4", "SERIAL5", "SERIAL6", "SERIAL7", "SERIAL8"] -CAN_PORTS = ["CAN1", "CAN2"] -I2C_PORTS = ["I2C1", "I2C2", "I2C3", "I2C4"] -PWM_IN_PORTS = ["PWM"] -PWM_OUT_PORTS = ["Main Out", "AIO"] -RC_PORTS = ["RCin/SBUS"] -SPI_PORTS = ["SPI"] -OTHER_PORTS = ["other"] +ANALOG_PORTS: tuple[str, ...] = ("Analog",) +SERIAL_PORTS: tuple[str, ...] = ("SERIAL1", "SERIAL2", "SERIAL3", "SERIAL4", "SERIAL5", "SERIAL6", "SERIAL7", "SERIAL8") +CAN_PORTS: tuple[str, ...] = ("CAN1", "CAN2") +I2C_PORTS: tuple[str, ...] = ("I2C1", "I2C2", "I2C3", "I2C4") +PWM_IN_PORTS: tuple[str, ...] = ("PWM",) +PWM_OUT_PORTS: tuple[str, ...] = ("Main Out", "AIO") +RC_PORTS: tuple[str, ...] = ("RCin/SBUS",) +SPI_PORTS: tuple[str, ...] = ("SPI",) +OTHER_PORTS: tuple[str, ...] = ("other",) + +# Servo function constants for ESC detection +SERVO_FUNCTION_ESC_CONTROL: set[int] = {33, 34, 35, 36, 73, 74} # Functions that indicate ESC control on AIO # Bus labels for SERIAL ports - maps SERIAL port names to their common bus labels # These labels help users identify ports by their typical usage on flight controllers: @@ -84,7 +87,8 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> ("RC Receiver", "FC Connection", "Type"), ("Telemetry", "FC Connection", "Type"), ("Battery Monitor", "FC Connection", "Type"), - ("ESC", "FC Connection", "Type"), + ("ESC", "FC->ESC Connection", "Type"), + ("ESC", "ESC->FC Telemetry", "Type"), ("GNSS Receiver", "FC Connection", "Type"), ] @@ -94,7 +98,7 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> # Protocol dictionaries SERIAL_PROTOCOLS_DICT: dict[str, dict[str, Any]] = { - "-1": {"type": ["None"], "protocol": "None", "component": None}, + "-1": {"type": ("None",), "protocol": "None", "component": None}, "1": {"type": SERIAL_PORTS, "protocol": "MAVLink1", "component": "Telemetry"}, "2": {"type": SERIAL_PORTS, "protocol": "MAVLink2", "component": "Telemetry"}, "3": {"type": SERIAL_PORTS, "protocol": "Frsky D", "component": None}, @@ -108,7 +112,7 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "13": {"type": SERIAL_PORTS, "protocol": "Beacon", "component": None}, "14": {"type": SERIAL_PORTS, "protocol": "Volz servo out", "component": None}, "15": {"type": SERIAL_PORTS, "protocol": "SBus servo out", "component": None}, - "16": {"type": SERIAL_PORTS, "protocol": "ESC Telemetry", "component": None}, + "16": {"type": SERIAL_PORTS, "protocol": "ESC Telemetry", "component": "ESC"}, "17": {"type": SERIAL_PORTS, "protocol": "Devo Telemetry", "component": None}, "18": {"type": SERIAL_PORTS, "protocol": "OpticalFlow", "component": None}, "19": {"type": SERIAL_PORTS, "protocol": "RobotisServo", "component": None}, @@ -120,7 +124,7 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "25": {"type": SERIAL_PORTS, "protocol": "LTM", "component": None}, "26": {"type": SERIAL_PORTS, "protocol": "RunCam", "component": None}, "27": {"type": SERIAL_PORTS, "protocol": "HottTelem", "component": None}, - "28": {"type": SERIAL_PORTS, "protocol": "Scripting", "component": None}, + "28": {"type": SERIAL_PORTS, "protocol": "Scripting", "component": "ESC"}, "29": {"type": SERIAL_PORTS, "protocol": "Crossfire VTX", "component": None}, "30": {"type": SERIAL_PORTS, "protocol": "Generator", "component": None}, "31": {"type": SERIAL_PORTS, "protocol": "Winch", "component": None}, @@ -143,8 +147,11 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "49": {"type": SERIAL_PORTS, "protocol": "i-BUS Telemetry", "component": None}, } -BATT_MONITOR_CONNECTION: dict[str, dict[str, Union[list[str], str]]] = { - "0": {"type": ["None"], "protocol": "Disabled"}, +# ESC protocol constants +ESC_TELEMETRY_PROTOCOLS = {"ESC Telemetry", "Scripting"} # Serial telemetry-only protocols + +BATT_MONITOR_CONNECTION: dict[str, dict[str, Union[tuple[str, ...], str]]] = { + "0": {"type": ("None",), "protocol": "Disabled"}, "3": {"type": ANALOG_PORTS, "protocol": "Analog Voltage Only"}, "4": {"type": ANALOG_PORTS, "protocol": "Analog Voltage and Current"}, "5": {"type": I2C_PORTS, "protocol": "Solo"}, @@ -174,8 +181,8 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "29": {"type": OTHER_PORTS, "protocol": "Scripting"}, } -GNSS_RECEIVER_CONNECTION: dict[str, dict[str, Union[list[str], str]]] = { - "0": {"type": ["None"], "protocol": "None"}, +GNSS_RECEIVER_CONNECTION: dict[str, dict[str, Union[tuple[str, ...], str]]] = { + "0": {"type": ("None",), "protocol": "None"}, "1": {"type": SERIAL_PORTS, "protocol": "AUTO"}, "2": {"type": SERIAL_PORTS, "protocol": "uBlox"}, "5": {"type": SERIAL_PORTS, "protocol": "NMEA"}, @@ -201,23 +208,56 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "26": {"type": SERIAL_PORTS, "protocol": "Septentrio-DualAntenna(SBF)"}, } -MOT_PWM_TYPE_DICT: dict[str, dict[str, Union[list[str], str, bool]]] = { - "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, - "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, - "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, - "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "is_dshot": False}, - "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, - "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, - "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, - "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, - "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, - "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, +MOT_PWM_TYPE_DICT: dict[str, dict[str, dict[str, Union[tuple[str, ...], str, bool]]]] = { + "ArduCopter": { + "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, + "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, + "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, + "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "is_dshot": False}, + "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, + "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, + "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, + "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, + "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, + "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + }, + "Heli": { + "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, + "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, + "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, + "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "is_dshot": False}, + "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, + "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, + "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, + "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, + "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, + "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + }, + "Rover": { + "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, + "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, + "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, + "3": {"type": PWM_OUT_PORTS, "protocol": "BrushedWithRelay", "is_dshot": False}, + "4": {"type": PWM_OUT_PORTS, "protocol": "BrushedBiPolar", "is_dshot": False}, + "5": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, + "6": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, + "7": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, + "8": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, + "9": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, + "10": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + }, } + +def get_mot_pwm_type_sub_dict(vehicle_type: str) -> dict[str, dict[str, Union[tuple[str, ...], str, bool]]]: + """Return the vehicle-type-specific entry sub-dict from MOT_PWM_TYPE_DICT.""" + return MOT_PWM_TYPE_DICT.get(vehicle_type, MOT_PWM_TYPE_DICT["ArduCopter"]) + + # RC_PROTOCOLS is a bitmask parameter, so keys are actual bitmask values (2^bit_position) # Special case: value 1 = All protocols enabled # Bit 1 (value 2) = PPM, Bit 2 (value 4) = IBUS, Bit 3 (value 8) = SBUS, etc. -RC_PROTOCOLS_DICT: dict[str, dict[str, Union[list[str], str]]] = { +RC_PROTOCOLS_DICT: dict[str, dict[str, Union[tuple[str, ...], str]]] = { "1": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "All"}, # Special case: 1 = All protocols "2": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "PPM"}, # Bit 1 "4": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "IBUS"}, # Bit 2 @@ -237,6 +277,23 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16 } +# ESC->FC telemetry connections +ESC_TELEMETRY_DICT: dict[str, dict[str, Union[tuple[str, ...], str]]] = { + "0": {"type": ("None",), "protocol": "None"}, + # On DShot: FC->ESC is either Main Out or AIO; and ESC->FC Telemetry is serial + # On BDShot: FC->ESC is either Main Out or AIO; and ESC->FC Telemetry is also Main Out or AIO + # but there is an optional backup serial telemetry channel + "1": {"type": SERIAL_PORTS, "protocol": "ESC Telemetry"}, + # On BShot if the optional serial ESC->FC backup telemetry is unused, choose this + "2": {"type": PWM_OUT_PORTS, "protocol": "BDShot"}, + # The same CAN connection is used for both FC->ESC and ESC->FC telemetry + "3": {"type": CAN_PORTS, "protocol": "DroneCAN"}, + # The same serial connection is used for both FC->ESC and ESC->FC telemetry + "4": {"type": SERIAL_PORTS, "protocol": "FETtecOneWire"}, + # On T-Motor/Hobbywing Datalink v2: FC->ESC is either Main Out or AIO; and serial telemetry is received via scripting + "5": {"type": SERIAL_PORTS, "protocol": "Scripting"}, +} + class ComponentDataModelValidation(ComponentDataModelBase): """ @@ -289,10 +346,11 @@ def get_all_protocols(param_dict: dict) -> tuple[str, ...]: for value in param_dict.values() ) + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") fallbacks: dict[str, tuple[str, ...]] = { "RC_PROTOCOLS": get_all_protocols(RC_PROTOCOLS_DICT), "BATT_MONITOR": get_all_protocols(BATT_MONITOR_CONNECTION), - "MOT_PWM_TYPE": get_all_protocols(MOT_PWM_TYPE_DICT), + "MOT_PWM_TYPE": get_all_protocols(get_mot_pwm_type_sub_dict(fw_type)), "GPS_TYPE": get_all_protocols(GNSS_RECEIVER_CONNECTION), "GPS1_TYPE": get_all_protocols(GNSS_RECEIVER_CONNECTION), # GPS_TYPE was renamed to GPS1_TYPE in 4.6 } @@ -315,7 +373,7 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: functools.reduce( operator.iadd, [ - type_val if isinstance(type_val, list) else [type_val] + type_val if isinstance(type_val, tuple) else [type_val] for type_val in [value["type"] for value in conn_dict.values()] ], [], @@ -338,90 +396,118 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: ), ("Battery Monitor", "FC Connection", "Type"): get_connection_types(BATT_MONITOR_CONNECTION), ("Battery Monitor", "FC Connection", "Protocol"): get_combobox_values("BATT_MONITOR"), - ("ESC", "FC Connection", "Type"): (*PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS), - ("ESC", "FC Connection", "Protocol"): self._mot_pwm_types, + ("ESC", "FC->ESC Connection", "Type"): (*PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS), + ("ESC", "FC->ESC Connection", "Protocol"): self._mot_pwm_types, + ("ESC", "ESC->FC Telemetry", "Type"): ("None", *PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS), + ("ESC", "ESC->FC Telemetry", "Protocol"): tuple( + dict.fromkeys(str(v["protocol"]) for v in ESC_TELEMETRY_DICT.values()) + ), ("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS), ("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION), ("Battery", "Specifications", "Chemistry"): BatteryCell.chemistries(), } for component in ["RC Receiver", "Telemetry", "Battery Monitor", "ESC", "GNSS Receiver"]: - if component in self._data["Components"]: + if component not in self._data.get("Components", {}): + continue + + if component == "ESC": self._update_possible_choices_for_path( - (component, "FC Connection", "Type"), self.get_component_value((component, "FC Connection", "Type")) + ("ESC", "FC->ESC Connection", "Type"), + self.get_component_value(("ESC", "FC->ESC Connection", "Type")), + ) + self._update_possible_choices_for_path( + ("ESC", "ESC->FC Telemetry", "Type"), + self.get_component_value(("ESC", "ESC->FC Telemetry", "Type")), + ) + else: + self._update_possible_choices_for_path( + (component, "FC Connection", "Type"), + self.get_component_value((component, "FC Connection", "Type")), ) def _update_possible_choices_for_path( # pylint: disable=too-many-branches self, path: ComponentPath, value: Union[ComponentData, ComponentValue, None] ) -> None: """Update _possible_choices when connection type values that affect protocol choices are changed.""" - # Only update if this is a connection type change that affects protocol choices - if len(path) >= 3 and path[1] == "FC Connection" and path[2] == "Type" and isinstance(value, str): - component_name = path[0] - protocol_path: ComponentPath = (component_name, "FC Connection", "Protocol") - - # Calculate the new possible choices for the corresponding protocol field - if component_name == "RC Receiver": - # Filter RC protocols based on the selected connection type - if value == "None": - new_choices: tuple[str, ...] = ("None",) - else: - # For any connection type, find protocols that support it - new_choices = tuple(str(v["protocol"]) for v in RC_PROTOCOLS_DICT.values() if value in v["type"]) - self._possible_choices[protocol_path] = new_choices - - elif component_name == "Telemetry": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - else: - # For non-None telemetry connections, use the standard telemetry protocols - self._possible_choices[protocol_path] = tuple( - str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "Telemetry" - ) + if len(path) < 3 or path[2] != "Type" or not isinstance(value, str): + return + + component_name = path[0] + section = path[1] + + if section not in ("FC Connection", "FC->ESC Connection", "ESC->FC Telemetry"): + return + + protocol_path: ComponentPath = (component_name, section, "Protocol") + + # Calculate the new possible choices for the corresponding protocol field + if component_name == "RC Receiver": + # Filter RC protocols based on the selected connection type + if value == "None": + new_choices: tuple[str, ...] = ("None",) + else: + # For any connection type, find protocols that support it + new_choices = tuple(str(v["protocol"]) for v in RC_PROTOCOLS_DICT.values() if value in v["type"]) + self._possible_choices[protocol_path] = new_choices + + elif component_name == "Telemetry": + if value == "None": + self._possible_choices[protocol_path] = ("None",) + else: + # For non-None telemetry connections, use the standard telemetry protocols + self._possible_choices[protocol_path] = tuple( + str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "Telemetry" + ) - elif component_name == "Battery Monitor": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - return + elif component_name == "Battery Monitor": + if value == "None": + self._possible_choices[protocol_path] = ("None",) + return - # Find protocols available for the selected connection type - batt_available_protocols: list[str] = [] - for conn_dict in BATT_MONITOR_CONNECTION.values(): - conn_type = conn_dict["type"] - if isinstance(conn_type, list) and value in conn_type: - batt_available_protocols.append(str(conn_dict["protocol"])) + # Find protocols available for the selected connection type + batt_available_protocols: list[str] = [] + for conn_dict in BATT_MONITOR_CONNECTION.values(): + conn_type = conn_dict["type"] + if isinstance(conn_type, tuple) and value in conn_type: + batt_available_protocols.append(str(conn_dict["protocol"])) - self._possible_choices[protocol_path] = ( - tuple(batt_available_protocols) if batt_available_protocols else ("None",) - ) + self._possible_choices[protocol_path] = tuple(batt_available_protocols) if batt_available_protocols else ("None",) - elif component_name == "ESC": + elif component_name == "ESC": + if section == "ESC->FC Telemetry": if value == "None": self._possible_choices[protocol_path] = ("None",) - elif value in CAN_PORTS: - self._possible_choices[protocol_path] = ("DroneCAN",) - elif value in SERIAL_PORTS: - self._possible_choices[protocol_path] = tuple( - str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "ESC" - ) else: - # For PWM outputs, use motor PWM types - self._possible_choices[protocol_path] = self._mot_pwm_types + self._possible_choices[protocol_path] = tuple( + str(v["protocol"]) + for v in ESC_TELEMETRY_DICT.values() + if isinstance(v["type"], tuple) and value in v["type"] + ) or ("None",) + elif value == "None": + self._possible_choices[protocol_path] = ("None",) + elif value in CAN_PORTS: + self._possible_choices[protocol_path] = ("DroneCAN",) + elif value in SERIAL_PORTS: + self._possible_choices[protocol_path] = tuple( + str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "ESC" + ) + else: + # For PWM outputs, use motor PWM types + self._possible_choices[protocol_path] = self._mot_pwm_types - elif component_name == "GNSS Receiver": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - return + elif component_name == "GNSS Receiver": + if value == "None": + self._possible_choices[protocol_path] = ("None",) + return - # Find protocols available for the selected connection type - gnss_available_protocols: list[str] = [] - for conn_dict in GNSS_RECEIVER_CONNECTION.values(): - conn_type = conn_dict["type"] - if isinstance(conn_type, list) and value in conn_type: - gnss_available_protocols.append(str(conn_dict["protocol"])) + # Find protocols available for the selected connection type + gnss_available_protocols: list[str] = [] + for conn_dict in GNSS_RECEIVER_CONNECTION.values(): + conn_type = conn_dict["type"] + if isinstance(conn_type, tuple) and value in conn_type: + gnss_available_protocols.append(str(conn_dict["protocol"])) - self._possible_choices[protocol_path] = ( - tuple(gnss_available_protocols) if gnss_available_protocols else ("None",) - ) + self._possible_choices[protocol_path] = tuple(gnss_available_protocols) if gnss_available_protocols else ("None",) def _validate_tow_limits(self, value: str, path: ComponentPath) -> tuple[str, Optional[float]]: """Validate takeoff weight min/max cross-constraints.""" @@ -563,6 +649,26 @@ def validate_against_another_value( # pylint: disable=too-many-arguments,too-ma ), corrected return "", None # value is within valid interval, return empty string as there is no error + def _validate_limits_and_voltages(self, path: ComponentPath, value: str, paths_str: str, errors: list[str]) -> None: + """Validate entry limits and battery voltages.""" + if path in self.VALIDATION_RULES: + error_msg, corrected_value = self.validate_entry_limits(value, path) + if error_msg: + errors.append(error_msg.format(value=value, paths_str=paths_str)) + if corrected_value is not None: + self.set_component_value(path, corrected_value) + return + + if path in BATTERY_CELL_VOLTAGE_PATHS: + error_msg, corrected_value = self.validate_cell_voltage(value, path) + if error_msg: + errors.append(error_msg.format(value=value, paths_str=paths_str)) + if corrected_value is not None: + self.set_component_value(path, corrected_value) + return + + self._validate_motor_poles(errors, path, value, paths_str) + def validate_all_data(self, entry_values: dict[ComponentPath, str]) -> tuple[bool, list[str]]: """ Centralize all data validation logic. @@ -586,40 +692,34 @@ def validate_all_data(self, entry_values: dict[ComponentPath, str]) -> tuple[boo # Keep protocol choices in sync with connection type changes in this batch. # This ensures dependent fields like Battery Monitor protocol are validated correctly # when both Type and Protocol are present in entry_values. - if len(path) >= 3 and path[1] == "FC Connection" and path[2] == "Type" and isinstance(value, str): + if len(path) >= 3 and path[2] == "Type" and isinstance(value, str): self._update_possible_choices_for_path(path, value) # Check for duplicate connections - if len(path) >= 3 and path[1] == "FC Connection" and path[2] == "Type": + esc_conn_sections = {"FC->ESC Connection", "ESC->FC Telemetry"} + is_fc_conn_type = ( + len(path) >= 3 and path[2] == "Type" and (path[1] == "FC Connection" or path[1] in esc_conn_sections) + ) + if is_fc_conn_type: + # Type assertion: path has at least 3 elements as checked above + if len(path) < 3: + continue # Help type checker understand that path[2] is safe to access if value in fc_serial_connection and value not in {"CAN1", "CAN2", "I2C1", "I2C2", "I2C3", "I2C4", "None"}: # Allow certain combinations if path[0] in {"Telemetry", "RC Receiver"} and fc_serial_connection[value] in {"Telemetry", "RC Receiver"}: continue + # Allow ESC->FC Telemetry to share the same port as FC->ESC Connection (bidirectional serial) + if path[0] == "ESC" and path[1] in esc_conn_sections and fc_serial_connection[value] == "ESC": + continue + error_msg = _("Duplicate FC connection type '{value}' for {paths_str}") errors.append(error_msg.format(value=value, paths_str=paths_str)) continue fc_serial_connection[value] = path[0] - if path in self.VALIDATION_RULES: - # Validate entry limits - error_msg, corrected_value = self.validate_entry_limits(value, path) - if error_msg: - errors.append(error_msg.format(value=value, paths_str=paths_str)) - if corrected_value is not None: - self.set_component_value(path, corrected_value) - continue - - if path in BATTERY_CELL_VOLTAGE_PATHS: - # Validate battery cell voltages - error_msg, corrected_value = self.validate_cell_voltage(value, path) - if error_msg: - errors.append(error_msg.format(value=value, paths_str=paths_str)) - if corrected_value is not None: - self.set_component_value(path, corrected_value) - continue - - self._validate_motor_poles(errors, path, value, paths_str) + # Validate limits and voltages + self._validate_limits_and_voltages(path, value, paths_str, errors) return len(errors) == 0, errors diff --git a/ardupilot_methodic_configurator/vehicle_components_schema.json b/ardupilot_methodic_configurator/vehicle_components_schema.json index 87f462ea6..30530b023 100644 --- a/ardupilot_methodic_configurator/vehicle_components_schema.json +++ b/ardupilot_methodic_configurator/vehicle_components_schema.json @@ -52,7 +52,7 @@ "description": "Main power source for the vehicle" }, "ESC": { - "$ref": "#/definitions/connectionComponent", + "$ref": "#/definitions/escComponent", "description": "Electronic Speed Controller for the motors" }, "Motors": { @@ -242,6 +242,29 @@ } ] }, + "escComponent": { + "allOf": [ + { "$ref": "#/definitions/baseComponent" }, + { + "properties": { + "Firmware": { + "$ref": "#/definitions/firmware", + "description": "ESC firmware information", + "x-is-optional": true + }, + "FC->ESC Connection": { + "$ref": "#/definitions/fcConnection", + "description": "Data path from flight controller to ESC" + }, + "ESC->FC Telemetry": { + "$ref": "#/definitions/fcConnection", + "description": "Telemetry path from ESC to flight controller (if applicable)" + } + }, + "description": "Electronic Speed Controller component with (optional) telemetry" + } + ] + }, "battery": { "allOf": [ { "$ref": "#/definitions/baseComponent" }, diff --git a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/configuration_steps_ArduPlane.json b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/configuration_steps_ArduPlane.json index f2579a33b..bf9ec9ff0 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/configuration_steps_ArduPlane.json +++ b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/configuration_steps_ArduPlane.json @@ -103,11 +103,11 @@ "MOT_HOVER_LEARN": { "New Value": 2, "Change Reason": "So that it can tune the throttle controller on 20_throttle_controller.param file" } }, "derived_parameters": { - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { diff --git a/ardupilot_methodic_configurator/vehicle_templates/Heli/OMP_M4/configuration_steps_Heli.json b/ardupilot_methodic_configurator/vehicle_templates/Heli/OMP_M4/configuration_steps_Heli.json index 6a018fb43..dc5f00355 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/Heli/OMP_M4/configuration_steps_Heli.json +++ b/ardupilot_methodic_configurator/vehicle_templates/Heli/OMP_M4/configuration_steps_Heli.json @@ -103,11 +103,11 @@ "MOT_HOVER_LEARN": { "New Value": 2, "Change Reason": "So that it can tune the throttle controller on 20_throttle_controller.param file" } }, "derived_parameters": { - "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, + "MOT_PWM_TYPE": { "New Value": "vehicle_components['ESC']['FC->ESC Connection']['Protocol']", "Change Reason": "Specified in component editor window" }, "SERVO_BLH_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" }, "SERVO_FTW_POLES": { "New Value": "vehicle_components['Motors']['Specifications']['Poles']", "Change Reason": "Specified in component editor window" } }, - "rename_connection": "vehicle_components['ESC']['FC Connection']['Type']", + "rename_connection": "vehicle_components['ESC']['FC->ESC Connection']['Type']", "old_filenames": [] }, "08_batt1.param": { diff --git a/update_vehicle_templates.py b/update_vehicle_templates.py index 0d87f7f92..2ff378317 100755 --- a/update_vehicle_templates.py +++ b/update_vehicle_templates.py @@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-3.0-or-later """ +import contextlib import json import logging import os @@ -103,6 +104,24 @@ def process_template_directory(template_dir: Path) -> None: datatypes = schema.get_all_value_datatypes() data_model = ComponentDataModel(local_fs.vehicle_components_fs.data, datatypes, schema) data_model.update_json_structure(fc_parameters={}, file_parameters=local_fs.file_parameters) + + # Build a flat {param_name: float} dict so process_fc_parameters() can detect ESC protocols, + # serial telemetry channels, etc. + # Seed from 00_default.param first (read_params_from_files skips it), then let the + # numbered step files overwrite — later configuration steps take priority. + flat_params: dict[str, float] = {} + with contextlib.suppress(ValueError, TypeError): + # for param_name, par in local_fs.param_default_dict.items(): + # try: + # flat_params[param_name] = float(par.value) + # except (ValueError, TypeError): + # pass + for par_dict in local_fs.file_parameters.values(): + for param_name, par in par_dict.items(): + flat_params[param_name] = float(par.value) + + data_model.process_fc_parameters(flat_params, local_fs.doc_dict) + local_fs.vehicle_components_fs.data = data_model.get_component_data() local_fs.save_vehicle_components_json_data(local_fs.vehicle_components_fs.data, str(template_dir)) From 418f7e4d3ebc3419c86047ec90c7dcf1728f47e9 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Mon, 13 Apr 2026 23:24:37 +0200 Subject: [PATCH 2/3] test(vehicle_components): Added tests for the functionality --- .../acceptance_template_import_from_params.py | 4 +- ...test_data_model_vehicle_components_base.py | 32 ++++- ...st_data_model_vehicle_components_import.py | 136 +++++++++++------- ...ata_model_vehicle_components_validation.py | 19 +-- ...model_vehicle_components_validation_bdd.py | 6 +- ...it_data_model_vehicle_components_import.py | 10 +- ...ata_model_vehicle_components_validation.py | 12 +- ...vehicle_components_validation_constants.py | 112 +++++++++------ 8 files changed, 213 insertions(+), 118 deletions(-) diff --git a/tests/acceptance_template_import_from_params.py b/tests/acceptance_template_import_from_params.py index 685d7eb99..f4393cfcd 100755 --- a/tests/acceptance_template_import_from_params.py +++ b/tests/acceptance_template_import_from_params.py @@ -730,8 +730,8 @@ def get_inferable_fields() -> list[tuple[str, str, str]]: ("Battery", "Specifications", "Capacity mAh"), ("Battery Monitor", "FC Connection", "Type"), ("Battery Monitor", "FC Connection", "Protocol"), - ("ESC", "FC Connection", "Type"), - ("ESC", "FC Connection", "Protocol"), + ("ESC", "FC->ESC Connection", "Type"), + ("ESC", "FC->ESC Connection", "Protocol"), ("GNSS Receiver", "FC Connection", "Type"), ("GNSS Receiver", "FC Connection", "Protocol"), ("RC Receiver", "FC Connection", "Type"), diff --git a/tests/test_data_model_vehicle_components_base.py b/tests/test_data_model_vehicle_components_base.py index 82e5d5cc3..e029d40d3 100755 --- a/tests/test_data_model_vehicle_components_base.py +++ b/tests/test_data_model_vehicle_components_base.py @@ -606,6 +606,36 @@ def test_system_recreates_missing_battery_component(self, realistic_model) -> No assert "Battery" in realistic_model._data["Components"] assert "Specifications" in realistic_model._data["Components"]["Battery"] + def test_system_migrates_legacy_esc_fc_connection_path(self) -> None: + """ + System migrates legacy ESC FC Connection to FC->ESC Connection when loading old files. + + GIVEN: A model with old ESC path data + WHEN: update_json_structure is called + THEN: ESC data is moved to the new FC->ESC Connection path and old path removed + """ + initial_data = { + "Components": { + "ESC": { + "Product": {"Manufacturer": "Test"}, + "FC Connection": {"Type": "Main Out", "Protocol": "DShot600"}, + } + }, + "Format version": 1, + } + + vehicle_components = VehicleComponents() + schema = VehicleComponentsJsonSchema(vehicle_components.load_schema()) + component_datatypes = schema.get_all_value_datatypes() + model = ComponentDataModelBase(initial_data, component_datatypes, schema) + + model.update_json_structure() + + assert "FC Connection" not in model._data["Components"]["ESC"] + assert "FC->ESC Connection" in model._data["Components"]["ESC"] + assert model._data["Components"]["ESC"]["FC->ESC Connection"]["Type"] == "Main Out" + assert model._data["Components"]["ESC"]["FC->ESC Connection"]["Protocol"] == "DShot600" + def test_system_recreates_missing_flight_controller_specifications(self, realistic_model) -> None: """ System recreates missing Flight Controller Specifications sub-section. @@ -882,7 +912,7 @@ def test_system_handles_sequential_access_to_different_components(self, basic_mo (("Battery", "Specifications", "Capacity mAh"), 2000), (("Frame", "Specifications", "Weight Kg"), 1.5), (("Flight Controller", "Product", "Manufacturer"), "TestCorp"), - (("ESC", "FC Connection", "Protocol"), "DShot600"), + (("ESC", "FC->ESC Connection", "Protocol"), "DShot600"), (("Motors", "Specifications", "Poles"), 14), ] diff --git a/tests/test_data_model_vehicle_components_import.py b/tests/test_data_model_vehicle_components_import.py index b796e3c97..318daa114 100755 --- a/tests/test_data_model_vehicle_components_import.py +++ b/tests/test_data_model_vehicle_components_import.py @@ -214,26 +214,28 @@ def test_system_handles_multiple_rc_protocols_configuration(self, realistic_mode @patch("ardupilot_methodic_configurator.data_model_vehicle_components_import.SERIAL_PORTS", ["SERIAL1", "SERIAL2"]) @patch( "ardupilot_methodic_configurator.data_model_vehicle_components_import.SERIAL_PROTOCOLS_DICT", - {"30": {"component": "ESC", "protocol": "ESC Telem"}}, + {"30": {"component": "ESC", "protocol": "ESC Telemetry"}}, ) def test_system_detects_multiple_serial_esc_connections(self, realistic_model) -> None: """ - System detects when multiple serial ports have ESC telemetry. + System uses first serial ESC telemetry port and allows ESC type detection to proceed. GIVEN: Flight controller with ESC telemetry on multiple serial ports WHEN: Importing serial port configuration - THEN: First ESC connection should be used - AND: Should return True indicating multiple ESC ports detected + THEN: First ESC telemetry connection should be used + AND: Should return False because telemetry-only protocols leave FC->ESC undetermined """ fc_parameters = {"SERIAL1_PROTOCOL": 30, "SERIAL2_PROTOCOL": 30} result = realistic_model._set_serial_type_from_fc_parameters(fc_parameters) - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) - esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) - assert esc_type == "SERIAL1" - assert esc_protocol == "ESC Telem" - assert result is True # Multiple ESCs + esc_telem_type = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Type")) + esc_telem_protocol = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Protocol")) + assert esc_telem_type == "SERIAL1" + assert esc_telem_protocol == "ESC Telemetry" + assert ( + result is False + ) # Telemetry-only protocol: FC->ESC is still DShot/PWM, _set_esc_type_from_fc_parameters must run @patch("ardupilot_methodic_configurator.data_model_vehicle_components_import.SERIAL_PORTS", ["SERIAL1"]) def test_system_handles_invalid_serial_protocol_value(self, realistic_model) -> None: @@ -253,8 +255,8 @@ def test_system_handles_invalid_serial_protocol_value(self, realistic_model) -> assert result is False @patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.MOT_PWM_TYPE_DICT", - {"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", + lambda *_: {"6": {"protocol": "DShot600"}}, ) def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None: """ @@ -273,14 +275,14 @@ def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None: realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) - esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) + esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) + esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) assert esc_type == "Main Out" assert esc_protocol == "DShot600" @patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.MOT_PWM_TYPE_DICT", - {"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", + lambda *_: {"6": {"protocol": "DShot600"}}, ) def test_system_imports_esc_aio_configuration(self, realistic_model) -> None: """ @@ -294,16 +296,41 @@ def test_system_imports_esc_aio_configuration(self, realistic_model) -> None: fc_parameters = { "MOT_PWM_TYPE": 6, "SERVO1_FUNCTION": 0, # Not motor function + "SERVO9_FUNCTION": 33, # Motor function on AUX output indicates AIO } doc: dict[str, Any] = {} realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) - esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) + esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) + esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) assert esc_type == "AIO" assert esc_protocol == "DShot600" + def test_user_can_import_esc_connection_and_telemetry_from_serial_fc(self, realistic_model) -> None: + """ + Import ESC serial config into FC->ESC Connection and ESC->FC Telemetry. + + GIVEN: Flight controller serial port protocol maps to ESC. + + WHEN: User imports serial port configuration. + + THEN: ESC FC->ESC Connection and ESC->FC Telemetry should be populated. + """ + fc_parameters = {"SERIAL1_PROTOCOL": 38} + + realistic_model._set_serial_type_from_fc_parameters(fc_parameters) + + esc_conn_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) + esc_conn_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) + esc_telemetry_type = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Type")) + esc_telemetry_protocol = realistic_model.get_component_value(("ESC", "ESC->FC Telemetry", "Protocol")) + + assert esc_conn_type == "SERIAL1" + assert esc_conn_protocol == "FETtecOneWire" + assert esc_telemetry_type == "SERIAL1" + assert esc_telemetry_protocol == "FETtecOneWire" + def test_user_can_import_battery_monitor_configuration(self, realistic_model) -> None: """ User can import battery monitor configuration. @@ -447,8 +474,8 @@ def test_system_skips_disabled_serial_ports(self, realistic_model) -> None: realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) - assert esc_type == "AIO" # Should default to AIO when no main out functions + esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) + assert esc_type == "Main Out" # Should default to Main Out when no AUX functions def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model) -> None: """ @@ -460,15 +487,15 @@ def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model) AND: ESC protocol should be correctly identified """ with patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.MOT_PWM_TYPE_DICT", - {"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", + return_value={"6": {"protocol": "DShot600"}}, ): fc_parameters = {"MOT_PWM_TYPE": 6} doc: dict[str, Any] = {} # Empty doc should trigger fallback realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) + esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) assert esc_protocol == "DShot600" def test_system_handles_esc_protocol_not_found(self, realistic_model) -> None: @@ -885,8 +912,8 @@ def test_system_handles_dshot_without_poles_parameter(self, realistic_model) -> THEN: System should handle gracefully without setting poles """ with patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.MOT_PWM_TYPE_DICT", - {"6": {"protocol": "DShot600", "is_dshot": True}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", + lambda *_: {"6": {"protocol": "DShot600", "is_dshot": True}}, ): fc_parameters = { "MOT_PWM_TYPE": 6, @@ -972,8 +999,8 @@ def test_user_can_import_complete_vehicle_configuration(self, realistic_model, s assert realistic_model.get_component_value(("GNSS Receiver", "FC Connection", "Type")) == "SERIAL2" assert realistic_model.get_component_value(("RC Receiver", "FC Connection", "Type")) == "SERIAL3" assert realistic_model.get_component_value(("RC Receiver", "FC Connection", "Protocol")) == "CRSF" - assert realistic_model.get_component_value(("ESC", "FC Connection", "Type")) == "Main Out" - assert realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) == "DShot600" + assert realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) == "Main Out" + assert realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) == "DShot600" assert realistic_model.get_component_value(("Motors", "Specifications", "Poles")) == 14 assert ( realistic_model.get_component_value(("Battery Monitor", "FC Connection", "Protocol")) @@ -1005,8 +1032,8 @@ def test_system_prioritizes_serial_esc_over_pwm_esc(self, realistic_model, sampl realistic_model.process_fc_parameters(fc_parameters, doc) # Should use serial ESC, not PWM ESC - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) - esc_protocol = realistic_model.get_component_value(("ESC", "FC Connection", "Protocol")) + esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) + esc_protocol = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) assert esc_type == "SERIAL1" assert esc_protocol == "FETtecOneWire" @@ -1164,21 +1191,24 @@ def test_system_detects_main_out_vs_aio_from_servo_functions(self, realistic_mod AND: AIO should be detected when no motor functions on SERVO outputs """ test_cases = [ - # Test case: (servo functions, expected_esc_type) - ([0, 0, 0, 0, 0, 0, 0, 0], "AIO"), # No motors on main out - ([33, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor1 on SERVO1 - ([0, 34, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor2 on SERVO2 - ([0, 0, 35, 0, 0, 0, 0, 0], "Main Out"), # Motor3 on SERVO3 - ([0, 0, 0, 36, 0, 0, 0, 0], "Main Out"), # Motor4 on SERVO4 - ([0, 0, 0, 0, 33, 0, 0, 0], "Main Out"), # Motor1 on SERVO5 (still main out, not AUX) - ([1, 2, 3, 4, 5, 6, 7, 8], "AIO"), # Other functions, no motors - ([33, 34, 35, 36, 0, 0, 0, 0], "Main Out"), # All 4 motors on main out + # Test case: (servo functions 1-14, expected_esc_type) + ([0] * 14, "Main Out"), # No motors anywhere + ([33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor1 on SERVO1 + ([0, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor2 on SERVO2 + ([0, 0, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor3 on SERVO3 + ([0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor4 on SERVO4 + ([0, 0, 0, 0, 33, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # Motor1 on SERVO5 + ([1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 0, 0, 0, 0], "Main Out"), # Other functions on main, no motors + ([33, 34, 35, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "Main Out"), # All 4 motors on main out + ([0, 0, 0, 0, 0, 0, 0, 0, 33, 0, 0, 0, 0, 0], "AIO"), # Motor1 on SERVO9 (AUX) + ([0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 0, 0, 0, 0], "AIO"), # Motor2 on SERVO10 (AUX) + ([0, 0, 0, 0, 0, 0, 0, 0, 33, 34, 35, 36, 0, 0], "AIO"), # Motors on SERVO9-12 (AUX) ] for servo_functions, expected_esc_type in test_cases: fc_parameters = {"MOT_PWM_TYPE": 6} # DShot600 - # Set all servo functions + # Set all servo functions (SERVO1 to SERVO14) for i, function in enumerate(servo_functions, 1): fc_parameters[f"SERVO{i}_FUNCTION"] = function @@ -1186,7 +1216,7 @@ def test_system_detects_main_out_vs_aio_from_servo_functions(self, realistic_mod realistic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - esc_type = realistic_model.get_component_value(("ESC", "FC Connection", "Type")) + esc_type = realistic_model.get_component_value(("ESC", "FC->ESC Connection", "Type")) assert esc_type == expected_esc_type, f"Failed for servo functions {servo_functions}" def test_gps1_type_parameter_support(self, realistic_model) -> None: @@ -1659,15 +1689,19 @@ def test_import_bat_values_skips_all_voltages_when_cell_count_is_zero(self, basi "MOT_BAT_VOLT_MIN": 12.8, }, ) - original_max = basic_model.get_component_value(("Battery", "Specifications", "Volt per cell max")) + basic_model.get_component_value(("Battery", "Specifications", "Volt per cell max")) # Act basic_model._import_bat_values_from_fc(specs) - # Assert: capacity IS set (not guarded by cell count), voltages are NOT set + # Assert: capacity IS set, voltages are set to 0.0 (invalid marker) when cell_count is 0 capacity = basic_model.get_component_value(("Battery", "Specifications", "Capacity mAh")) assert capacity == 4500 - assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell max")) == original_max + assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell max")) == 0.0 + assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell arm")) == 0.0 + assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell low")) == 0.0 + assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell crit")) == 0.0 + assert basic_model.get_component_value(("Battery", "Specifications", "Volt per cell min")) == 0.0 def test_estimate_cell_count_uses_batt_arm_volt_as_fallback(self, basic_model) -> None: """ @@ -1743,19 +1777,19 @@ def test_detect_chemistry_from_mot_bat_volt_min(self, realistic_model) -> None: def test_estimate_cell_count_returns_zero_for_all_invalid_volt_per_cell(self, basic_model) -> None: """ - System returns 0 when all volt-per-cell values are zero/invalid. + System returns 0 when all voltage parameter values are zero or negative. - GIVEN: FC parameters with voltage params but all volt-per-cell stored values are zero + GIVEN: FC parameters where all voltage values are zero WHEN: Calling _estimate_battery_cell_count THEN: Returns 0 and an error is logged """ - # Arrange: all voltage specs are zero (invalid) - basic_model has default 0s + # Arrange: all voltage parameter values are 0, triggering the 'voltage <= 0' guard fc_parameters = { - "MOT_BAT_VOLT_MAX": 16.8, - "BATT_LOW_VOLT": 14.4, - "BATT_CRT_VOLT": 13.2, - "BATT_ARM_VOLT": 15.2, - "MOT_BAT_VOLT_MIN": 12.8, + "MOT_BAT_VOLT_MAX": 0.0, + "BATT_LOW_VOLT": 0.0, + "BATT_CRT_VOLT": 0.0, + "BATT_ARM_VOLT": 0.0, + "MOT_BAT_VOLT_MIN": 0.0, } # Act @@ -2058,7 +2092,7 @@ def test_system_uses_mot_pwm_type_dict_fallback_when_doc_has_no_mot_pwm_type(sel basic_model._set_esc_type_from_fc_parameters(fc_parameters, doc) - result = basic_model.get_component_value(("ESC", "FC Connection", "Protocol")) + result = basic_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) assert result == "Normal" # ------------------------------------------------------------------ diff --git a/tests/test_data_model_vehicle_components_validation.py b/tests/test_data_model_vehicle_components_validation.py index 4a68b6a67..6557ed804 100755 --- a/tests/test_data_model_vehicle_components_validation.py +++ b/tests/test_data_model_vehicle_components_validation.py @@ -704,8 +704,8 @@ def test_validate_all_data_valid_entries(self, realistic_model) -> None: ("RC Receiver", "FC Connection", "Protocol"): "CRSF", ("GNSS Receiver", "FC Connection", "Type"): "SERIAL3", ("GNSS Receiver", "FC Connection", "Protocol"): "uBlox", - ("ESC", "FC Connection", "Type"): "Main Out", - ("ESC", "FC Connection", "Protocol"): "DShot600", + ("ESC", "FC->ESC Connection", "Type"): "Main Out", + ("ESC", "FC->ESC Connection", "Protocol"): "DShot600", } is_valid, errors = model.validate_all_data(valid_entries) @@ -818,7 +818,7 @@ def test_validate_all_data_battery_esc_combinations(self, realistic_model) -> No allowed_entries = { ("Battery Monitor", "FC Connection", "Type"): "other", ("Battery Monitor", "FC Connection", "Protocol"): "ESC", - ("ESC", "FC Connection", "Type"): "Main Out", + ("ESC", "FC->ESC Connection", "Type"): "Main Out", } is_valid, errors = model.validate_all_data(allowed_entries) @@ -1311,8 +1311,8 @@ def test_pwm_output_protocol_choices(self, realistic_model) -> None: model.init_possible_choices({"MOT_PWM_TYPE": {"values": {"0": "Normal", "6": "DShot600"}}}) # Test ESC connection to PWM outputs (not serial or CAN) - model._update_possible_choices_for_path(("ESC", "FC Connection", "Type"), "Main Out") - protocol_choices = model._possible_choices.get(("ESC", "FC Connection", "Protocol"), ()) + model._update_possible_choices_for_path(("ESC", "FC->ESC Connection", "Type"), "Main Out") + protocol_choices = model._possible_choices.get(("ESC", "FC->ESC Connection", "Protocol"), ()) # Should use motor PWM types for PWM outputs assert len(protocol_choices) > 0 @@ -1338,8 +1338,8 @@ def test_comprehensive_connection_type_coverage(self, realistic_model) -> None: ("RC Receiver", "FC Connection", "Type", "I2C1", False), # May not have protocols (not common for RC) ("Telemetry", "FC Connection", "Type", "None", True), # Should have protocols ("Telemetry", "FC Connection", "Type", "SERIAL1", True), # Should have protocols - ("ESC", "FC Connection", "Type", "None", True), # Should have protocols - ("ESC", "FC Connection", "Type", "CAN1", True), # Should have protocols + ("ESC", "FC->ESC Connection", "Type", "None", True), # Should have protocols + ("ESC", "FC->ESC Connection", "Type", "CAN1", True), # Should have protocols ("GNSS Receiver", "FC Connection", "Type", "None", True), # Should have protocols ("GNSS Receiver", "FC Connection", "Type", "SERIAL3", True), # Should have protocols ("Battery Monitor", "FC Connection", "Type", "None", True), # Should have protocols @@ -1348,7 +1348,10 @@ def test_comprehensive_connection_type_coverage(self, realistic_model) -> None: for component, section, field, value, should_have_protocols in test_cases: model._update_possible_choices_for_path((component, section, field), value) - protocol_path = (component, "FC Connection", "Protocol") + if component == "ESC": + protocol_path = (component, "FC->ESC Connection", "Protocol") + else: + protocol_path = (component, "FC Connection", "Protocol") protocol_choices = model._possible_choices.get(protocol_path, ()) if should_have_protocols: diff --git a/tests/test_data_model_vehicle_components_validation_bdd.py b/tests/test_data_model_vehicle_components_validation_bdd.py index 322941f47..0c81fe3f3 100755 --- a/tests/test_data_model_vehicle_components_validation_bdd.py +++ b/tests/test_data_model_vehicle_components_validation_bdd.py @@ -133,7 +133,7 @@ def test_user_sees_validation_errors_for_invalid_combobox_selections(self, valid invalid_entries = { ("Battery", "Specifications", "Chemistry"): "UnknownChemistry", # Chemistry has combobox choices ("RC Receiver", "FC Connection", "Protocol"): "InvalidProtocol", # RC Protocol has choices - ("ESC", "FC Connection", "Protocol"): "NonExistentProtocol", # ESC Protocol has choices + ("ESC", "FC->ESC Connection", "Protocol"): "NonExistentProtocol", # ESC Protocol has choices } # Act: Validate all data @@ -161,7 +161,7 @@ def test_user_sees_validation_errors_for_duplicate_fc_connections(self, validati # Arrange: Create entries with duplicate FC connections that are NOT allowed duplicate_entries = { ("GNSS Receiver", "FC Connection", "Type"): "SERIAL2", - ("ESC", "FC Connection", "Type"): "SERIAL2", # Not allowed duplicate + ("ESC", "FC->ESC Connection", "Type"): "SERIAL2", # Not allowed duplicate ("RC Receiver", "FC Connection", "Type"): "SERIAL3", ( "Battery Monitor", @@ -358,7 +358,7 @@ def test_validation_performance_with_large_dataset(self, validation_model) -> No large_entries[("Battery", "Specifications", "Chemistry")] = "InvalidChem" # Invalid combobox large_entries[("Battery", "Specifications", "Volt per cell max")] = "5.0" # Invalid battery voltage for Lipo large_entries[("RC Receiver", "FC Connection", "Type")] = "SERIAL1" - large_entries[("ESC", "FC Connection", "Type")] = "SERIAL1" # Not allowed duplicate + large_entries[("ESC", "FC->ESC Connection", "Type")] = "SERIAL1" # Not allowed duplicate # Act: Validate all data is_valid, errors = validation_model.validate_all_data(large_entries) diff --git a/tests/unit_data_model_vehicle_components_import.py b/tests/unit_data_model_vehicle_components_import.py index 06721ed69..0d2fe8e67 100755 --- a/tests/unit_data_model_vehicle_components_import.py +++ b/tests/unit_data_model_vehicle_components_import.py @@ -365,17 +365,17 @@ def test_set_battery_type_handles_type_error(self, realistic_model) -> None: realistic_model._set_battery_type_from_fc_parameters(fc_parameters) # pylint: enable=duplicate-code - def test_set_battery_type_handles_list_type_values(self, realistic_model) -> None: + def test_set_battery_type_handles_tuple_type_values(self, realistic_model) -> None: """ - Internal battery type setter handles list-type configuration values. + Internal battery type setter handles tuple-type configuration values. - GIVEN: Battery configuration with list values for type and protocol + GIVEN: Battery configuration with tuple values for type and protocol WHEN: Setting battery type from parameters - THEN: First element of each list should be used + THEN: First element of each tuple should be used """ with patch( "ardupilot_methodic_configurator.data_model_vehicle_components_import.BATT_MONITOR_CONNECTION", - {"4": {"type": ["Analog", "Digital"], "protocol": ["Voltage", "Current"]}}, + {"4": {"type": ("Analog", "Digital"), "protocol": ("Voltage", "Current")}}, ): fc_parameters = {"BATT_MONITOR": 4} realistic_model._set_battery_type_from_fc_parameters(fc_parameters) diff --git a/tests/unit_data_model_vehicle_components_validation.py b/tests/unit_data_model_vehicle_components_validation.py index afe7519fe..a6d2a7326 100755 --- a/tests/unit_data_model_vehicle_components_validation.py +++ b/tests/unit_data_model_vehicle_components_validation.py @@ -100,8 +100,8 @@ def test_update_possible_choices_for_esc_can(self, realistic_model) -> None: model = realistic_model model.init_possible_choices({"MOT_PWM_TYPE": {"values": {"0": "Normal", "6": "DShot600"}}}) - model._update_possible_choices_for_path(("ESC", "FC Connection", "Type"), "CAN1") - protocol_choices = model._possible_choices.get(("ESC", "FC Connection", "Protocol"), ()) + model._update_possible_choices_for_path(("ESC", "FC->ESC Connection", "Type"), "CAN1") + protocol_choices = model._possible_choices.get(("ESC", "FC->ESC Connection", "Protocol"), ()) assert protocol_choices == ("DroneCAN",) @@ -110,8 +110,8 @@ def test_update_possible_choices_for_esc_pwm(self, realistic_model) -> None: model = realistic_model model.init_possible_choices({"MOT_PWM_TYPE": {"values": {"0": "Normal", "6": "DShot600"}}}) - model._update_possible_choices_for_path(("ESC", "FC Connection", "Type"), "Main Out") - protocol_choices = model._possible_choices.get(("ESC", "FC Connection", "Protocol"), ()) + model._update_possible_choices_for_path(("ESC", "FC->ESC Connection", "Type"), "Main Out") + protocol_choices = model._possible_choices.get(("ESC", "FC->ESC Connection", "Protocol"), ()) assert len(protocol_choices) > 0 # pylint: enable=duplicate-code @@ -244,7 +244,7 @@ def test_correct_display_values_preserves_key_values(self, validation_model) -> initial_data = { "Components": { "RC Receiver": {"FC Connection": {"Type": "SERIAL2"}}, - "ESC": {"FC Connection": {"Type": "CAN1"}}, + "ESC": {"FC->ESC Connection": {"Type": "CAN1"}}, }, "Format version": 1, } @@ -258,7 +258,7 @@ def test_correct_display_values_preserves_key_values(self, validation_model) -> # Assert assert model.get_component_value(("RC Receiver", "FC Connection", "Type")) == "SERIAL2" - assert model.get_component_value(("ESC", "FC Connection", "Type")) == "CAN1" + assert model.get_component_value(("ESC", "FC->ESC Connection", "Type")) == "CAN1" def test_correct_display_values_handles_nested_data(self, validation_model) -> None: """Test that correct_display_values_in_loaded_data() handles nested data structures.""" diff --git a/tests/unit_data_model_vehicle_components_validation_constants.py b/tests/unit_data_model_vehicle_components_validation_constants.py index 0193b6bdd..cff893a7e 100755 --- a/tests/unit_data_model_vehicle_components_validation_constants.py +++ b/tests/unit_data_model_vehicle_components_validation_constants.py @@ -52,7 +52,11 @@ def test_fc_connection_type_paths_structure(self) -> None: # All paths should follow the pattern (Component, "FC Connection", "Type") for path in FC_CONNECTION_TYPE_PATHS: - assert path[1] == "FC Connection" + assert path[1] in [ + "FC Connection", + "FC->ESC Connection", + "ESC->FC Telemetry", + ] assert path[2] == "Type" def test_battery_cell_voltage_paths_structure(self) -> None: @@ -111,7 +115,7 @@ def test_serial_protocols_dict_structure(self) -> None: assert set(value.keys()) == required_fields, f"Value for key '{key}' missing required fields" # Check field types - assert isinstance(value["type"], list), f"'type' field for key '{key}' is not a list" + assert isinstance(value["type"], tuple), f"'type' field for key '{key}' is not a tuple" assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' is not a string" assert value["component"] is None or isinstance(value["component"], str), ( @@ -127,7 +131,7 @@ def test_serial_protocols_dict_structure(self) -> None: assert isinstance(port_type, str), f"Port type in key '{key}' is not a string" # Type should reference known port lists - assert type_list in (SERIAL_PORTS, ["None"]), f"'type' field for key '{key}' does not reference SERIAL_PORTS" + assert type_list in (SERIAL_PORTS, ("None",)), f"'type' field for key '{key}' does not reference SERIAL_PORTS" # Verify some expected protocols exist expected_protocols = { @@ -163,7 +167,7 @@ def test_batt_monitor_connection_structure(self) -> None: assert set(value.keys()) == required_fields, f"Value for key '{key}' has incorrect fields" # Check field types - assert isinstance(value["type"], list), f"'type' field for key '{key}' is not a list" + assert isinstance(value["type"], tuple), f"'type' field for key '{key}' is not a tuple" assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' is not a string" # Type should reference known port lists or be specific port names @@ -229,7 +233,7 @@ def test_gnss_receiver_connection_structure(self) -> None: assert set(value.keys()) == required_fields, f"Value for key '{key}' has incorrect fields" # Check field types - assert isinstance(value["type"], list), f"'type' field for key '{key}' is not a list" + assert isinstance(value["type"], tuple), f"'type' field for key '{key}' is not a tuple" assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' is not a string" # Type should reference known port lists or be specific port names @@ -263,43 +267,47 @@ def test_gnss_receiver_connection_structure(self) -> None: def test_mot_pwm_type_dict_structure(self) -> None: """Test MOT_PWM_TYPE_DICT structure and data types.""" - # Should be a dict + # Should be a dict of vehicle-type sub-dicts assert isinstance(MOT_PWM_TYPE_DICT, dict) assert len(MOT_PWM_TYPE_DICT) > 0 - # Keys should be strings representing PWM type numbers - for key in MOT_PWM_TYPE_DICT: - assert isinstance(key, str) - # Should be convertible to int - try: - int(key) - except ValueError: - pytest.fail(f"Key '{key}' is not a valid integer string") - - # Values should be dicts with specific structure - required_fields = {"type", "protocol", "is_dshot"} - for key, value in MOT_PWM_TYPE_DICT.items(): - assert isinstance(value, dict), f"Value for key '{key}' is not a dict" - assert set(value.keys()) == required_fields, f"Value for key '{key}' has incorrect fields" - - # Check field types - assert isinstance(value["type"], list), f"'type' field for key '{key}' is not a list" - assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' is not a string" - assert isinstance(value["is_dshot"], bool), f"'is_dshot' field for key '{key}' is not a boolean" - - # Type should reference PWM output ports - assert value["type"] == PWM_OUT_PORTS, f"'type' field for key '{key}' does not reference PWM_OUT_PORTS" - - # Verify some expected PWM types exist + # Top-level keys are vehicle type strings (e.g. "ArduCopter", "Rover") + for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + assert isinstance(vtype, str), f"Vehicle type key '{vtype}' is not a string" + assert isinstance(sub_dict, dict), f"Sub-dict for '{vtype}' is not a dict" + + # Inner keys should be integer strings representing PWM type numbers + for key in sub_dict: + assert isinstance(key, str) + try: + int(key) + except ValueError: + pytest.fail(f"Key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is not a valid integer string") + + # Values should be dicts with specific structure + required_fields = {"type", "protocol", "is_dshot"} + for key, value in sub_dict.items(): + assert isinstance(value, dict), f"Value for key '{key}' in '{vtype}' is not a dict" + assert set(value.keys()) == required_fields, f"Value for key '{key}' in '{vtype}' has incorrect fields" + + # Check field types + assert isinstance(value["type"], tuple), f"'type' field for key '{key}' in '{vtype}' is not a tuple" + assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' in '{vtype}' is not a string" + assert isinstance(value["is_dshot"], bool), f"'is_dshot' field for key '{key}' in '{vtype}' is not a boolean" + + # Type should reference PWM output ports + assert value["type"] == PWM_OUT_PORTS, f"'type' for key '{key}' in '{vtype}' does not reference PWM_OUT_PORTS" + + # Verify expected PWM types exist in the ArduCopter sub-dict expected_pwm_types = { "0": {"protocol": "Normal", "is_dshot": False}, "6": {"protocol": "DShot600", "is_dshot": True}, } - + copter_sub = MOT_PWM_TYPE_DICT["ArduCopter"] for key, expected_data in expected_pwm_types.items(): - assert key in MOT_PWM_TYPE_DICT - assert MOT_PWM_TYPE_DICT[key]["protocol"] == expected_data["protocol"] - assert MOT_PWM_TYPE_DICT[key]["is_dshot"] == expected_data["is_dshot"] + assert key in copter_sub + assert copter_sub[key]["protocol"] == expected_data["protocol"] + assert copter_sub[key]["is_dshot"] == expected_data["is_dshot"] def test_rc_protocols_dict_structure(self) -> None: """Test RC_PROTOCOLS_DICT structure and data types.""" @@ -323,7 +331,7 @@ def test_rc_protocols_dict_structure(self) -> None: assert set(value.keys()) == required_fields, f"Value for key '{key}' has incorrect fields" # Check field types - assert isinstance(value["type"], list), f"'type' field for key '{key}' is not a list" + assert isinstance(value["type"], tuple), f"'type' field for key '{key}' is not a tuple" assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' is not a string" # Type should be combination of RC_PORTS + SERIAL_PORTS or CAN_PORTS @@ -370,8 +378,8 @@ def test_port_definitions_consistency(self) -> None: ] for port_name, port_list in port_lists: - # Should be a list - assert isinstance(port_list, list), f"{port_name} is not a list" + # Should be a tuple (constants use tuple for immutability) + assert isinstance(port_list, tuple), f"{port_name} is not a tuple" # Should not be empty assert len(port_list) > 0, f"{port_name} is empty" @@ -418,9 +426,10 @@ def test_protocol_number_ranges(self) -> None: assert 0 <= gps_num <= 50, f"GPS type number {gps_num} is out of expected range" # Motor PWM type numbers should be reasonable (typically 0-10) - for key in MOT_PWM_TYPE_DICT: - pwm_num = int(key) - assert 0 <= pwm_num <= 20, f"Motor PWM type number {pwm_num} is out of expected range" + for sub_dict in MOT_PWM_TYPE_DICT.values(): + for key in sub_dict: + pwm_num = int(key) + assert 0 <= pwm_num <= 20, f"Motor PWM type number {pwm_num} is out of expected range" # RC protocol numbers should be reasonable bit positions (typically 0-15) for key in RC_PROTOCOLS_DICT: @@ -433,7 +442,6 @@ def test_protocol_names_not_empty(self) -> None: ("SERIAL_PROTOCOLS_DICT", SERIAL_PROTOCOLS_DICT), ("BATT_MONITOR_CONNECTION", BATT_MONITOR_CONNECTION), ("GNSS_RECEIVER_CONNECTION", GNSS_RECEIVER_CONNECTION), - ("MOT_PWM_TYPE_DICT", MOT_PWM_TYPE_DICT), ("RC_PROTOCOLS_DICT", RC_PROTOCOLS_DICT), ] @@ -443,13 +451,22 @@ def test_protocol_names_not_empty(self) -> None: assert isinstance(protocol_name, str), f"Protocol name for key '{key}' in {dict_name} is not a string" assert len(protocol_name.strip()) > 0, f"Protocol name for key '{key}' in {dict_name} is empty or whitespace" + for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + for key, value in sub_dict.items(): + protocol_name = value["protocol"] + assert isinstance(protocol_name, str), ( + f"Protocol name for key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is not a string" + ) + assert len(protocol_name.strip()) > 0, ( + f"Protocol name for key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is empty" + ) + def test_no_protocol_duplicates_within_dict(self) -> None: """Test that there are no duplicate protocol names within each dictionary.""" protocol_dicts = [ ("SERIAL_PROTOCOLS_DICT", SERIAL_PROTOCOLS_DICT), ("BATT_MONITOR_CONNECTION", BATT_MONITOR_CONNECTION), ("GNSS_RECEIVER_CONNECTION", GNSS_RECEIVER_CONNECTION), - ("MOT_PWM_TYPE_DICT", MOT_PWM_TYPE_DICT), ("RC_PROTOCOLS_DICT", RC_PROTOCOLS_DICT), ] @@ -467,6 +484,17 @@ def test_no_protocol_duplicates_within_dict(self) -> None: f"Too many duplicate protocol names in {dict_name}: {len(unique_names)}/{len(protocol_names)} unique" ) + # Check uniqueness within each vehicle-type sub-dict of MOT_PWM_TYPE_DICT + for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + protocol_names = [value["protocol"] for value in sub_dict.values()] + unique_names = set(protocol_names) + assert len(unique_names) > 0, f"No protocols found in MOT_PWM_TYPE_DICT['{vtype}']" + uniqueness_ratio = len(unique_names) / len(protocol_names) + assert uniqueness_ratio >= 0.8, ( + f"Too many duplicate protocol names in MOT_PWM_TYPE_DICT['{vtype}']: " + f"{len(unique_names)}/{len(protocol_names)} unique" + ) + def test_serial_bus_labels_structure(self) -> None: """Test SERIAL_BUS_LABELS structure and data types.""" # Should be a dictionary From 07cf98897f107867691ba8081be965343169ab94 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Fri, 10 Apr 2026 19:52:37 +0200 Subject: [PATCH 3/3] docs(tuning guide): Add component editor documentation --- TUNING_GUIDE_ArduCopter.md | 228 +++++++++++++++++- images/blog/component_editor_battery.png | Bin 0 -> 10821 bytes ...omponent_editor_battery_monitor_analog.png | Bin 0 -> 5738 bytes .../component_editor_battery_monitor_can.png | Bin 0 -> 2375 bytes .../component_editor_battery_monitor_i2c.png | Bin 0 -> 6908 bytes .../component_editor_battery_monitor_none.png | Bin 0 -> 1831 bytes ...component_editor_battery_monitor_other.png | Bin 0 -> 3739 bytes .../component_editor_battery_monitor_pwm.png | Bin 0 -> 2651 bytes .../component_editor_battery_monitor_spi.png | Bin 0 -> 2171 bytes images/blog/component_editor_esc_main_out.png | Bin 0 -> 6065 bytes ...omponent_editor_esc_telem_main_out_aio.png | Bin 0 -> 2466 bytes .../component_editor_esc_telem_serial.png | Bin 0 -> 2596 bytes images/blog/component_editor_firmware.png | Bin 0 -> 1424 bytes .../component_editor_flight_controller.png | Bin 0 -> 8637 bytes images/blog/component_editor_frame.png | Bin 0 -> 4798 bytes images/blog/component_editor_gnss.png | Bin 0 -> 7473 bytes images/blog/component_editor_motors.png | Bin 0 -> 4273 bytes images/blog/component_editor_product.png | Bin 0 -> 2023 bytes images/blog/component_editor_propellers.png | Bin 0 -> 4964 bytes 19 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 images/blog/component_editor_battery.png create mode 100644 images/blog/component_editor_battery_monitor_analog.png create mode 100644 images/blog/component_editor_battery_monitor_can.png create mode 100644 images/blog/component_editor_battery_monitor_i2c.png create mode 100644 images/blog/component_editor_battery_monitor_none.png create mode 100644 images/blog/component_editor_battery_monitor_other.png create mode 100644 images/blog/component_editor_battery_monitor_pwm.png create mode 100644 images/blog/component_editor_battery_monitor_spi.png create mode 100644 images/blog/component_editor_esc_main_out.png create mode 100644 images/blog/component_editor_esc_telem_main_out_aio.png create mode 100644 images/blog/component_editor_esc_telem_serial.png create mode 100644 images/blog/component_editor_firmware.png create mode 100644 images/blog/component_editor_flight_controller.png create mode 100644 images/blog/component_editor_frame.png create mode 100644 images/blog/component_editor_gnss.png create mode 100644 images/blog/component_editor_motors.png create mode 100644 images/blog/component_editor_product.png create mode 100644 images/blog/component_editor_propellers.png diff --git a/TUNING_GUIDE_ArduCopter.md b/TUNING_GUIDE_ArduCopter.md index 87d5da5f2..91ecb8c20 100644 --- a/TUNING_GUIDE_ArduCopter.md +++ b/TUNING_GUIDE_ArduCopter.md @@ -140,9 +140,231 @@ So, [start the ArduPilot Methodic Configurator and select a vehicle that resembl 1. select the destination directory, give it a name and press `Create a vehicle configuration directory from template` 1. On the component editor window, **add all the details of the components of your system** as we did in [Section 1.2](#12-our-example-vehicle): ![Component editor window](images/App_screenshot_Component_Editor.png) -1. Make sure to **scroll all the way down and enter all the information requested**, even if it does not seem important to you. -1. Click the `Save data and start configuration` button on the bottom -1. You now have a vehicle configuration directory with the name that you selected. But the files are just templates, you need to edit them in the next steps. + +Most optional information fields are only visible in `normal` GUI complexity mode. + +All components have **optional** information about the product itself: + +![product](images/blog/component_editor_product.png) + +The URL can be used to store a link to a datasheet or a link to a shop product page. + +Some components have **optional** information about their firmware: + +![firmware](images/blog/component_editor_firmware.png) + +All components have an **optional** notes field. + +## Flight Controller + +![flight controller](images/blog/component_editor_flight_controller.png) + +Some information, if available, is automatically filed in by the software as seen in the example above. + +## Frame + +![frame](images/blog/component_editor_frame.png) + +The minimum take off weight and the maximum take off weight in Kilo are entered here. +If you have variable payload configure the vehicle at the minimum take off weight. +Only after completely tuned can you add the additional payload. + +## Battery Monitor + +All supported connection types and their corresponding protocols are: + +| Connection Type | Protocol | +| --------------- | -------- | +| `None` | `Disabled` | +| `Analog` | `Analog Voltage Only` | +| `Analog` | `Analog Voltage and Current` | +| `Analog` | `FuelLevelAnalog` | +| `Analog` | `Synthetic Current and Analog Voltage` | +| `I2C1`–`I2C4` | `Solo` | +| `I2C1`–`I2C4` | `Bebop` | +| `I2C1`–`I2C4` | `SMBus-Generic` | +| `I2C1`–`I2C4` | `FuelFlow` | +| `I2C1`–`I2C4` | `SMBUS-SUI3` | +| `I2C1`–`I2C4` | `SMBUS-SUI6` | +| `I2C1`–`I2C4` | `NeoDesign` | +| `I2C1`–`I2C4` | `SMBus-Maxell` | +| `I2C1`–`I2C4` | `Generator-Elec` | +| `I2C1`–`I2C4` | `Generator-Fuel` | +| `I2C1`–`I2C4` | `Rotoye` | +| `I2C1`–`I2C4` | `MPPT` | +| `I2C1`–`I2C4` | `INA2XX` | +| `I2C1`–`I2C4` | `LTC2946` | +| `I2C1`–`I2C4` | `EFI` | +| `I2C1`–`I2C4` | `AD7091R5` | +| `CAN1`–`CAN2` | `DroneCAN-BatteryInfo` | +| `PWM` | `FuelLevelPWM` | +| `SPI` | `INA239_SPI` | +| `other` | `ESC` | +| `other` | `Sum Of Selected Monitors` | +| `other` | `Torqeedo` | +| `other` | `Scripting` | + +It is strongly recommended to use a battery monitor. +But if you do not have one select `none` in the flight controller connection: + +![battery monitor none](images/blog/component_editor_battery_monitor_none.png) + +If your battery monitor has an analog connection to the FC, select `analog` and one of the possible protocols: + +![battery monitor analog](images/blog/component_editor_battery_monitor_analog.png) + +If your battery monitor has an I2C connection to the FC, select the I2C bus and one of the possible protocols: + +![battery monitor i2c](images/blog/component_editor_battery_monitor_i2c.png) + +If your battery monitor has a CAN connection to the FC, select the CAN bus: + +![battery monitor can](images/blog/component_editor_battery_monitor_can.png) + +If your battery monitor has a SPI connection to the FC, select the SPI bus: + +![battery monitor spi](images/blog/component_editor_battery_monitor_spi.png) + +If your battery monitor has a PWM connection to the FC, select the PWM: + +![battery monitor pwm](images/blog/component_editor_battery_monitor_pwm.png) + +Otherwise select `other` and one of the possible protocols: + +![battery monitor other](images/blog/component_editor_battery_monitor_other.png) + +## Battery + +![battery](images/blog/component_editor_battery.png) + +Select the correct battery chemistry, doing so will automatically set typical voltage thresholds for that battery chemistry. + +Afterwards you should tweak the voltage thresholds to meet your requirements. + +- `Volt per cell max` - PID values will only scale when below this voltage +- `Volt per cell arm` - vehicle will only arm if battery voltage is above this threshold +- `Volt per cell low` - first failsafe level gets triggered when below this value +- `Volt per cell crit` - second failsafe level gets triggered when below this value +- `Volt per cell min` - PID values will only scale when above this voltage + +They must obey `Volt per cell crit` < `Volt per cell low` < `Volt per cell arm` < `Volt per cell max` + +`Number of cells` is the number of cells connected in series. +For a 6S battery this is 6. + +## ESC + +Electronic speed controllers have a `FC->ESC Connection` for control of the motor speed and +an optional `ESC->FC Telemetry` for telemetry feedback from the ESC to the flight controller. + +![esc main out](images/blog/component_editor_esc_main_out.png) + +The `FC->ESC Connection` type can be `Main Out`, an `AIO` integrated output, a serial port, or a CAN bus. +The protocol is determined by the `MOT_PWM_TYPE` parameter (e.g. `Normal`, `DShot600`) for PWM outputs, +or the serial/CAN protocol (e.g. `FETtecOneWire`, `DroneCAN`) for digital connections. + +The `ESC->FC Telemetry` type and protocol describe the return path: + +| Connection Type | Protocol | Notes | +| --------------- | -------- | ----- | +| `None` | `None` | No ESC telemetry | +| same as FC->ESC | `BDShot` | BDShot only on Main Out and/or AIO, without serial port backup channel | +| serial port | `ESC Telemetry` | DShot or BDShot serial telemetry backup channel | +| serial port | `FETtecOneWire` | Bidirectional FETtec protocol on the same wire | +| serial port | `Scripting` | For T-Motor/Hobbywing Datalink v2 serial telemetry | +| serial port | `Torqeedo` | For Torqeedo telemetry | +| serial port | `CoDevESC` | For CoDevESC serial telemetry | +| CAN port | `DroneCAN` | Telemetry over CAN bus | + +When using BDShot only on `Main Out` (1-8) and/or `Aux I/O` (9-14) connection, without serial port backup channel: + +![ESC telemetry BDshot only](images/blog/component_editor_esc_telem_main_out_aio.png) + +When using DShot or BDShot with a serial port backup channel: + +![ESC telemetry serial](images/blog/component_editor_esc_telem_serial.png) + +## Motors + +![Motor configuration interface showing pole count input](images/blog/component_editor_motors.png) + +Enter the number of magnetic **poles** of the motor rotor. +This is the **P** number in the common `nNmP` motor winding notation (e.g. `12N14P` → 14 poles). +The value must be an even integer. +It is used by ArduPilot to calculate the actual motor RPM from the ESC telemetry electrical frequency. + +## Propellers + +![Propeller configuration interface showing diameter input](images/blog/component_editor_propellers.png) + +Enter the propeller **diameter in inches**. +This value affects many initial PID values. + +## GNSS Receiver + +![GNSS receiver configuration interface](images/blog/component_editor_gnss.png) + +Select the FC connection **type** (serial port or CAN bus) and the matching **protocol**: + +| Connection Type | Protocol | +| --------------- | -------- | +| None | `None` | +| CAN bus | `DroneCAN` | +| serial port — auto-detect | `AUTO` | +| serial port — vendor-specific | other | + +If you do not have a GNSS receiver, select `None` as the connection type. + +## RC Controller + +The hand-held controller used by the pilot. +Enter the manufacturer and model for documentation purposes. +This component has no FC connection — it communicates wirelessly via the RC Transmitter and RC Receiver pair. + +## RC Transmitter + +The RF transmitter module (may be integrated in the RC Controller or a separate module). +Enter the manufacturer and model for documentation purposes. +This component has no FC connection. + +## RC Receiver + +Select the FC connection **type** and **protocol** that match how the receiver is wired to the flight controller: + +| Connection Type | Protocol | +| --------------- | -------- | +| RCin/SBUS — auto-detect all protocols | `All` | +| RCin/SBUS | `PPM` | +| RCin/SBUS or serial | `SBUS` / `SBUS_NI` | +| serial port | `DSM` | +| serial port | `CRSF` | +| serial port | `FPORT` | +| serial port | `MAVRadio` | +| serial port — vendor-specific | other | + +If your receiver is connected to a dedicated RC input pin, choose `RCin/SBUS` as the type. +If it is connected to a UART (e.g. CRSF, FPORT, DSM), choose the corresponding serial port. + +## Telemetry + +Select the FC connection **type** (serial port or CAN bus) and the matching **protocol**: + +| Connection Type | Protocol | Notes | +| --------------- | -------- | ----- | +| None | `None` | when not present | +| serial | `MAVLink2` | recommended for most ground stations | +| serial | `MAVLink1` | legacy ground stations | +| serial | `MAVLink High Latency` | satellite / low-bandwidth links | +| serial | `DDS XRCE` | ROS 2 micro-XRCE-DDS bridge | +| serial | other | vendor-specific | + +If you do not have a telemetry radio, select `None` as the connection type. + +Make sure to **scroll all the way down and enter all the information requested**, even if it does not seem important to you. +Click the `Save data and start configuration` button on the bottom + +You now have a vehicle configuration directory with the name that you selected. +But the files are just templates, you need to edit them in the next steps. # 4. Perform IMU temperature calibration before assembling the autopilot into the vehicle (optional) diff --git a/images/blog/component_editor_battery.png b/images/blog/component_editor_battery.png new file mode 100644 index 0000000000000000000000000000000000000000..38dfacb4b6aea17211236a53fad16ef723e773a3 GIT binary patch literal 10821 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

DOcqTYM>f~Fg z_a72mdJYB$Bt13v?z9}&mAGWbvPea6Hk%(2EhCvvM*s#kJ2I1lQzTf`bWBy>95N7G zcM{lO#S|}tVzuKII!F>-Nam$UG$FXa7mo}KRHw0Qs05@u7aXusc10-HH#=h6iAZR# zQx?*&NLO`ShB$$EF29>TI6qPR9bB|7DN<;d3Z^bOol84*-D1W{6kWNpC0@Ars&s<1 zcSHux__XaM>j2ZK8RD>bw*Rh;AgpGF|rZ0D!21)jWF<^hFHzI&Um(DR~J zT+C-*iqc-S{xXt)(71Ra9e=%W=2R(chigN>>uDS01AEYIq2s}}-|dvAS(_9Xm@$6w zNKzum29^v<1e(Qk`%9B?tA2N)*|9+j@7%Ww$}kM!s*7;WOuk3$z)VcgK_rEw=xJkR z-d4~C6fVl{j7RN=D1qsB&L**j(^?-Ga>}E(0p?fc^fW*Q70ZNyW-?@P?q;i4&x4rD zJpH{;3E{WK5AaX!x?P~(34}J9q(Dwah|ZP~kwj}VoWJ_#IBvKs5cW~dPZffhA_2c8 z>MjuF<>b8Gn>)^Jm35>*ULh2z+P~IL@oY$wT%V0289=dG4;QQ=aMK>Ox*yB%UDgA8 zJsLMW%Ogxl9*%pCO7ou8+8w)X^apY2;RHnSt=q z>6gaTM(y2+(!H$%@`eordK*Jb*R`Mcrt02T{cL5opMSGKL8}+PL>7*!FAgDZeyb;D z-LIj$GQ^{KV_x2dC`|UJ4X$f;Dp*Yb<~NBnBrE||`d0XR#s>~q+aCTk^h;y8aY@%5 zbhKnxD^Gw>RTbdpuWe{ZJ$ODx4KTc~kQ=r*H-G(}_{n;r`Ttoq0kdV-%}^-^k3i=D x1}BzORq?Jff*#xg@c(X_@`v#cHzl}B$^w7!G*(yH6o3qZq{S7$MWO~D{uiSaJVXEh literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_battery_monitor_analog.png b/images/blog/component_editor_battery_monitor_analog.png new file mode 100644 index 0000000000000000000000000000000000000000..571259d4b81135cd1675862656a96d63b059d997 GIT binary patch literal 5738 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 e|Jwi$5}`aId3zV$_J|%bpeUy%TO(~2@qYlTC zO>5#@8^8f$(VrU%P=%x%v$AvAzFq^s&)O!|L6tt^u zYCy_VgvzsXBZWju$*Ld2XOZ{h*u=M}?e*J?bG%N@N%HS`G3TEXk|YTbWX=4u=?Wm9 z0@N821b%eZFVQAxLDAC{Ax%iN;2E%H6d}Etf)OV^+p~DFE2*?HVF2|Ore1PdqVx-T zeR~eifOXL?8QKbGT6%qv&GPR;xfJ0?g=$J=Mo$Q5?6=c10Qp=jG(J;$;*dLJ77DQR zr6sL}>zC-XAe8JKEHTlVUC(Vx>X+!VHVi8%^?OU(=C@pAE%?;gu#&Rhr1)2l=7}88 zFQqC}Q<5Y}>K5fQL{{y=n@|%J07Xa}Nm7|%%2Jb4 zd-*c6Xt~JhG5gJSyG@MXxIKV)v1=LVC;-D31>ilY5d)N08MvG(fN&6jojoVXZXY^C9ztcxNXt&#r zzu3j*n*dB%>V8e9Xj=Vmyx48|31G_IqMjHpc3X3{cWQRMZZTEgc}ND!dTkRU*vAwhsTLxNz@`R4re zdcDBPLV|?ndC%!%a5@8kR;%S5gzOZ5|Niw87O2j^ceRk6K$hjVx3|5$J#Uv-$>;UYllxmZJwHE%+k2bPPA{&V+Ea5rO{evf zHCOoa;_TOGn%0-#%!PlQDMH#@ldrG5@0aMb(ASpUzq^Xx!sPyTz@M=0FF=#oaU^o= z5?VBW5i4ZeN<-F(`*j72y}-b~&cc}-*M86~91m?Sr*@^oN)3KVlGH6i<%ggEX%)YV z#D0`mh$b@tg3y4icTj1;V-?oIZ^@Xa-((JTq4ghRZDBn?y!a~<233c{7e!r+ulrWc z+QS!;&IZq8QVE94RG}4qJ4R$|y%TdKB5OyL*D|kSnQMH;d@IT|K~aR7%pSPr1!<%m-+KWLh!Qx&SIwp~oQTLh=!(+*poj;}{Y`tN5MIA%e- zYb3NzQ)NcG7s(6PiTSFHY?}94 zs52x8P-jRG?0~NhjqF>_*7?yF294}n%a;1l7Y2>&TL}8mk)=RCIko;@knwu~!^%i#Q;7)yZK#FnuawHk8a} zlUk2t>G$+@L(GPf)oRw+8Jdx5#z2hn;$4HkP+JnSH_eIwVm5TtfI%S|3T3mF&my6! z<|!wWNlUZtYy{mWZkD125-yiXaDH?}sI}vUk8UV`{Uu3dhOh&iW3M>k7IBX4lLXq4BTsYlS! z8%(yYjRE+^RdvGFryXAHUh8$sXO>)5?UbLME_kC^w`kZ>uIFfZtPX+#S0kGNc9jI`M>h$7p+JADiz3w8uHA!)0pSBSZnvs?9%m+MgYUkJ zRLoGpqg)l2&0kgw?^o2nq`v+hL zbhd?4U}Ro!zKH|@>I?}2)EN>4s52x8Z1aH2%gasG1NqpP&o?QG+H6&j4~F%Jf`5sr t2(nsme1-%8>I?}2)EN>4s52x8{tpLuVWkiNsfGXm002ovPDHLkV1nA6mAC)^ literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_battery_monitor_i2c.png b/images/blog/component_editor_battery_monitor_i2c.png new file mode 100644 index 0000000000000000000000000000000000000000..7155dd78a2b08ae70ed3ed2e9f13489d0ab11366 GIT binary patch literal 6908 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 D-+DN# literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_battery_monitor_none.png b/images/blog/component_editor_battery_monitor_none.png new file mode 100644 index 0000000000000000000000000000000000000000..42a3eba464bba56fbe16412e182dd41417b28cc4 GIT binary patch literal 1831 zcmV+?2iW+DP) zJx}9G6o!wl_D@h*2(TNPw5()Nph%|L3a4{{2&**EtdJr$g+Z3kZ9)?Xa<_01$=>Q# zM2gT7MOt(_H)y3XAmoal;am74cH+bzB$F7k+@-=CH0J>sIv zEP#6o%g;5hkX%525Ur6IFfRs;KwGS=`^SqckWYCl+S-ZAwTUA51*{xBorwX+2O{tF znceT2(vFZTAe2iRUJo~D@D)vKL=$XmVYs?qJ5n@g@COE%YT5lh)VHN=7nwcjb!MvN z=t(NAC3H{m0fRjjnS`vL%fup>e1#Reg^5MC@WjWVx#N45j@OV^ZYdQwVG-R|pu@jn8R6-knU&!g98 z-@kt!uV7jKj25xmVoALnD8i)FnHZ2CDSXuj_RDPd9{UR*S+OjO<2cK*+|8JAK=9pE zSh*(g2RsDl+}1Q(mW9NCRStap*;>nxYD$*FXID8;TiU)5bgk^RSc;ZRu9&9XbbmgQ zfMFQn|JdB)O#ne5y|qf2=iSXxy{D%M5R_isFD%u2ddB|Ft?nmIsZA6?*4xj#S0c5E zA_zOu!9kf2wmqa2$Z-XVS!raZqOn4JPU=;s}`iG{Ej5!!U>4Xvycxr`ffD zEE5xTCMfR2L4VF5nfWyPt*wXMh|-Yv%Ob}Cd#BBx=a*07(fR@p0!f1RC)|X|`dXGu z*Jrj?8P3V~XWkDQd_^00Q}3tF;vZw<=ydZ~9#57}m!BCAzr*VuGM_Im58c9HejP?) z&p0o}C6(jdKbdsK$~DQj^(`E?hE#J?sj0T$f)LWHgL8<2a6U1Lt^gH#fq<%J+h)jN?sV#~0e* z*77FS1C;6)G7GK_&1c)4jPLta4h-{o$^b&Vy4mz(Z!ug`@2dh0~9S!h|}#n`yOez5>{ z+|SWj{8JFWHP1H=yEhq+#HD)UT#31AB3XIMtMW46KV!E==>GW81#vr&nc3~z03pvN zyUKwvC0#~YKhl~HySvxk7Pkr4|GFLA)8+ZK-?zoo<2Kz5F&ccp#`b*M;*2{^2M)pE7y+8Au=tI9; zGL!T%L(s@xJW}ONR<23D*JK&kJ8eFo|AZE!?^)sbScm#_vRhWQdK_fdw==srYt!0z~ADwBfT@a!OF)P50tQ8l#MBK>oaxi%%xoM!0 zohI}4qkEq6vdXoGZwvAjXTMm1?_8c>cO!A?6z2nTGbQxGZ8eh7lyaorxlc5-8$fTp6AP5v8VXU zr3T}G*Z@}6+njGzZww~wCmm>HF9^k0udd(k>$;B5(P@(A`KDnQcXxN4PG@ChWkfc( zULCRkaJ~jIkwy8@kq<<%{ph(tBRgdZ>_^WH8rh3ZF?6QSd3Y6hUSSUY{W}K%F5pK%F5p_#d1X VZqDmbE4u&y002ovPDHLkV1iSaiqZf8 literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_battery_monitor_other.png b/images/blog/component_editor_battery_monitor_other.png new file mode 100644 index 0000000000000000000000000000000000000000..59a40f1ee34cecd26e82ef5218e5f436860f63f7 GIT binary patch literal 3739 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 literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_esc_main_out.png b/images/blog/component_editor_esc_main_out.png new file mode 100644 index 0000000000000000000000000000000000000000..050732e2d2893f485f28797837ff16e35c5698ff GIT binary patch literal 6065 zcmaKQcQjmG`}U+f1knjb7eUlu)aYcCFbEPgg6K7R@C>3w8NC|@gUM(SCDD5wb&MV* zdJ7UII=}I(_gU||zCXTut?TS__B#9AXWi|(_m0$osFD%`i2(orsk)lV3;fuK$IpAW z@mIw{6A}P`&R1PUQQsH7ldWyXIN972sHfEMe)#c94d>r0Q6mX7OuAb;pFVcq?NFns zPW(AZXq+6QAwJrjOy5JIDoNuC%F^lkOwu-3Q-b-SL1m>{+VQHXE*1C7ezFOK9RwD7HZWb(Mff`-b$_)?9XC$A?;Q?c61Nu z!~;xVV6{`jsR$Lj5rXewU0J!H2|=1a>@-`;RH&wi;A!)Ng=^F1Ky6D@0)5A$;d>~C zoS87zn{{40MDMA*wM)zlLEenq)yGS2yB=|9bWYmF0ZY7vJ2AHUIJDygHm}{AG*$O| zMC8X5RXwHr>`B{H|8Zj{5$%2DRKL|rv1`k`3LBOlClVOS@z|Mv2CtC6eX;vPKtQ0@ z)0%~Cs+fmZCKvwnYbN8Kk&qETB(_2~b|Tl5$=LO0W%~!T@$mDhS5xLmJ*8memxj?s zv@E&_K%mcjZR7zFV*b#Uj{7ay|8y#ZJ+=sY=D_|Tbz`8zf!ywU&Oz@ zpaVT+9DzP&>UP~ZJY(HFum5~Kd9@cH9@(?p9Vbq`_|#|D^MQEQwUxkrz~GRx%&a-N z5tuN`D6bS*mof)NBFg)cPToB;^sQ}ao?oSs-LtrtPP+h=nw@aI?zK&_soFd^%^WOx zX?eZVXNY{7(^*z~UBp`5;?kgF=QE^?_b}^^9=VlDa~@wRBJy6n6sZYZ&!c*S+NC=N znmg^6mKPI#tS#~LTePfBvqAxMqxFizlRx2g1?Q2Mrr%mxer4nu@*8vRZt(dK<{fqK zu1AwVTQ1(YSo@%=61l%v=@A3=;Wo-o#on!knLG0~kH@oW-#F-A*->+)t$+8X?zLQBwS^E~f<97P+|4~#suAD;b_#PxcE4R> zZVpycdqI;z!(Yh3TJVWYXeOI|zs)709wdZ2mQpi^b7qdzJ4wBm1H-z9`Wzc$Ezfy&~_|SVcy3s zL%tXH+U}bgB++H_zGZK+-@z4;Z?`TUJC@&8m88{bsIdEG$vZK1lP4DDzq@nns%k_U z8i6CMs9xKx>D5o(`m7eox|-Wmp`5fUrv;ZA4_|7C31Wa68)`9ViDyrIc=v~u5{cLI zQvb}vqzEEKT>>lAps4)coGx9_kP zh(|9qD5B}Xtm52pLI#3}O?o+pP2{J5iizpyCsnVv!yHrv&KY~v4am}+o+rAOI+IFR z==ZhOvBJr<^B9XA{;-PEuJg6dV&d?zmML>!Y$rzBVFN; z^M%JuOVZVYCCSX@7GR2p&|S!~70fO!oe6nc2In&A1$bnR`0x#$hj}-+we&&fgoRyN z#^?l>Dh15{?D;sy#dhZEQ=Vv=B?N!yCG5~i;Z%39X5f}kQ?-nl@qeskpivX_8NqPq z>4v!w3DFFdHLqg3xTpdU3^Eb>I|G#264#^1qvli}J@j-Cizh=3DpM`cm$we4gU_ zTazGhj8>P5ui;_U9~xuVsr!Pdt=+>d-|Y?I$FdyoX?Ot)?777@sS81o6(nCi?)HGm zXKicLv8>jycu|@VY}wC*ouARrXGp`#-5o7lWkX04L_?8By*ZM!u$_1-c__PWXipp~ z`L{Nr6MGgJ-(P1(+rQ0y95!)}aPrF!1}R>q#;c3EVBh>KB2l1zutZNo`d?To89o7d z$iT7p`h(j>26R1&9`X|sD2u8Rf(<#O=`A8-?#5K8fcyLMg}V)7(P7c|4i=94dgMqcj~9neW$@-(Dt>>#Phm1>{LnAqwI**n)k^GJ zXJ=jEXcGzMFHYK)4SYmXD&{k*)(`g;pBr-BQ|C?BBzVmGxYr7nJEl(T z=VdipxEe6|WcOab_agKigv3b733_FP{K8T>?b%r=8@W@?naZq{d%pIFyKSwQxsoz* z-ZPgs;?j28LW`*8%t;ZJTUscp6Td9pp?{k=^~oT0hjgT^3}vcD@=ANS>+@lTJPhix z68-Fv-Io-w6W z$m9<5st*=cG32FQq_*PZCTxH+@5cE4sDR|BD})xt;^S}36L3?2d$?S`(#n`ud93~D z&)M+|gV0tURm|{>Gz085@1AJR!=L7}5|D4hIUuL*LeoAcw);zfUaVZAA?;vKlT z2A`fl*1tvdnvNOHf4L(jrj`RMh< zVbxYz&mwnHBP$(F@6JmsysZ!e;f;gF4FtB!yboCm)BRCw=#srK?{szGV6iyWa6YdO z{hjo3>w^=PI%>E>TY<&=2Sdf=mF7H@uE{6Q=%PjK;x;K84Z>Eg$=&VpA%KFlB{^&9 z>fB)$2mqvm6*}*tsTVKI1D(yT`Tenm;(A(UcC-k+ZwS4Mz4;T`qYc&hz$=ZpD^(W2 z59F8(r;9u)YH#Xf{Pu;$927#kfStS!(aZcBf`6F1GN|=#o0ZI0F17>2c67<>N!T2I zh-Q7iXl9hNaq-9IU|Lmf72Wb#W?Z`9_+nvx4Dh4xsUlzCJ|u|Tb`@iOh4S1h_S_Ra zc3tuy?xiJdO$z*tfWEwR_a%LOQ9v89va>$6lXj7{D7G!F_JFie`+CR6)pUmz>c8+I zFb)H(Kk3^StC`$HM6-^%UX|Wy?aJuoR&LVsQLMHDK>d?^OG#yWEUtW9FYsE5lh3{p zUz7I;^Jrgnb%cfZzbd>ws|b_T@+oCm6zxA=2)y3FJ~Z_QJ|1lVBGm@Fh20#XgH0L2xQy?jG$@CHKij!*pDeM2H{@ev7Pi% zkty*7A7l{KWF~$OnhOA!p&jwQGh+?02I^JOFizT`J1*xCxE(-Yo7=M*WM6ZrI|f(Q zR`Yu<{GPu=DV*-s6nrcyrEyI&#*ag;BQo{yZzo8xd+S>hhd<5sVG39~Vl39P^KF_D zK$%STi&5G+K~#SG`y+ikxj$FL!GtLh-ucdFa-ovgkpy}qms%1fU!ifUs*miQAibid z#JY%Fy|B_kfdU~lYSIz3f#BzL8=Ay<5YJq+vzIww0B!cy>q|~NS7&`0a$7==9@o(= zsoZH@E8q=HVQ`=?PW)qlY4(2Jmygo4Gg?U4@5>_7Z$LO^HOIl+B`LWEwM91@+~hY< zis0K(1n=(u4+iQMZPK5aD!8hvg}Pmyd0i?S3?KG=;vyp4K=lhnkJA?3`sCg%F=X6f z>=E8%dS9q$_(@TV2i*{5f=OrS7D^opVB1%8r7-4hUJyl|*&6~V-oFM+w3WPOSGTrF zDTxdi$s(f{8IKAOp}qs1 zQWk-F>fo#t&jX~Lp^K-ZOZcr+scOixUS|+VFs(z#v~mz!Oe3(}Kah!qtc1*ak^&Tt zu{FseTqS}UJ`+?m($lSPp?yR@4pX8ez`uWeGwsCSSjVr7E(T#x#xo zo;se7gVIR?JT}D!g4qn4u3p}52-PV7bENR)d*qGfY1y#bB(Lb!NiD9&7$Ht6st7+*S6P@FiKdUu|KADb~~-!RF_g1|KUKoaPw;q-Wy-R4gJrC%S=R zHw8&6AK-ZMc~ynqsYsZzWwM{!(+nF_%aq|}=i!VmmHqwwI~*{#EDQBXN{uf;k6eu; zoh9%+43mM2B8Ki$iU|O-_F>?s$egZwJzutzpByGHk^tcC?HxX4%*ta0TSEOVTG4Xi zD+E#>+@8`Bvoi;bTC?|5Mj>p(Z0%oAB%>>IhWh=|t@rmC+HHWA0yE{E28RK$9W8aSV)i39Y%BK(?-Ed?2Qrvkx6QRINv z|_76kXj9U+SzJFWe?dfq{j6%)PwSJv)& zISFU>$=N2H_nky6 z+FV3;JK+;i50;tFE4G0brSmWSnPC|j2P5S^hkT-wQ@wPoEwVWquS)aI?L*z=+^X&A z{YSKMeT0Ctj9xm|!0CCslwVv5QNB-gKdoGkL($f8*A1vN1FQpP=F6Ktyb?R@!Qc*p z9YvF_Xy1mXA-K%bR8fza{m*_d0a^#dyf-sfTdi&EBX#8s`Ao%`vLw0WwKP^jUh|@z z?%Ot?slOGU+m9Cav;j~*>)OC|ZgqcESzQ?13;uNsLJn)=GGGJ;I0}JG$`zCa5?;BP zFOaw*PbwD4j{>4Y?!MlI_M}cyE z+M-%yH$`N5eouWROSeB9h}*5eS1q8`^q#uw;8VWOqmZVKa64>_Z3}l(84}OL$c}JN z>_&`B3-@2_4CS_I!q%j&#iUp7taU#@`!sy-`iI<&vHszxhEpT z8kvucStUSa`=p{J!fecTjW*Qo$MR-pM~cJ~1r%iw#o>FytxzM0l5dqED{f0P$cC{F zYBeU#sBSW8st`{}QjKAy^V_GMGfTs_0eIsDd!BF(lVlqka1ev4>J1-{*N>%sE+o_E zK=e6=#rsTuDp-7gy`j%mJL5MzgiWd?oN?d{6oW*&O?|`om~4Ey+)gR`zT)RIm-oE; z=sY_JdA4-?i=^hrcON>a|GTkXQW52lhvgmJT0p}&n91xZxPkN;2=MFEHAzRYPGY?G z>FW{T(qy);jZ7YaA2RfmD(pPNmr=SjMG^!*1{9FrR*bn}_uKOEi3sKsE-7-o0}Ic} z28nP+*>FOEAyIhS9SWkg@4)8c|Cw&4w5iPA40|g-=#(A3rU3n!m7>9H83vKp%I8o( z=LoFC5QgH9u?R4`f&hTd1NWb~+p(r8D`NdQ@l&{|cN+3Aogv2w!7i_HS>eR8;BMcs zCJ2z>+s8FeytXt`*Rlo&CsI&2a@T9LHy5$y8Sk3=B?y|CAo_>^NWuvTsQ}lckoB?0QF}O Km2#ywAN~(GKCGVr literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_esc_telem_main_out_aio.png b/images/blog/component_editor_esc_telem_main_out_aio.png new file mode 100644 index 0000000000000000000000000000000000000000..17cd57785a2584aea8164d19dbe2ffb958bc0f8d GIT binary patch literal 2466 zcmV;T30?MyP) zy-(v#8pj`>?w_EtRwS{6(xeTNwFMW+(9j%C=YmUE(Ii(Oq-Zxq;3Ai#-2NmN zND0@_fTf57WQnwC(CWqsTDjUk;YwfOm-sbtLa>26p9;qFVw^AY%*6AZp$H*>AW`wB zxNiViEl_Ysq|7Baxynqa%C0mc0RRA0F>fa4MRn08Y()brbQGA@pj@5vR*=`=&vWL_ zRQg?kHh)vad;vOyumGTnq6{nxpo;liJ!PaC=>ofG6ELGfwnj(|0OTsuT6YzEMIvtQ z&7#u1wq%{aXr-`HA`Vey1S&9xA%zC+6Y%6@;-Q(;MgsGSU{b7K?~ijTUQ+RC2WP>f>sdrwBf!aSDCyh8;MnUa(xnm9Yy3SQ|Q64p3^Ikt4zLH z%`;3spSLxJG2D-N1vjkc>>m|dBYjTFIX+dZ&zk{TBZT;<&T7I;4_l!fxtf!>J|TpV z4zygOKA%g&dJE17AsxO~V2M2#DMHsshlfn1Pt1;YsH~q6((NAdy<6jt zk#BqaA%gd-J_G>Z?covF z{Ys`+FI2ZCiLPN{K!wv$eszxfWh!R^4FK#G56b#y8jPP?zf@o$xHj{i zIjczFR<5xu$hS&mzZUo~R|lTw?Z5g@8oqt|=1o+Te+AY`3uhH6cPrOwpSet3f8?$V zT_QM>bPZ-IeL|+Fi4Lf6Hcn-O9PZKrwb^0!E3IQurJ<#8XEeZH{GR0MpeRZzl~NSN zN(r2$+A{?riy|!uH0VRE3`Yad!XeKT7_dGcRTNe4Fkcn($8a`PY6c-IbQSuPf_h8b zXT33P%B2Nr^XC1`!SRnD0Nfc3As^$%rPu4ZI~umbqA zar8Akk;1rn`qVhuwXRO0q7iwmu1GqdHk-`@|A;xhz8n;HIa^9%XD|DtjKSHN=|4*c z)b=?H%1NL2j_7z-6)m=B!vcb%1^<=%rY)df0mE1`#aLj6+by17=^ym?`V#hbY!Q=d z>oM`Bcz)^$d4uxLUH#&~+k0gkZp`Kt;g zQuFl5kdVQ+xw#EDnx<)^Ms}VXOziAs&0f=}>iTk!PKB9mAX9na?netw8tBg)NI!l^ zVAI=BdQbsuPEz_)3VOkj)J~%GHC^5=JHsi!=;uK1mB!bX!86x@3XA94)`(0xNS476 zb8;hV_I}cpU&o2OB8$#aY2h%{>!s|*%S`I|m|++uS5I~H-i)pFE8upS2EZLPvPD-o z1@5Q;!q4E zj`;QcuGdjAbx9)E)~pDcC_T14>50;#R`M@mFKhqVnOAGiNN>-`i!7H#dpKOVk1WIj zt3@mWa~D_cuL8^6JYWIA`G!gVn2Wr3?(grHTVXuw;Vv#NLax_C;w9uNxA4RxxWAqJ z5lg#B?8G3rnLZ2T0~7yh5oD!6!6A`?foLOR>uncx5|`)?B6dB3){HZ$k@6C4sH=)6bX9O%49-Yzo*XZPMmf09qo z@j*6E{bZG{Bhg_Tbej#)_8c2wdN{*#Ke7MKCW3ROvWBaE)OebB7tBQMCS6V%>Y6xl zG;Oq@%(sRdrMYD02&uGi$eTu)H$)SZijpniT{jRm=?pPowy=~t9OUQ}7J|2IZt_$eLl7 z=i1t6?=tD!sA*6>1<~Q!mkyHU^oVq_N9_~1^9yit)ZsRm?m}Lv4kGhO3&1D;6DFHUu{#bbeb32km>1#1)Ux<|^ z8t1(+X_)GrkB`L8Ue-?>UaNhgBsN@rd}MCoao$g$Kc&Fj+C~*yJO2kQ(coZ3=^Ok}3lLWE&%EA2@rWW+ZZ{pA<#u*NZn=DFNUc zEV=fmv6<9!W*ehNbDx~;?Icw$-rz89DZHoC1RowAy4^0mypNJF?^1ic-u?ak#l^+i z+L}u?c$y#*Gq_p=nL+H%dt`<9AkO=RLCb!`d=TgTVk}{Eb0luwli=Rq#2J|voTVa> zf`UUL1qFvh3JMO16#q%X$;nB?_kpYkKJQXfsZ=btf_zl$UoZIdaQN*5@_OJH4v7>L g91 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 O&}&&GnUa@A!T$%hqW6ga literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_frame.png b/images/blog/component_editor_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..6c05a80fe73488b763f3c59efbb1129a0c5ac5f7 GIT binary patch literal 4798 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 a3Oils>HFBpZ{)rdpr>uD^;W~>@xK7zJw^!t literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_gnss.png b/images/blog/component_editor_gnss.png new file mode 100644 index 0000000000000000000000000000000000000000..49240be6211a968ad0b6fedeb1ae9ca5e06cd49f GIT binary patch literal 7473 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 literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_motors.png b/images/blog/component_editor_motors.png new file mode 100644 index 0000000000000000000000000000000000000000..fe63cc34d4cb8387ab1e31d5099091717a89ab92 GIT binary patch literal 4273 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 V7%nBwzm^*~z!il-RyqeJ{RhB1*bM*x literal 0 HcmV?d00001 diff --git a/images/blog/component_editor_propellers.png b/images/blog/component_editor_propellers.png new file mode 100644 index 0000000000000000000000000000000000000000..913da8a62c4b5452d522b30439bd55fee54b9a58 GIT binary patch literal 4964 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 literal 0 HcmV?d00001