robolib.plugins_manager

plugins_manager.py A list of plugins and the orchestration of events between them. Import level: 4

  1"""
  2plugins_manager.py A list of plugins and the orchestration of events between them.
  3Import level: 4
  4"""
  5from __future__ import annotations
  6from microecs import EntityId, World
  7from microspec import Protocol
  8from micronetcode import Message
  9
 10from robolib.utils import logger
 11from robolib.plugin import Plugin, Event, Response, ResponseKind
 12from robolib.traits import Serializable
 13from robolib.netcode import RobosimClient, RobosimClientState
 14
 15class PluginsManager(Serializable):
 16    """
 17    Manager on top of all loaded plugins. It routes TCP (protocol) messages to and from each plugin. Also handles the
 18    ordering in which events are applied (last wins). The order of plugins determine priority in case >=2 target the
 19    same behavior, e.g. manual move plugin vs automatic trajectory plugin.
 20    Note: this class doesn't do any channel (queue) transmission. Both relevant public methods (io_handler and
 21    response_handler) return the replies of the plugins (or errors). The queueing is done in the main loop.
 22    Order of calling (bracketed ones happen outside, inlcuding channel response):
 23    [start tick] -> [tcp recv] -> io_handler (message to events + update ecs data from events) ->
 24                 -> [physics system] -> response_handler -> [tcp send] -> [end tick]
 25    """
 26
 27    def __init__(self, registered_plugins: dict[str, type[Plugin]], plugin_names: list[str]):
 28        Serializable.__init__(self)
 29        self.registered_plugins = registered_plugins
 30        self.plugin_names = plugin_names
 31
 32        # Instantiate plugins here!
 33        self.plugins: list[Plugin] = [registered_plugins[name]() for name in plugin_names]
 34        all_endpoints = [e.name for p in self.plugins for e in p.endpoints]
 35        assert len(set(all_endpoints)) == len(all_endpoints), f"Duplicates: {all_endpoints}"
 36        self.all_endpoints = set(all_endpoints)
 37
 38        self._endpoint_name_to_plugin = {e.name: plugin for plugin in self.plugins for e in plugin.endpoints}
 39        # Protocol validation via microspec
 40        self._protocol = Protocol(endpoints={e.name: e for p in self.plugins for e in p.endpoints})
 41        # Internal buffer between io_handler and response_handler
 42        self._events_this_tick: dict[EntityId, list[Event]] = {}
 43
 44    def on_after_physics(self, world: World):
 45        """Calls all the plugins after the physics loop. All events must be responded to after this phase"""
 46        [plugin.on_after_physics(world) for plugin in self.plugins]
 47
 48    def on_before_physics(self, world: World):
 49        """Calls all the plugins before the physics loop"""
 50        [plugin.on_before_physics(world) for plugin in self.plugins]
 51
 52    def io_handler(self, world: World, messages_for_plugins: list[Message]) -> list[tuple[Message, Response]]:
 53        """
 54        Called at the beginning of each render tick. Sets events_this_tick {eid -> [events]} and modifies
 55        ECS data (TODO-196). Returns the replies of the messages rejected before reaching a plugin (unknown endpoint,
 56        failed validation, client not connected). Accepted messages are answered later, by responses_handler. Must be
 57        called exactly once per tick: it clears events_this_tick, so a second call drops the first's events.
 58        """
 59        # group messages by plugins
 60        messages: dict[Plugin, list[Message]] = {}
 61        early_responses: list[tuple[Message, Response]] = []
 62        for msg in messages_for_plugins:
 63            client: RobosimClient = msg.client
 64            cmd = msg.data["cmd"]
 65            err = None
 66
 67            if (plugin := self._endpoint_name_to_plugin.get(cmd)) is None:
 68                err = f"Unknown message: {msg} ({cmd=}) from channel: {client.channel.idx}"
 69            # Validate the payload using microspec.
 70            elif (valid_err := self._protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None:
 71                err = valid_err.error
 72            # Plugin messages are assumed to only work for CONNECTED clients. (TODO-197)
 73            elif client.state != RobosimClientState.CONNECTED:
 74                err = f"Client from channel: {client.channel.idx} not connected"
 75
 76            if err is not None:
 77                logger.warning(err)
 78                early_responses.append((msg, Response.err(err)))
 79                continue
 80
 81            # the plugin message is validated, so no syntax sanitization is needed inside the plugin
 82            messages.setdefault(plugin, []).append(msg)
 83
 84        # Then, after grouping, we go through all the messages and call on_message_receive which is used for entity evts
 85        self._events_this_tick.clear()
 86        n_events = 0
 87        for plugin in self.plugins:
 88            # first, we go through all the messages of this plugin (if any) and create ECS events group by entityt id
 89            for message in messages.get(plugin, []):
 90                client: RobosimClient = message.client
 91                plg_resp = plugin.on_message_receive(world, entity_id=client.robot_eid, message=message)
 92                # for physics responses, the even is further relayed to the ECS and processed by physics system.
 93                if plg_resp.kind == ResponseKind.Physics:
 94                    event = Event(source=plugin, target=client.robot_eid, message=message, payload=plg_resp.payload)
 95                else:
 96                    event = Event(source=plugin, target=client.robot_eid, message=message, response=plg_resp)
 97                self._events_this_tick.setdefault(client.robot_eid, []).append(event)
 98                n_events += 1
 99
100            # Then, we call on_tick on this plugin after it aggregated all the data
101            for event in plugin.on_tick(world):
102                self._events_this_tick.setdefault(event.target, []).append(event)
103                n_events += 1
104
105        logger.log_every_s(f"Processing {n_events} across {len(self._events_this_tick)} ents this tick", "TRACE", True)
106
107        # Update ECS data from events
108        for entity_id, entity_events in self._events_this_tick.items():
109            for event in reversed(entity_events):
110                if event.payload is not None: # the last event targeting this entity wins this tick
111                    world.get_entity(entity_id).set_data(**event.payload) # the old e.k[:]=v in one step
112                    event.applied = True
113                    break # only the last event targetting the entity wins this tick (ordering matters!)
114
115        return early_responses
116
117    def responses_handler(self, world: World) -> list[tuple[Message, Response]]:
118        """Called at the end of each render tick to ensure all the received messages have their responses"""
119        responses = []
120        for entity_events in self._events_this_tick.values():
121            for event in entity_events:
122                # events w/o a message are generated via on_tick and they are internal (e.g. wind or default moves)
123                if event.message is None:
124                    continue
125                # respond to this unresponded-yet event (e.g. from on_message_receive)
126                if event.response is None:
127                    event.response = event.source.on_message_response(world, event)
128                    assert event.response.kind in (ResponseKind.Ok, ResponseKind.Error), event.response
129                    assert event.response.payload is not None, event.response
130                responses.append((event.message, event.response))
131        return responses
132
133    def to_dict(self) -> dict:
134        return {name: plugin.to_dict() for name, plugin in zip(self.plugin_names, self.plugins)}
135
136    # pylint: disable=arguments-differ
137    @staticmethod
138    def from_dict(registered_plugins: dict[str, type[Plugin]], state: dict) -> PluginsManager:
139        plugin_names = []
140        for plugin_name in state.keys():
141            if plugin_name not in registered_plugins:
142                logger.warning(f"Plugin: '{plugin_name}' not in {list(registered_plugins)}. Skipping.")
143                continue
144            plugin_names.append(plugin_name)
145
146        res = PluginsManager(registered_plugins, plugin_names)
147        for plugin_name, plugin in zip(res.plugin_names, res.plugins):
148            plugin.load_state_dict(state[plugin_name])
149        return res
150
151    def __repr__(self):
152        plugins = "".join(f"\n  - {type(s).__name__}. Endpoints: {s.endpoints}" for s in self.plugins)
153        return f"[PluginsManager]\n- Endpoints: {len(self.all_endpoints)}\n- Plugins ({len(self.plugins)}):{plugins}"
class PluginsManager(robolib.traits.Serializable):
 16class PluginsManager(Serializable):
 17    """
 18    Manager on top of all loaded plugins. It routes TCP (protocol) messages to and from each plugin. Also handles the
 19    ordering in which events are applied (last wins). The order of plugins determine priority in case >=2 target the
 20    same behavior, e.g. manual move plugin vs automatic trajectory plugin.
 21    Note: this class doesn't do any channel (queue) transmission. Both relevant public methods (io_handler and
 22    response_handler) return the replies of the plugins (or errors). The queueing is done in the main loop.
 23    Order of calling (bracketed ones happen outside, inlcuding channel response):
 24    [start tick] -> [tcp recv] -> io_handler (message to events + update ecs data from events) ->
 25                 -> [physics system] -> response_handler -> [tcp send] -> [end tick]
 26    """
 27
 28    def __init__(self, registered_plugins: dict[str, type[Plugin]], plugin_names: list[str]):
 29        Serializable.__init__(self)
 30        self.registered_plugins = registered_plugins
 31        self.plugin_names = plugin_names
 32
 33        # Instantiate plugins here!
 34        self.plugins: list[Plugin] = [registered_plugins[name]() for name in plugin_names]
 35        all_endpoints = [e.name for p in self.plugins for e in p.endpoints]
 36        assert len(set(all_endpoints)) == len(all_endpoints), f"Duplicates: {all_endpoints}"
 37        self.all_endpoints = set(all_endpoints)
 38
 39        self._endpoint_name_to_plugin = {e.name: plugin for plugin in self.plugins for e in plugin.endpoints}
 40        # Protocol validation via microspec
 41        self._protocol = Protocol(endpoints={e.name: e for p in self.plugins for e in p.endpoints})
 42        # Internal buffer between io_handler and response_handler
 43        self._events_this_tick: dict[EntityId, list[Event]] = {}
 44
 45    def on_after_physics(self, world: World):
 46        """Calls all the plugins after the physics loop. All events must be responded to after this phase"""
 47        [plugin.on_after_physics(world) for plugin in self.plugins]
 48
 49    def on_before_physics(self, world: World):
 50        """Calls all the plugins before the physics loop"""
 51        [plugin.on_before_physics(world) for plugin in self.plugins]
 52
 53    def io_handler(self, world: World, messages_for_plugins: list[Message]) -> list[tuple[Message, Response]]:
 54        """
 55        Called at the beginning of each render tick. Sets events_this_tick {eid -> [events]} and modifies
 56        ECS data (TODO-196). Returns the replies of the messages rejected before reaching a plugin (unknown endpoint,
 57        failed validation, client not connected). Accepted messages are answered later, by responses_handler. Must be
 58        called exactly once per tick: it clears events_this_tick, so a second call drops the first's events.
 59        """
 60        # group messages by plugins
 61        messages: dict[Plugin, list[Message]] = {}
 62        early_responses: list[tuple[Message, Response]] = []
 63        for msg in messages_for_plugins:
 64            client: RobosimClient = msg.client
 65            cmd = msg.data["cmd"]
 66            err = None
 67
 68            if (plugin := self._endpoint_name_to_plugin.get(cmd)) is None:
 69                err = f"Unknown message: {msg} ({cmd=}) from channel: {client.channel.idx}"
 70            # Validate the payload using microspec.
 71            elif (valid_err := self._protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None:
 72                err = valid_err.error
 73            # Plugin messages are assumed to only work for CONNECTED clients. (TODO-197)
 74            elif client.state != RobosimClientState.CONNECTED:
 75                err = f"Client from channel: {client.channel.idx} not connected"
 76
 77            if err is not None:
 78                logger.warning(err)
 79                early_responses.append((msg, Response.err(err)))
 80                continue
 81
 82            # the plugin message is validated, so no syntax sanitization is needed inside the plugin
 83            messages.setdefault(plugin, []).append(msg)
 84
 85        # Then, after grouping, we go through all the messages and call on_message_receive which is used for entity evts
 86        self._events_this_tick.clear()
 87        n_events = 0
 88        for plugin in self.plugins:
 89            # first, we go through all the messages of this plugin (if any) and create ECS events group by entityt id
 90            for message in messages.get(plugin, []):
 91                client: RobosimClient = message.client
 92                plg_resp = plugin.on_message_receive(world, entity_id=client.robot_eid, message=message)
 93                # for physics responses, the even is further relayed to the ECS and processed by physics system.
 94                if plg_resp.kind == ResponseKind.Physics:
 95                    event = Event(source=plugin, target=client.robot_eid, message=message, payload=plg_resp.payload)
 96                else:
 97                    event = Event(source=plugin, target=client.robot_eid, message=message, response=plg_resp)
 98                self._events_this_tick.setdefault(client.robot_eid, []).append(event)
 99                n_events += 1
100
101            # Then, we call on_tick on this plugin after it aggregated all the data
102            for event in plugin.on_tick(world):
103                self._events_this_tick.setdefault(event.target, []).append(event)
104                n_events += 1
105
106        logger.log_every_s(f"Processing {n_events} across {len(self._events_this_tick)} ents this tick", "TRACE", True)
107
108        # Update ECS data from events
109        for entity_id, entity_events in self._events_this_tick.items():
110            for event in reversed(entity_events):
111                if event.payload is not None: # the last event targeting this entity wins this tick
112                    world.get_entity(entity_id).set_data(**event.payload) # the old e.k[:]=v in one step
113                    event.applied = True
114                    break # only the last event targetting the entity wins this tick (ordering matters!)
115
116        return early_responses
117
118    def responses_handler(self, world: World) -> list[tuple[Message, Response]]:
119        """Called at the end of each render tick to ensure all the received messages have their responses"""
120        responses = []
121        for entity_events in self._events_this_tick.values():
122            for event in entity_events:
123                # events w/o a message are generated via on_tick and they are internal (e.g. wind or default moves)
124                if event.message is None:
125                    continue
126                # respond to this unresponded-yet event (e.g. from on_message_receive)
127                if event.response is None:
128                    event.response = event.source.on_message_response(world, event)
129                    assert event.response.kind in (ResponseKind.Ok, ResponseKind.Error), event.response
130                    assert event.response.payload is not None, event.response
131                responses.append((event.message, event.response))
132        return responses
133
134    def to_dict(self) -> dict:
135        return {name: plugin.to_dict() for name, plugin in zip(self.plugin_names, self.plugins)}
136
137    # pylint: disable=arguments-differ
138    @staticmethod
139    def from_dict(registered_plugins: dict[str, type[Plugin]], state: dict) -> PluginsManager:
140        plugin_names = []
141        for plugin_name in state.keys():
142            if plugin_name not in registered_plugins:
143                logger.warning(f"Plugin: '{plugin_name}' not in {list(registered_plugins)}. Skipping.")
144                continue
145            plugin_names.append(plugin_name)
146
147        res = PluginsManager(registered_plugins, plugin_names)
148        for plugin_name, plugin in zip(res.plugin_names, res.plugins):
149            plugin.load_state_dict(state[plugin_name])
150        return res
151
152    def __repr__(self):
153        plugins = "".join(f"\n  - {type(s).__name__}. Endpoints: {s.endpoints}" for s in self.plugins)
154        return f"[PluginsManager]\n- Endpoints: {len(self.all_endpoints)}\n- Plugins ({len(self.plugins)}):{plugins}"

Manager on top of all loaded plugins. It routes TCP (protocol) messages to and from each plugin. Also handles the ordering in which events are applied (last wins). The order of plugins determine priority in case >=2 target the same behavior, e.g. manual move plugin vs automatic trajectory plugin. Note: this class doesn't do any channel (queue) transmission. Both relevant public methods (io_handler and response_handler) return the replies of the plugins (or errors). The queueing is done in the main loop. Order of calling (bracketed ones happen outside, inlcuding channel response): [start tick] -> [tcp recv] -> io_handler (message to events + update ecs data from events) -> -> [physics system] -> response_handler -> [tcp send] -> [end tick]

PluginsManager( registered_plugins: dict[str, type[robolib.plugin.Plugin]], plugin_names: list[str])
28    def __init__(self, registered_plugins: dict[str, type[Plugin]], plugin_names: list[str]):
29        Serializable.__init__(self)
30        self.registered_plugins = registered_plugins
31        self.plugin_names = plugin_names
32
33        # Instantiate plugins here!
34        self.plugins: list[Plugin] = [registered_plugins[name]() for name in plugin_names]
35        all_endpoints = [e.name for p in self.plugins for e in p.endpoints]
36        assert len(set(all_endpoints)) == len(all_endpoints), f"Duplicates: {all_endpoints}"
37        self.all_endpoints = set(all_endpoints)
38
39        self._endpoint_name_to_plugin = {e.name: plugin for plugin in self.plugins for e in plugin.endpoints}
40        # Protocol validation via microspec
41        self._protocol = Protocol(endpoints={e.name: e for p in self.plugins for e in p.endpoints})
42        # Internal buffer between io_handler and response_handler
43        self._events_this_tick: dict[EntityId, list[Event]] = {}
registered_plugins
plugin_names
plugins: list[robolib.plugin.Plugin]
all_endpoints
def on_after_physics(self, world: microecs.world.World):
45    def on_after_physics(self, world: World):
46        """Calls all the plugins after the physics loop. All events must be responded to after this phase"""
47        [plugin.on_after_physics(world) for plugin in self.plugins]

Calls all the plugins after the physics loop. All events must be responded to after this phase

def on_before_physics(self, world: microecs.world.World):
49    def on_before_physics(self, world: World):
50        """Calls all the plugins before the physics loop"""
51        [plugin.on_before_physics(world) for plugin in self.plugins]

Calls all the plugins before the physics loop

def io_handler( self, world: microecs.world.World, messages_for_plugins: list[micronetcode.message.Message]) -> list[tuple[micronetcode.message.Message, robolib.plugin.Response]]:
 53    def io_handler(self, world: World, messages_for_plugins: list[Message]) -> list[tuple[Message, Response]]:
 54        """
 55        Called at the beginning of each render tick. Sets events_this_tick {eid -> [events]} and modifies
 56        ECS data (TODO-196). Returns the replies of the messages rejected before reaching a plugin (unknown endpoint,
 57        failed validation, client not connected). Accepted messages are answered later, by responses_handler. Must be
 58        called exactly once per tick: it clears events_this_tick, so a second call drops the first's events.
 59        """
 60        # group messages by plugins
 61        messages: dict[Plugin, list[Message]] = {}
 62        early_responses: list[tuple[Message, Response]] = []
 63        for msg in messages_for_plugins:
 64            client: RobosimClient = msg.client
 65            cmd = msg.data["cmd"]
 66            err = None
 67
 68            if (plugin := self._endpoint_name_to_plugin.get(cmd)) is None:
 69                err = f"Unknown message: {msg} ({cmd=}) from channel: {client.channel.idx}"
 70            # Validate the payload using microspec.
 71            elif (valid_err := self._protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None:
 72                err = valid_err.error
 73            # Plugin messages are assumed to only work for CONNECTED clients. (TODO-197)
 74            elif client.state != RobosimClientState.CONNECTED:
 75                err = f"Client from channel: {client.channel.idx} not connected"
 76
 77            if err is not None:
 78                logger.warning(err)
 79                early_responses.append((msg, Response.err(err)))
 80                continue
 81
 82            # the plugin message is validated, so no syntax sanitization is needed inside the plugin
 83            messages.setdefault(plugin, []).append(msg)
 84
 85        # Then, after grouping, we go through all the messages and call on_message_receive which is used for entity evts
 86        self._events_this_tick.clear()
 87        n_events = 0
 88        for plugin in self.plugins:
 89            # first, we go through all the messages of this plugin (if any) and create ECS events group by entityt id
 90            for message in messages.get(plugin, []):
 91                client: RobosimClient = message.client
 92                plg_resp = plugin.on_message_receive(world, entity_id=client.robot_eid, message=message)
 93                # for physics responses, the even is further relayed to the ECS and processed by physics system.
 94                if plg_resp.kind == ResponseKind.Physics:
 95                    event = Event(source=plugin, target=client.robot_eid, message=message, payload=plg_resp.payload)
 96                else:
 97                    event = Event(source=plugin, target=client.robot_eid, message=message, response=plg_resp)
 98                self._events_this_tick.setdefault(client.robot_eid, []).append(event)
 99                n_events += 1
100
101            # Then, we call on_tick on this plugin after it aggregated all the data
102            for event in plugin.on_tick(world):
103                self._events_this_tick.setdefault(event.target, []).append(event)
104                n_events += 1
105
106        logger.log_every_s(f"Processing {n_events} across {len(self._events_this_tick)} ents this tick", "TRACE", True)
107
108        # Update ECS data from events
109        for entity_id, entity_events in self._events_this_tick.items():
110            for event in reversed(entity_events):
111                if event.payload is not None: # the last event targeting this entity wins this tick
112                    world.get_entity(entity_id).set_data(**event.payload) # the old e.k[:]=v in one step
113                    event.applied = True
114                    break # only the last event targetting the entity wins this tick (ordering matters!)
115
116        return early_responses

Called at the beginning of each render tick. Sets events_this_tick {eid -> [events]} and modifies ECS data (TODO-196). Returns the replies of the messages rejected before reaching a plugin (unknown endpoint, failed validation, client not connected). Accepted messages are answered later, by responses_handler. Must be called exactly once per tick: it clears events_this_tick, so a second call drops the first's events.

def responses_handler( self, world: microecs.world.World) -> list[tuple[micronetcode.message.Message, robolib.plugin.Response]]:
118    def responses_handler(self, world: World) -> list[tuple[Message, Response]]:
119        """Called at the end of each render tick to ensure all the received messages have their responses"""
120        responses = []
121        for entity_events in self._events_this_tick.values():
122            for event in entity_events:
123                # events w/o a message are generated via on_tick and they are internal (e.g. wind or default moves)
124                if event.message is None:
125                    continue
126                # respond to this unresponded-yet event (e.g. from on_message_receive)
127                if event.response is None:
128                    event.response = event.source.on_message_response(world, event)
129                    assert event.response.kind in (ResponseKind.Ok, ResponseKind.Error), event.response
130                    assert event.response.payload is not None, event.response
131                responses.append((event.message, event.response))
132        return responses

Called at the end of each render tick to ensure all the received messages have their responses

def to_dict(self) -> dict:
134    def to_dict(self) -> dict:
135        return {name: plugin.to_dict() for name, plugin in zip(self.plugin_names, self.plugins)}

the dict representation of this object for serialization purposes

@staticmethod
def from_dict( registered_plugins: dict[str, type[robolib.plugin.Plugin]], state: dict) -> PluginsManager:
138    @staticmethod
139    def from_dict(registered_plugins: dict[str, type[Plugin]], state: dict) -> PluginsManager:
140        plugin_names = []
141        for plugin_name in state.keys():
142            if plugin_name not in registered_plugins:
143                logger.warning(f"Plugin: '{plugin_name}' not in {list(registered_plugins)}. Skipping.")
144                continue
145            plugin_names.append(plugin_name)
146
147        res = PluginsManager(registered_plugins, plugin_names)
148        for plugin_name, plugin in zip(res.plugin_names, res.plugins):
149            plugin.load_state_dict(state[plugin_name])
150        return res

loads this object from a serialized dict representation