-
Notifications
You must be signed in to change notification settings - Fork 4
Add runtime dependency inference from controller manager #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6c445e0
98cd48d
27cea7f
1f142be
c09bced
f217d4f
14f6568
27fb66c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| import threading | ||
| from typing import Dict, List | ||
|
|
||
| from controller_manager_msgs.msg import ControllerManagerActivity | ||
| from controller_manager_msgs.srv import ListControllers | ||
| from controller_manager_msgs.srv import ListHardwareComponents | ||
| from lifecycle_msgs.msg import TransitionEvent | ||
| from lifecycle_msgs.srv import GetState | ||
| from rclpy.event_handler import QoSSubscriptionMatchedInfo | ||
|
|
@@ -14,6 +17,8 @@ | |
| from foreman.engine import ForemanEngine | ||
| from foreman.types import Component | ||
| from foreman.types import ComponentType | ||
| from foreman.types import ControllerDependencyRule | ||
| from foreman.types import HardwareRequirement | ||
| from foreman.types import LifecycleState | ||
|
|
||
|
|
||
|
|
@@ -64,6 +69,24 @@ def __init__( | |
| f"{self._logger_prefix} Subscribed to /{controller_manager_name}/activity" | ||
| ) | ||
|
|
||
| # Service clients used to query controller_manager when building dependency rules. | ||
| self._client_list_controllers = self._node.create_client( | ||
| ListControllers, | ||
| f'/{controller_manager_name}/list_controllers', | ||
| callback_group=self._node.callback_group_subscriber | ||
| ) | ||
| self._client_list_hardware_components = self._node.create_client( | ||
| ListHardwareComponents, | ||
| f'/{controller_manager_name}/list_hardware_components', | ||
| callback_group=self._node.callback_group_subscriber | ||
| ) | ||
|
|
||
| # Latest controller_manager observations used to infer dependency rules. | ||
| # Updated asynchronously from controller_manager callbacks. | ||
| self._dependency_lock = threading.Lock() | ||
| self._latest_controllers = [] | ||
| self._latest_hardware_components = [] | ||
|
|
||
| # --- Lifecycle node monitoring --- | ||
| self._lc_node_get_state_clients: Dict[str, object] = {} | ||
|
|
||
|
|
@@ -96,6 +119,88 @@ def __init__( | |
| f"{self._logger_prefix} Monitoring lifecycle nodes: {lifecycle_nodes}" | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def infer_dependency_rules(controller_states, hardware_components): | ||
| """Infer each controller's hardware dependencies from its required interfaces. | ||
|
|
||
| A command interface requires its owning hardware ACTIVE; a state interface | ||
| requires it only INACTIVE. If a controller needs both from one hardware, ACTIVE wins. | ||
| """ | ||
| owner_of_interface = {} | ||
| # Build a mapping of interface names to their owning hardware components | ||
| for hardware in hardware_components: | ||
| for interface in list(hardware.command_interfaces) + list(hardware.state_interfaces): | ||
| owner_of_interface[interface.name] = hardware.name | ||
|
|
||
| dependency_rules = [] | ||
| for controller in controller_states: | ||
| # Map required interfaces to hardware components and their required lifecycle states | ||
| required_hardware_state = {} | ||
| for interface in controller.required_command_interfaces: | ||
| hardware_name = owner_of_interface.get(interface) | ||
| if hardware_name: | ||
| required_hardware_state[hardware_name] = LifecycleState.ACTIVE | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You cannot assume this, you need to query the hardware lifecycle state either from activity topic or simply query it with the service.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I understand now.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes exactly
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have a doubt about how to represent the hardware state in the dependency rule. Suppose we have a controller X depends on the hardware Y in its lifecycle state Z. If Z is just the hardware’s current observed state, then the rule becomes circular. I think you mean that Z should be the lifecycle state at which the controller’s required interfaces actually become available, based on the data reported by the controller_manager, rather than a state inferred from the interface type or the hardware’s current state. Could you confirm what Z should represent?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it really defines a valid state or not, should be handled separately in a different state. At the staruo, when you load the profiles then you validate the profiles, so that way you can be sure about the possibility. But IMO here it should be different |
||
|
|
||
| for interface in controller.required_state_interfaces: | ||
| hardware_name = owner_of_interface.get(interface) | ||
| if hardware_name and hardware_name not in required_hardware_state: | ||
| required_hardware_state[hardware_name] = LifecycleState.INACTIVE | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here |
||
|
|
||
| dependency_rules.append(ControllerDependencyRule( | ||
| controller_name=controller.name, | ||
| required_hardware=[ | ||
| HardwareRequirement(name=name, state=state) | ||
| for name, state in required_hardware_state.items() | ||
| ], | ||
| )) | ||
| return dependency_rules | ||
|
|
||
| def get_dependency_rules(self): | ||
| """ | ||
| Infer dependency rules from the latest controller_manager observations. | ||
|
|
||
| The observations are refreshed asynchronously, so this method never blocks. | ||
| """ | ||
| with self._dependency_lock: | ||
| return self.infer_dependency_rules( | ||
| self._latest_controllers, self._latest_hardware_components) | ||
|
|
||
| def _refresh_dependency_rules(self): | ||
| """Refresh dependency observations asynchronously. | ||
|
|
||
| Query hardware first, then controllers, before rebuilding dependencies. | ||
| """ | ||
| if not (self._client_list_hardware_components.service_is_ready() | ||
| and self._client_list_controllers.service_is_ready()): | ||
| return | ||
| future = self._client_list_hardware_components.call_async( | ||
| ListHardwareComponents.Request()) | ||
| future.add_done_callback(self._on_hardware_components_response) | ||
|
|
||
| def _on_hardware_components_response(self, future): | ||
| """Store the latest hardware observations and request controllers.""" | ||
| try: | ||
| hardware = future.result().component | ||
| except Exception as e: | ||
| self._node.get_logger().warning( | ||
| f"{self._logger_prefix} Failed to list hardware components: {e}") | ||
| return | ||
| with self._dependency_lock: | ||
| self._latest_hardware_components = hardware | ||
| ctrl_future = self._client_list_controllers.call_async(ListControllers.Request()) | ||
| ctrl_future.add_done_callback(self._on_controllers_response) | ||
|
|
||
| def _on_controllers_response(self, future): | ||
| """Store the latest controller observations.""" | ||
| try: | ||
| controllers = future.result().controller | ||
| except Exception as e: | ||
| self._node.get_logger().warning( | ||
| f"{self._logger_prefix} Failed to list controllers: {e}") | ||
| return | ||
| with self._dependency_lock: | ||
| self._latest_controllers = controllers | ||
|
|
||
| # TODO: use matched event for this topic as well? That way we know if controller manager dies. | ||
| # Minor. Currently we catch unexpected transitions in the engine (all components go to finalized) | ||
| def _activity_callback(self, msg: ControllerManagerActivity): | ||
|
|
@@ -124,6 +229,8 @@ def _activity_callback(self, msg: ControllerManagerActivity): | |
|
|
||
| self._cm_components = components | ||
| self._push_merged_state() | ||
| # Refresh dependency observations after receiving a new activity update. | ||
| self._refresh_dependency_rules() | ||
|
|
||
| def _on_lifecycle_publisher_matched(self, name: str, info: QoSSubscriptionMatchedInfo): | ||
| """DDS matched event: lifecycle node's transition_event publisher appeared or disappeared.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,8 @@ | |
| from foreman.planner import Planner | ||
| from foreman.types import Component | ||
| from foreman.types import ComponentType | ||
| from foreman.types import ControllerDependencyRule | ||
| from foreman.types import DependencyProvider | ||
| from foreman.types import ErrorSnapshot | ||
| from foreman.types import ForemanError | ||
| from foreman.types import ForemanErrorCategory | ||
|
|
@@ -91,6 +93,10 @@ def abort_goal(self, error: ForemanError): | |
| self._last_issued_command = None | ||
| self._locked_abort_transition() | ||
|
|
||
| def set_dependency_provider(self, dependency_provider: DependencyProvider): | ||
| """Set the source of dependency rules for the planner.""" | ||
| self._planner.set_dependency_provider(dependency_provider) | ||
|
|
||
| def get_next_transition(self) -> Optional[SystemTransitionCommand]: | ||
| """Calculate the next step toward the goal.""" | ||
| if not self._current_goal: | ||
|
|
@@ -251,8 +257,9 @@ def _locked_check_unsatisfiable_dependencies(self, goal: SystemGoal) -> List[str | |
| goal_infrastructure_states[comp.name] = comp.lifecycle_state | ||
|
|
||
| errors = [] | ||
| current_dependency_rules = self._planner.get_current_rules() | ||
| for ctrl_goal in goal.controller_goals: | ||
| rule = self._planner.rules.get(ctrl_goal.name) | ||
| rule = current_dependency_rules.get(ctrl_goal.name) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would happen if the key element doesn't exist here?. Add a test for that case and fix it
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi,the engine already handles a missing rule with |
||
| if not rule: | ||
| continue | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,8 @@ def __init__(self): | |
| controller_manager_name=controller_manager_name, | ||
| lifecycle_nodes=self.foreman_config.lifecycle_nodes | ||
| ) | ||
| # Planner queries dependency rules from the monitor when planning. | ||
| self.foreman_engine.set_dependency_provider(self.component_state_monitor) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, this is kinda the approach I'm talking about 👌🏾 |
||
| self.controller_manager_service_caller = adapters.ControllerManagerServiceCaller( | ||
| node=self, | ||
| controller_manager_name=controller_manager_name | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.