openrois-components-core 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openrois/components/core/__init__.py +41 -0
- openrois/components/core/_spec.py +180 -0
- openrois/components/core/component.py +412 -0
- openrois/components/core/decorators.py +91 -0
- openrois/components/core/py.typed +0 -0
- openrois/components/core/resource.py +122 -0
- openrois/components/core/ros2.py +118 -0
- openrois_components_core-0.1.0a1.dist-info/METADATA +221 -0
- openrois_components_core-0.1.0a1.dist-info/RECORD +12 -0
- openrois_components_core-0.1.0a1.dist-info/WHEEL +4 -0
- openrois_components_core-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- openrois_components_core-0.1.0a1.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""OpenRoIS component SDK: write a RoIS HRI Component as a Python class.
|
|
2
|
+
|
|
3
|
+
A component subclasses Component, declares its type with ``@component(profile)``, and
|
|
4
|
+
marks the messages it implements::
|
|
5
|
+
|
|
6
|
+
from openrois.components.core import Component, component, invoke, subscribe
|
|
7
|
+
from openrois.interfaces.components import NAVIGATION_PROFILE
|
|
8
|
+
|
|
9
|
+
@component(NAVIGATION_PROFILE)
|
|
10
|
+
class Navigation(Component):
|
|
11
|
+
@invoke("start")
|
|
12
|
+
async def start(self) -> None:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
The engine that hosts the component stores its parameters, answers component_status,
|
|
16
|
+
tracks its commands and serves the part of the profile it implements. A connection that
|
|
17
|
+
several components share is a Resource, which the engine opens and closes for them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from openrois.components.core.component import CommandFailed, Component, EngineBinding
|
|
21
|
+
from openrois.components.core.decorators import (
|
|
22
|
+
component,
|
|
23
|
+
invoke,
|
|
24
|
+
on_set_parameter,
|
|
25
|
+
query,
|
|
26
|
+
subscribe,
|
|
27
|
+
)
|
|
28
|
+
from openrois.components.core.resource import Resource, ResourceBinding
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CommandFailed",
|
|
32
|
+
"Component",
|
|
33
|
+
"EngineBinding",
|
|
34
|
+
"Resource",
|
|
35
|
+
"ResourceBinding",
|
|
36
|
+
"component",
|
|
37
|
+
"invoke",
|
|
38
|
+
"on_set_parameter",
|
|
39
|
+
"query",
|
|
40
|
+
"subscribe",
|
|
41
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""What a decorated component class declares, checked against its profile.
|
|
2
|
+
|
|
3
|
+
``@component`` builds a ComponentSpec when a class is defined: which method handles
|
|
4
|
+
each command, query and event, the hook for new parameter values, and the profile the
|
|
5
|
+
engine serves for the class. A handler that does not match the profile fails here, at
|
|
6
|
+
class definition, so a typo cannot advertise a message the profile does not define.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from openrois.interfaces.components import ROIS_COMMON_PROFILE, ROIS_COMMON_URN
|
|
18
|
+
from openrois.interfaces.profiles import (
|
|
19
|
+
CommandMessageProfile,
|
|
20
|
+
EventMessageProfile,
|
|
21
|
+
HRIComponentProfile,
|
|
22
|
+
QueryMessageProfile,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type HandlerKind = Literal["command", "query", "event", "set_parameter"]
|
|
26
|
+
|
|
27
|
+
# The messages whose handlers take arguments by name.
|
|
28
|
+
type MessageWithArguments = CommandMessageProfile | QueryMessageProfile | EventMessageProfile
|
|
29
|
+
|
|
30
|
+
# The attribute the method decorators set on the functions they mark.
|
|
31
|
+
HANDLER_ATTRIBUTE = "__rois_handler__"
|
|
32
|
+
|
|
33
|
+
# The engine answers these itself, so a component never handles them.
|
|
34
|
+
ENGINE_COMMANDS = frozenset({"set_parameter"})
|
|
35
|
+
ENGINE_QUERIES = frozenset({"component_status"})
|
|
36
|
+
|
|
37
|
+
# A component whose profile includes RoISCommon serves these, handler or not.
|
|
38
|
+
ROIS_COMMON_COMMANDS = frozenset(m.name for m in ROIS_COMMON_PROFILE.command_profiles)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Handler:
|
|
43
|
+
"""The message a method handles, set on the method by a decorator."""
|
|
44
|
+
|
|
45
|
+
kind: HandlerKind
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class ComponentSpec:
|
|
51
|
+
"""The messages a component class handles and the profile it serves.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
declared: The profile the class declares, usually a profile constant.
|
|
55
|
+
served: The declared profile with only the messages the class answers: the
|
|
56
|
+
commands, queries and events it has handlers for, the four RoIS_Common
|
|
57
|
+
commands when the profile includes RoISCommon (the engine answers them by
|
|
58
|
+
default), otherwise ``stop`` when it handles ``start``, and
|
|
59
|
+
``component_status``, which the engine answers. Every parameter of the
|
|
60
|
+
declared profile stays.
|
|
61
|
+
commands: The method that handles each command, by command name.
|
|
62
|
+
queries: The method that handles each query, by query name.
|
|
63
|
+
events: The method that runs on a new subscription, by event name.
|
|
64
|
+
set_parameter_hook: The method that applies new parameter values, if any.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
declared: HRIComponentProfile
|
|
68
|
+
served: HRIComponentProfile
|
|
69
|
+
commands: Mapping[str, str]
|
|
70
|
+
queries: Mapping[str, str]
|
|
71
|
+
events: Mapping[str, str]
|
|
72
|
+
set_parameter_hook: str | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_spec(cls: type, profile: HRIComponentProfile) -> ComponentSpec:
|
|
76
|
+
"""Collect the handlers of a class and check them against its profile.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TypeError: A handler is not a coroutine function or has parameters its message
|
|
80
|
+
does not define.
|
|
81
|
+
ValueError: A handler names a message the profile does not define, a message the
|
|
82
|
+
engine answers itself, or a message another handler already handles.
|
|
83
|
+
"""
|
|
84
|
+
owner = f"{cls.__qualname__}"
|
|
85
|
+
kind_of_type = f"{profile.identifier.authority} {profile.identifier.code}".strip()
|
|
86
|
+
defined: dict[HandlerKind, set[str]] = {
|
|
87
|
+
"command": {m.name for m in profile.command_profiles},
|
|
88
|
+
"query": {m.name for m in profile.query_profiles},
|
|
89
|
+
"event": {m.name for m in profile.event_profiles},
|
|
90
|
+
}
|
|
91
|
+
tables: dict[HandlerKind, dict[str, str]] = {"command": {}, "query": {}, "event": {}}
|
|
92
|
+
hook: str | None = None
|
|
93
|
+
|
|
94
|
+
for attribute in dir(cls):
|
|
95
|
+
method = getattr(cls, attribute, None)
|
|
96
|
+
handler = getattr(method, HANDLER_ATTRIBUTE, None)
|
|
97
|
+
if not isinstance(handler, Handler):
|
|
98
|
+
continue
|
|
99
|
+
where = f"{owner}.{attribute}"
|
|
100
|
+
if not inspect.iscoroutinefunction(method):
|
|
101
|
+
raise TypeError(f"{where} must be an async def")
|
|
102
|
+
parameters = list(inspect.signature(method).parameters.values())[1:]
|
|
103
|
+
|
|
104
|
+
if handler.kind == "set_parameter":
|
|
105
|
+
if not profile.parameter_profiles:
|
|
106
|
+
raise ValueError(f"{where} is an @on_set_parameter hook, but the profile "
|
|
107
|
+
f"{kind_of_type} declares no parameters")
|
|
108
|
+
if hook is not None:
|
|
109
|
+
raise ValueError(f"{owner} has two @on_set_parameter hooks: {hook} and "
|
|
110
|
+
f"{attribute}")
|
|
111
|
+
if len(parameters) != 1:
|
|
112
|
+
raise TypeError(f"{where} must take one argument, the new values")
|
|
113
|
+
hook = attribute
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if handler.kind == "command" and handler.name in ENGINE_COMMANDS:
|
|
117
|
+
raise ValueError(f"{where} handles {handler.name}, which the engine runs itself. "
|
|
118
|
+
"Apply new parameter values in an @on_set_parameter hook")
|
|
119
|
+
if handler.kind == "query" and handler.name in ENGINE_QUERIES:
|
|
120
|
+
raise ValueError(f"{where} answers {handler.name}, which the engine answers "
|
|
121
|
+
"itself")
|
|
122
|
+
if handler.name not in defined[handler.kind]:
|
|
123
|
+
raise ValueError(f"{where} handles the {handler.kind} {handler.name!r}, which the "
|
|
124
|
+
f"profile {kind_of_type} does not define")
|
|
125
|
+
table = tables[handler.kind]
|
|
126
|
+
if handler.name in table:
|
|
127
|
+
raise ValueError(f"{owner}.{table[handler.name]} and {where} both handle the "
|
|
128
|
+
f"{handler.kind} {handler.name!r}")
|
|
129
|
+
message: MessageWithArguments
|
|
130
|
+
if handler.kind == "command":
|
|
131
|
+
message = next(m for m in profile.command_profiles if m.name == handler.name)
|
|
132
|
+
elif handler.kind == "query":
|
|
133
|
+
message = next(m for m in profile.query_profiles if m.name == handler.name)
|
|
134
|
+
else:
|
|
135
|
+
message = next(m for m in profile.event_profiles if m.name == handler.name)
|
|
136
|
+
_check_parameters(where, message, parameters)
|
|
137
|
+
table[handler.name] = attribute
|
|
138
|
+
|
|
139
|
+
commands, queries, events = tables["command"], tables["query"], tables["event"]
|
|
140
|
+
rois_common = ROIS_COMMON_URN in profile.sub_component_profiles
|
|
141
|
+
served = profile.model_copy(
|
|
142
|
+
update={
|
|
143
|
+
"command_profiles": [
|
|
144
|
+
m
|
|
145
|
+
for m in profile.command_profiles
|
|
146
|
+
if m.name in commands
|
|
147
|
+
or (rois_common and m.name in ROIS_COMMON_COMMANDS)
|
|
148
|
+
or (m.name == "stop" and "start" in commands)
|
|
149
|
+
],
|
|
150
|
+
"query_profiles": [
|
|
151
|
+
m for m in profile.query_profiles if m.name in queries or m.name in ENGINE_QUERIES
|
|
152
|
+
],
|
|
153
|
+
"event_profiles": [m for m in profile.event_profiles if m.name in events],
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
return ComponentSpec(
|
|
157
|
+
declared=profile,
|
|
158
|
+
served=served,
|
|
159
|
+
commands=MappingProxyType(commands),
|
|
160
|
+
queries=MappingProxyType(queries),
|
|
161
|
+
events=MappingProxyType(events),
|
|
162
|
+
set_parameter_hook=hook,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_parameters(
|
|
167
|
+
where: str,
|
|
168
|
+
message: MessageWithArguments,
|
|
169
|
+
parameters: list[inspect.Parameter],
|
|
170
|
+
) -> None:
|
|
171
|
+
"""A handler takes the arguments of its message by name, and nothing else."""
|
|
172
|
+
arguments = {a.name for a in message.arguments}
|
|
173
|
+
for parameter in parameters:
|
|
174
|
+
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
175
|
+
continue
|
|
176
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
177
|
+
raise TypeError(f"{where} cannot take *{parameter.name}: arguments arrive by name")
|
|
178
|
+
if parameter.name not in arguments:
|
|
179
|
+
raise TypeError(f"{where} takes {parameter.name!r}, which the message "
|
|
180
|
+
f"{message.name!r} does not define as an argument")
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""The Component base class and what it needs from the engine that hosts it.
|
|
2
|
+
|
|
3
|
+
A component is a subclass of Component declared with ``@component(profile)``. Its author
|
|
4
|
+
writes handlers for the messages it supports and uses what the engine binds at
|
|
5
|
+
registration: ``self.parameters``, the current parameter values, ``self.emit``, which
|
|
6
|
+
sends an event to the clients that subscribed, and ``self.raise_status`` and
|
|
7
|
+
``self.clear_status``, which report WARNING or ERROR.
|
|
8
|
+
|
|
9
|
+
The methods whose names start with ``rois_`` are the side the engine calls. An engine
|
|
10
|
+
that hosts components written with this SDK calls them to read the served profile, bind
|
|
11
|
+
itself, run commands and queries, start subscriptions and apply new parameter values.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any, ClassVar, Protocol
|
|
19
|
+
|
|
20
|
+
from openrois.interfaces.common import ComponentStatus
|
|
21
|
+
from openrois.interfaces.hri import Argument, Parameter, Result
|
|
22
|
+
from openrois.interfaces.profiles import (
|
|
23
|
+
CommandMessageProfile,
|
|
24
|
+
EventMessageProfile,
|
|
25
|
+
HRIComponentProfile,
|
|
26
|
+
MessageProfile,
|
|
27
|
+
QueryMessageProfile,
|
|
28
|
+
)
|
|
29
|
+
from openrois.interfaces.service import CompletedStatus
|
|
30
|
+
from openrois.interfaces.values import decode_value, encode_value
|
|
31
|
+
|
|
32
|
+
from openrois.components.core._spec import ComponentSpec
|
|
33
|
+
from openrois.components.core.resource import Resource, check_raisable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CommandFailed(Exception): # noqa: N818, the name says what happened to the command.
|
|
37
|
+
"""Raised by a command handler to end its command with a status other than OK.
|
|
38
|
+
|
|
39
|
+
Any other exception ends the command with ERROR.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
status: How the command ended: ERROR, ABORT, OUT_OF_RESOURCES or TIMEOUT.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status: CompletedStatus = CompletedStatus.ERROR, message: str = "") -> None:
|
|
46
|
+
if status is CompletedStatus.OK:
|
|
47
|
+
raise ValueError("A failed command cannot end with OK")
|
|
48
|
+
super().__init__(message or status.value)
|
|
49
|
+
self.status = status
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EngineBinding(Protocol):
|
|
53
|
+
"""What a component needs from the engine that hosts it.
|
|
54
|
+
|
|
55
|
+
The engine passes an object of this shape to ``Component.rois_bind`` when it
|
|
56
|
+
registers the component.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def ref(self) -> str:
|
|
61
|
+
"""The fully qualified ref of the component, ``engine_id/name``."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def parameters(self) -> Sequence[Parameter]:
|
|
65
|
+
"""The current value of every parameter of the component."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def emit(
|
|
69
|
+
self,
|
|
70
|
+
event_type: str,
|
|
71
|
+
results: Sequence[Result],
|
|
72
|
+
concerning: Mapping[str, str] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Send an event to its subscriptions. Safe to call from any thread.
|
|
75
|
+
|
|
76
|
+
``concerning`` names the argument values the event is about, in their string
|
|
77
|
+
form. The event goes to the subscriptions whose arguments equal them, and to
|
|
78
|
+
every subscription of the event when it is empty.
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def raise_status(self, status: ComponentStatus, message: str) -> None:
|
|
83
|
+
"""Report WARNING or ERROR for the component. Safe to call from any thread."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def clear_status(self) -> None:
|
|
87
|
+
"""Withdraw the status the component raised. Safe to call from any thread."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def engine_status(self) -> ComponentStatus:
|
|
91
|
+
"""The status of the engine: ERROR or WARNING as raised on it, or READY."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Component:
|
|
96
|
+
"""Base class of a RoIS HRI Component.
|
|
97
|
+
|
|
98
|
+
Subclass it, declare the profile with ``@component``, and write handlers::
|
|
99
|
+
|
|
100
|
+
@component(NAVIGATION_PROFILE)
|
|
101
|
+
class Navigation(Component):
|
|
102
|
+
@invoke("start")
|
|
103
|
+
async def start(self) -> dict[str, object]:
|
|
104
|
+
target = self.parameters["target_positions"][0]
|
|
105
|
+
await self._robot.go_to(target)
|
|
106
|
+
self.emit("reached_target", target=target, is_final_target=True)
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
@subscribe("reached_target")
|
|
110
|
+
async def reached_target(self) -> None:
|
|
111
|
+
pass # start emits the event when the robot arrives.
|
|
112
|
+
|
|
113
|
+
A command handler runs for as long as the command lasts. It takes the arguments of
|
|
114
|
+
its command by name, converted from their profile types, and returns the values of
|
|
115
|
+
the command's results by name, or None. Raising CommandFailed ends the command with
|
|
116
|
+
the status it names, and any other exception ends it with ERROR. A query handler
|
|
117
|
+
returns the values of the query's results the same way.
|
|
118
|
+
|
|
119
|
+
The engine calls ``connect()`` before the component takes requests and
|
|
120
|
+
``disconnect()`` when it stops. Override them to open and close the connection to
|
|
121
|
+
the backend. A connection that several components share is a Resource: the
|
|
122
|
+
component records it with ``self.uses(resource)``, and the engine opens it before
|
|
123
|
+
the first of them connects.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
# Set by @component on the class it declares.
|
|
127
|
+
__rois_spec__: ClassVar[ComponentSpec | None] = None
|
|
128
|
+
|
|
129
|
+
# Set by rois_bind when the engine registers the component.
|
|
130
|
+
_rois_binding: EngineBinding | None = None
|
|
131
|
+
|
|
132
|
+
# Set by uses(), in the order the component recorded them.
|
|
133
|
+
_rois_resources: list[Resource] | None = None
|
|
134
|
+
|
|
135
|
+
# -- For component authors ---------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def ref(self) -> str:
|
|
139
|
+
"""The fully qualified ref the engine registered the component under."""
|
|
140
|
+
return self._binding().ref
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def parameters(self) -> Mapping[str, Any]:
|
|
144
|
+
"""The current parameter values, converted from their profile types.
|
|
145
|
+
|
|
146
|
+
A ``string[]`` parameter reads as a list of str and an ``int`` as an int. The
|
|
147
|
+
engine stores the values and changes them on ``set_parameter``. A parameter with
|
|
148
|
+
no value yet, because its profile gives no default and no client has set it, is
|
|
149
|
+
missing from the mapping.
|
|
150
|
+
"""
|
|
151
|
+
types = {p.name: p.data_type_ref.code for p in self._spec().declared.parameter_profiles}
|
|
152
|
+
return MappingProxyType(
|
|
153
|
+
{
|
|
154
|
+
p.name: decode_value(types.get(p.name, p.data_type_ref), p.value)
|
|
155
|
+
for p in self._binding().parameters()
|
|
156
|
+
if p.value != ""
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def emit(
|
|
161
|
+
self,
|
|
162
|
+
event_type: str,
|
|
163
|
+
*,
|
|
164
|
+
concerning: Mapping[str, object] | None = None,
|
|
165
|
+
**values: object,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Send an event to the clients that subscribed to it.
|
|
168
|
+
|
|
169
|
+
The values are the event's results by name, converted to their profile types. A
|
|
170
|
+
result left out is not sent. Safe to call from any thread, for example a ROS 2
|
|
171
|
+
callback.
|
|
172
|
+
|
|
173
|
+
An event whose profile has arguments, such as the ``stream_id`` of
|
|
174
|
+
``notify_stream_status``, names in ``concerning`` the argument values it is
|
|
175
|
+
about. It then reaches the subscriptions that gave those values, or none, and an
|
|
176
|
+
event that names no values reaches every subscription.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: The component does not serve the event, a value has no result
|
|
180
|
+
of that name or does not fit its type, or ``concerning`` names an
|
|
181
|
+
argument the event does not have.
|
|
182
|
+
RuntimeError: No engine hosts the component yet.
|
|
183
|
+
"""
|
|
184
|
+
spec = self._spec()
|
|
185
|
+
if event_type not in spec.events:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{type(self).__qualname__} does not serve the event {event_type!r}. "
|
|
188
|
+
"Mark it with @subscribe"
|
|
189
|
+
)
|
|
190
|
+
message = _message(spec.served.event_profiles, event_type)
|
|
191
|
+
concerns = _concerns(message, concerning or {})
|
|
192
|
+
self._binding().emit(event_type, _results(message, values), concerns)
|
|
193
|
+
|
|
194
|
+
def uses[R: Resource](self, resource: R) -> R:
|
|
195
|
+
"""Record a resource the component shares with others, and return it.
|
|
196
|
+
|
|
197
|
+
Call it in ``__init__``. The engine opens every resource a component uses before
|
|
198
|
+
the component connects, and a status the resource raises becomes the
|
|
199
|
+
component's.
|
|
200
|
+
"""
|
|
201
|
+
if self._rois_resources is None:
|
|
202
|
+
self._rois_resources = []
|
|
203
|
+
if not any(r is resource for r in self._rois_resources):
|
|
204
|
+
self._rois_resources.append(resource)
|
|
205
|
+
return resource
|
|
206
|
+
|
|
207
|
+
def raise_status(self, status: ComponentStatus, message: str = "") -> None:
|
|
208
|
+
"""Report WARNING or ERROR, with a message, until ``clear_status``.
|
|
209
|
+
|
|
210
|
+
component_status reports the raised status, and the engine sends
|
|
211
|
+
``rois.system.notify_error`` to the sessions that bound the component or
|
|
212
|
+
subscribed to its events. While ERROR is raised, the engine refuses the
|
|
213
|
+
component's commands and queries and holds back its events. Safe to call from
|
|
214
|
+
any thread.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValueError: The status is neither WARNING nor ERROR.
|
|
218
|
+
RuntimeError: No engine hosts the component yet.
|
|
219
|
+
"""
|
|
220
|
+
check_raisable(status)
|
|
221
|
+
self._binding().raise_status(status, message)
|
|
222
|
+
|
|
223
|
+
def clear_status(self) -> None:
|
|
224
|
+
"""Withdraw the status the component raised. Safe to call from any thread.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
RuntimeError: No engine hosts the component yet.
|
|
228
|
+
"""
|
|
229
|
+
self._binding().clear_status()
|
|
230
|
+
|
|
231
|
+
def engine_state(self) -> ComponentStatus:
|
|
232
|
+
"""The status of the engine that hosts the component.
|
|
233
|
+
|
|
234
|
+
ERROR or WARNING while one is raised on the engine, READY otherwise. A
|
|
235
|
+
SystemInformation component answers ``engine_status`` with it.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
RuntimeError: No engine hosts the component yet.
|
|
239
|
+
"""
|
|
240
|
+
return self._binding().engine_status()
|
|
241
|
+
|
|
242
|
+
async def connect(self) -> None:
|
|
243
|
+
"""Open the connection to the backend. The default does nothing."""
|
|
244
|
+
|
|
245
|
+
async def disconnect(self) -> None:
|
|
246
|
+
"""Close the connection to the backend. The default does nothing."""
|
|
247
|
+
|
|
248
|
+
# -- Called by the engine ----------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def rois_profile(cls) -> HRIComponentProfile:
|
|
252
|
+
"""The profile the engine serves for this class: what it implements."""
|
|
253
|
+
return cls._spec().served
|
|
254
|
+
|
|
255
|
+
def rois_bind(self, binding: EngineBinding) -> None:
|
|
256
|
+
"""Attach the engine that hosts this component."""
|
|
257
|
+
self._rois_binding = binding
|
|
258
|
+
|
|
259
|
+
def rois_resources(self) -> Sequence[Resource]:
|
|
260
|
+
"""The resources the component uses, in the order it recorded them."""
|
|
261
|
+
return list(self._rois_resources or ())
|
|
262
|
+
|
|
263
|
+
async def rois_command(
|
|
264
|
+
self,
|
|
265
|
+
command_type: str,
|
|
266
|
+
arguments: Sequence[Argument] = (),
|
|
267
|
+
) -> list[Result]:
|
|
268
|
+
"""Run one command until it completes, and return its results.
|
|
269
|
+
|
|
270
|
+
A served command without a handler returns at once. That is ``stop`` alongside
|
|
271
|
+
``start``, and the four RoIS_Common commands of a profile that includes
|
|
272
|
+
RoISCommon: the engine holds, releases and aborts commands itself, and a start
|
|
273
|
+
without a handler is done once it begins.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
CommandFailed: The handler ended the command with another status.
|
|
277
|
+
ValueError: The component does not serve the command, or an argument does
|
|
278
|
+
not fit its profile.
|
|
279
|
+
"""
|
|
280
|
+
spec = self._spec()
|
|
281
|
+
message = _message(spec.served.command_profiles, command_type)
|
|
282
|
+
method = spec.commands.get(command_type)
|
|
283
|
+
if method is None:
|
|
284
|
+
return []
|
|
285
|
+
values = await getattr(self, method)(**_arguments(message, arguments))
|
|
286
|
+
return _results(message, values)
|
|
287
|
+
|
|
288
|
+
async def rois_query(
|
|
289
|
+
self,
|
|
290
|
+
query_type: str,
|
|
291
|
+
arguments: Sequence[Argument] = (),
|
|
292
|
+
) -> list[Result]:
|
|
293
|
+
"""Answer one query with its results.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: The component has no handler for the query, or an argument does
|
|
297
|
+
not fit its profile.
|
|
298
|
+
"""
|
|
299
|
+
spec = self._spec()
|
|
300
|
+
method = spec.queries.get(query_type)
|
|
301
|
+
if method is None:
|
|
302
|
+
raise ValueError(f"{type(self).__qualname__} does not answer {query_type!r}")
|
|
303
|
+
message = _message(spec.served.query_profiles, query_type)
|
|
304
|
+
values = await getattr(self, method)(**_arguments(message, arguments))
|
|
305
|
+
return _results(message, values)
|
|
306
|
+
|
|
307
|
+
async def rois_subscribed(self, event_type: str, arguments: Sequence[Argument] = ()) -> None:
|
|
308
|
+
"""Run the @subscribe method of an event for a new subscription.
|
|
309
|
+
|
|
310
|
+
The method receives the arguments the subscription gave, by name.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
ValueError: The component does not serve the event, or an argument does not
|
|
314
|
+
fit its profile.
|
|
315
|
+
"""
|
|
316
|
+
spec = self._spec()
|
|
317
|
+
method = spec.events.get(event_type)
|
|
318
|
+
if method is None:
|
|
319
|
+
raise ValueError(f"{type(self).__qualname__} does not serve {event_type!r}")
|
|
320
|
+
message = _message(spec.served.event_profiles, event_type)
|
|
321
|
+
await getattr(self, method)(**_arguments(message, arguments))
|
|
322
|
+
|
|
323
|
+
async def rois_set_parameters(self, parameters: Sequence[Parameter]) -> None:
|
|
324
|
+
"""Run the @on_set_parameter hook with new values, before the engine stores them.
|
|
325
|
+
|
|
326
|
+
Without a hook this returns at once. An exception from the hook refuses the
|
|
327
|
+
values, and the engine keeps the old ones.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: A parameter is not in the profile or its value does not fit.
|
|
331
|
+
"""
|
|
332
|
+
spec = self._spec()
|
|
333
|
+
if spec.set_parameter_hook is None:
|
|
334
|
+
return
|
|
335
|
+
types = {p.name: p.data_type_ref.code for p in spec.declared.parameter_profiles}
|
|
336
|
+
values: dict[str, Any] = {}
|
|
337
|
+
for parameter in parameters:
|
|
338
|
+
if parameter.name not in types:
|
|
339
|
+
raise ValueError(f"The profile defines no parameter {parameter.name!r}")
|
|
340
|
+
values[parameter.name] = decode_value(types[parameter.name], parameter.value)
|
|
341
|
+
await getattr(self, spec.set_parameter_hook)(MappingProxyType(values))
|
|
342
|
+
|
|
343
|
+
# -- Internals ---------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def _spec(cls) -> ComponentSpec:
|
|
347
|
+
spec = cls.__rois_spec__
|
|
348
|
+
if spec is None:
|
|
349
|
+
raise TypeError(f"{cls.__qualname__} is not declared with @component(profile)")
|
|
350
|
+
return spec
|
|
351
|
+
|
|
352
|
+
def _binding(self) -> EngineBinding:
|
|
353
|
+
if self._rois_binding is None:
|
|
354
|
+
raise RuntimeError(f"No engine hosts this {type(self).__qualname__} yet")
|
|
355
|
+
return self._rois_binding
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _message[M: MessageProfile](messages: Sequence[M], name: str) -> M:
|
|
359
|
+
"""The message profile of one name, among the served ones."""
|
|
360
|
+
for message in messages:
|
|
361
|
+
if message.name == name:
|
|
362
|
+
return message
|
|
363
|
+
raise ValueError(f"The component does not serve {name!r}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
type _WithArguments = CommandMessageProfile | QueryMessageProfile | EventMessageProfile
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _arguments(message: _WithArguments, arguments: Sequence[Argument]) -> dict[str, Any]:
|
|
370
|
+
"""The arguments of a message by name, converted from their profile types."""
|
|
371
|
+
types = {a.name: a.data_type_ref.code for a in message.arguments}
|
|
372
|
+
values: dict[str, Any] = {}
|
|
373
|
+
for argument in arguments:
|
|
374
|
+
if argument.name not in types:
|
|
375
|
+
raise ValueError(f"{message.name!r} has no argument {argument.name!r}")
|
|
376
|
+
values[argument.name] = decode_value(types[argument.name], argument.value)
|
|
377
|
+
return values
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _concerns(message: EventMessageProfile, values: Mapping[str, object]) -> dict[str, str]:
|
|
381
|
+
"""The argument values an event concerns, in the form their types take on the wire."""
|
|
382
|
+
types = {a.name: a.data_type_ref.code for a in message.arguments}
|
|
383
|
+
concerns: dict[str, str] = {}
|
|
384
|
+
for name, value in values.items():
|
|
385
|
+
if name not in types:
|
|
386
|
+
raise ValueError(f"{message.name!r} has no argument {name!r}")
|
|
387
|
+
concerns[name] = encode_value(types[name], value)
|
|
388
|
+
return concerns
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _results(message: MessageProfile, values: object) -> list[Result]:
|
|
392
|
+
"""The results of a message from their values by name, in the order of the profile."""
|
|
393
|
+
if values is None:
|
|
394
|
+
return []
|
|
395
|
+
if not isinstance(values, Mapping):
|
|
396
|
+
raise TypeError(
|
|
397
|
+
f"A handler of {message.name!r} returns its result values by name, or None, "
|
|
398
|
+
f"not {type(values).__name__}"
|
|
399
|
+
)
|
|
400
|
+
declared = {r.name for r in message.results}
|
|
401
|
+
unknown = sorted(set(values) - declared)
|
|
402
|
+
if unknown:
|
|
403
|
+
raise ValueError(f"{message.name!r} has no result named {', '.join(unknown)}")
|
|
404
|
+
return [
|
|
405
|
+
Result(
|
|
406
|
+
name=r.name,
|
|
407
|
+
data_type_ref=r.data_type_ref.code,
|
|
408
|
+
value=encode_value(r.data_type_ref.code, values[r.name]),
|
|
409
|
+
)
|
|
410
|
+
for r in message.results
|
|
411
|
+
if r.name in values
|
|
412
|
+
]
|