openrois-engine 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/engine/__init__.py +37 -0
- openrois/engine/child.py +621 -0
- openrois/engine/config.py +236 -0
- openrois/engine/contract.py +125 -0
- openrois/engine/engine.py +1004 -0
- openrois/engine/jsonrpc.py +198 -0
- openrois/engine/local.py +1069 -0
- openrois/engine/py.typed +0 -0
- openrois/engine/session.py +37 -0
- openrois/engine/ws_client.py +122 -0
- openrois/engine/ws_server.py +188 -0
- openrois_engine-0.1.0a1.dist-info/METADATA +309 -0
- openrois_engine-0.1.0a1.dist-info/RECORD +16 -0
- openrois_engine-0.1.0a1.dist-info/WHEEL +4 -0
- openrois_engine-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- openrois_engine-0.1.0a1.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""OpenRoIS engine: the recursive RoIS HRI Engine, with its WebSocket server and client.
|
|
2
|
+
|
|
3
|
+
One ``Engine`` class serves an adapter (components in its own process), a gateway
|
|
4
|
+
(child engines) and a middle tier (both). It answers the RoIS method catalog and
|
|
5
|
+
reaches every component through the component contract.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
Engine: The recursive HRI engine.
|
|
9
|
+
Session: A client of an engine, or its parent engine.
|
|
10
|
+
ComponentContract: Where the components of an engine live.
|
|
11
|
+
LocalComponent: The shape of a component the engine hosts in its process.
|
|
12
|
+
LocalComponents: The component contract for the components of this process.
|
|
13
|
+
ChildEngine: The component contract for one child engine.
|
|
14
|
+
WsServer: WebSocket server for a gateway.
|
|
15
|
+
WsClient: WebSocket client for an adapter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from openrois.engine.child import ChildEngine
|
|
21
|
+
from openrois.engine.contract import ComponentContract
|
|
22
|
+
from openrois.engine.engine import Engine
|
|
23
|
+
from openrois.engine.local import LocalComponent, LocalComponents
|
|
24
|
+
from openrois.engine.session import Session
|
|
25
|
+
from openrois.engine.ws_client import WsClient
|
|
26
|
+
from openrois.engine.ws_server import WsServer
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ChildEngine",
|
|
30
|
+
"ComponentContract",
|
|
31
|
+
"Engine",
|
|
32
|
+
"LocalComponent",
|
|
33
|
+
"LocalComponents",
|
|
34
|
+
"Session",
|
|
35
|
+
"WsClient",
|
|
36
|
+
"WsServer",
|
|
37
|
+
]
|
openrois/engine/child.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""A child engine: an adapter or a lower gateway, reached over the method catalog.
|
|
2
|
+
|
|
3
|
+
``ChildEngine`` implements the component contract for the components of one child
|
|
4
|
+
engine. It sends the child the same catalog requests a client would send, and turns
|
|
5
|
+
the child's notifications back into the engine's callbacks. The parent engine is the
|
|
6
|
+
child's only client, so the child skips the bind checks the parent already made.
|
|
7
|
+
|
|
8
|
+
The parent connects to the child with ``rois.system.connect`` before it reads its
|
|
9
|
+
profile, as any client of an engine does.
|
|
10
|
+
|
|
11
|
+
Ids stay the same end to end: refs and the ids the child assigns start with the id of
|
|
12
|
+
the engine that owns the component, and command ids are those the parent assigned. A
|
|
13
|
+
reply that assigns an id outside the child's engine ids counts as ERROR.
|
|
14
|
+
|
|
15
|
+
A command the parent aborts, because the session that started it released the component
|
|
16
|
+
or went away, is stopped on the child when no other client of the parent still holds the
|
|
17
|
+
component: the parent sends it an execute of stop on the component when its profile
|
|
18
|
+
declares stop, and then releases the component on the child. An OpenRoIS child aborts
|
|
19
|
+
the commands still running there on that release. Any other child is left alone after
|
|
20
|
+
that, and the parent no longer waits for its completed. When other clients still hold the
|
|
21
|
+
component, the parent only stops waiting, and the command runs on in the child.
|
|
22
|
+
|
|
23
|
+
A request that times out may still take effect on the child. A subscription whose reply
|
|
24
|
+
comes too late is ended, since nobody holds it. A command keeps running, and a parameter
|
|
25
|
+
value is applied, as RoIS has no way to take them back.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from openrois.interfaces.catalog import (
|
|
37
|
+
ConnectParams,
|
|
38
|
+
ConnectResult,
|
|
39
|
+
ExecuteParams,
|
|
40
|
+
ExecuteResult,
|
|
41
|
+
GetCommandResultParams,
|
|
42
|
+
GetCommandResultResult,
|
|
43
|
+
GetErrorDetailParams,
|
|
44
|
+
GetErrorDetailResult,
|
|
45
|
+
GetEventDetailParams,
|
|
46
|
+
GetEventDetailResult,
|
|
47
|
+
GetParameterParams,
|
|
48
|
+
GetParameterResult,
|
|
49
|
+
GetProfileParams,
|
|
50
|
+
GetProfileResult,
|
|
51
|
+
QueryParams,
|
|
52
|
+
QueryResult,
|
|
53
|
+
ReleaseParams,
|
|
54
|
+
ReleaseResult,
|
|
55
|
+
SetParameterParams,
|
|
56
|
+
SetParameterResult,
|
|
57
|
+
SubscribeParams,
|
|
58
|
+
SubscribeResult,
|
|
59
|
+
UnsubscribeParams,
|
|
60
|
+
UnsubscribeResult,
|
|
61
|
+
)
|
|
62
|
+
from openrois.interfaces.condition import COMPONENT_REF, all_of, eq
|
|
63
|
+
from openrois.interfaces.hri import Argument, CommandUnit, Parameter, ReturnCode
|
|
64
|
+
from openrois.interfaces.profiles import HRIComponentProfile, HRIEngineProfileType
|
|
65
|
+
from openrois.interfaces.service import (
|
|
66
|
+
CompletedParams,
|
|
67
|
+
CompletedStatus,
|
|
68
|
+
NotifyErrorParams,
|
|
69
|
+
NotifyEventParams,
|
|
70
|
+
)
|
|
71
|
+
from pydantic import BaseModel, ValidationError
|
|
72
|
+
|
|
73
|
+
from openrois.engine.contract import Deliver, OnError
|
|
74
|
+
|
|
75
|
+
logger = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
_JSONRPC_VERSION = "2.0"
|
|
78
|
+
|
|
79
|
+
# Checks an OK reply before the caller sees it, and records what the next messages need.
|
|
80
|
+
# Returning False turns the reply into ERROR.
|
|
81
|
+
type _OnReply = Callable[[Any], bool]
|
|
82
|
+
|
|
83
|
+
# A request waiting for its reply: the future, the result model, and the hook for an OK.
|
|
84
|
+
type _Pending = tuple[asyncio.Future[BaseModel], type[BaseModel], _OnReply | None]
|
|
85
|
+
|
|
86
|
+
# What to do with an OK reply that arrives after its caller stopped waiting.
|
|
87
|
+
type _OnLate = Callable[[Any], None]
|
|
88
|
+
|
|
89
|
+
# How many requests whose caller stopped waiting are remembered for a late reply.
|
|
90
|
+
_LATE_KEPT = 1024
|
|
91
|
+
|
|
92
|
+
# The completion status of a command the child refused to start, by return code.
|
|
93
|
+
_REFUSED_STATUS = {
|
|
94
|
+
ReturnCode.OUT_OF_RESOURCES: CompletedStatus.OUT_OF_RESOURCES,
|
|
95
|
+
ReturnCode.TIMEOUT: CompletedStatus.TIMEOUT,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ChildEngine:
|
|
100
|
+
"""The component contract for one child engine on a WebSocket connection.
|
|
101
|
+
|
|
102
|
+
The connection's reader passes every message from the child to :meth:`handle`,
|
|
103
|
+
in order, on the event loop. A reply is matched to its request, and work that must
|
|
104
|
+
happen before the next message, such as recording a new subscription, happens right
|
|
105
|
+
there.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
send: Callable[[str], Awaitable[None]],
|
|
111
|
+
*,
|
|
112
|
+
request_timeout: float = 10.0,
|
|
113
|
+
check_engine_ids: Callable[[ChildEngine, frozenset[str]], None] | None = None,
|
|
114
|
+
on_profile_changed: Callable[[], None] | None = None,
|
|
115
|
+
on_refused: Callable[[str], Awaitable[None]] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Initialize the proxy of a child engine that has not been discovered yet.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
send: Sends one text message to the child.
|
|
121
|
+
request_timeout: Seconds to wait for the child's reply to a request
|
|
122
|
+
before answering TIMEOUT. Commands themselves may run longer.
|
|
123
|
+
check_engine_ids: Raises ValueError for engine ids of the child that
|
|
124
|
+
another engine in the tree already uses, such as
|
|
125
|
+
``Engine.check_engine_ids``. Every profile of the child goes through
|
|
126
|
+
it, the first one and every changed one.
|
|
127
|
+
on_profile_changed: Called after the child's profile changed and was
|
|
128
|
+
read again.
|
|
129
|
+
on_refused: Called with the reason when a changed profile breaks the rules
|
|
130
|
+
discovery checks. It closes the connection, as discovery would have.
|
|
131
|
+
"""
|
|
132
|
+
self._send = send
|
|
133
|
+
self._request_timeout = request_timeout
|
|
134
|
+
self._check_engine_ids = check_engine_ids
|
|
135
|
+
self._on_profile_changed = on_profile_changed
|
|
136
|
+
self._on_refused = on_refused
|
|
137
|
+
self._open = True
|
|
138
|
+
self._next_id = 0
|
|
139
|
+
self._pending: dict[int, _Pending] = {}
|
|
140
|
+
self._late: dict[int, tuple[type[BaseModel], _OnLate]] = {}
|
|
141
|
+
self._running: dict[str, asyncio.Future[CompletedStatus]] = {}
|
|
142
|
+
self._parameters: dict[str, asyncio.Future[CompletedStatus]] = {}
|
|
143
|
+
self._deliveries: dict[str, Deliver] = {}
|
|
144
|
+
self._on_error: OnError | None = None
|
|
145
|
+
# Commands whose abort only stops the wait, since other clients share them.
|
|
146
|
+
self._kept: set[str] = set()
|
|
147
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
148
|
+
self._profile: HRIEngineProfileType | None = None
|
|
149
|
+
self._profiles: dict[str, HRIComponentProfile] = {}
|
|
150
|
+
self._engine_ids: frozenset[str] = frozenset()
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def engine_id(self) -> str:
|
|
154
|
+
"""The id of the child engine, empty before discovery."""
|
|
155
|
+
return self._profile.identifier.code if self._profile is not None else ""
|
|
156
|
+
|
|
157
|
+
# -- Connection ----------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def discover(self) -> None:
|
|
160
|
+
"""Connect to the child and read its profile. Call once, after the reader started.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValueError: The child refused the connect or did not answer with a profile,
|
|
164
|
+
or the profile names an invalid engine id or a component ref no engine
|
|
165
|
+
in it owns.
|
|
166
|
+
"""
|
|
167
|
+
connected = await self._request("rois.system.connect", ConnectParams(), ConnectResult)
|
|
168
|
+
if connected.return_code is not ReturnCode.OK:
|
|
169
|
+
raise ValueError(f"Connect failed with {connected.return_code.value}.")
|
|
170
|
+
result = await self._request(
|
|
171
|
+
"rois.system.get_profile", GetProfileParams(condition=""), GetProfileResult
|
|
172
|
+
)
|
|
173
|
+
if result.return_code is not ReturnCode.OK or result.profile is None:
|
|
174
|
+
raise ValueError(f"Discovery failed with {result.return_code.value}.")
|
|
175
|
+
self._apply_profile(result)
|
|
176
|
+
|
|
177
|
+
def handle(self, message: Mapping[str, Any]) -> None:
|
|
178
|
+
"""Route one message from the child: a reply or a notification."""
|
|
179
|
+
if "id" in message and "method" not in message:
|
|
180
|
+
self._handle_reply(message)
|
|
181
|
+
elif "method" in message and "id" not in message:
|
|
182
|
+
# A reply wakes its caller with call_soon, so a notification queued the same
|
|
183
|
+
# way runs after the caller has passed the reply on. The client of a gateway
|
|
184
|
+
# thus reads the reply to subscribe before the first event of the
|
|
185
|
+
# subscription, and the reply to set_parameter before its completion.
|
|
186
|
+
asyncio.get_running_loop().call_soon(
|
|
187
|
+
self._handle_notification, str(message["method"]), message.get("params", {})
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
logger.warning("Child engine %s sent a request, which it may not do.", self.engine_id)
|
|
191
|
+
|
|
192
|
+
def detach(self) -> None:
|
|
193
|
+
"""Forget the connection: fail what still waits on the child."""
|
|
194
|
+
self._open = False
|
|
195
|
+
for future, result_type, _ in self._pending.values():
|
|
196
|
+
if not future.done():
|
|
197
|
+
future.set_result(result_type.model_validate({"return_code": ReturnCode.ERROR}))
|
|
198
|
+
self._pending.clear()
|
|
199
|
+
self._late.clear()
|
|
200
|
+
for running in self._running.values():
|
|
201
|
+
if not running.done():
|
|
202
|
+
running.set_result(CompletedStatus.ERROR)
|
|
203
|
+
self._running.clear()
|
|
204
|
+
for changed in self._parameters.values():
|
|
205
|
+
if not changed.done():
|
|
206
|
+
changed.set_result(CompletedStatus.ERROR)
|
|
207
|
+
self._parameters.clear()
|
|
208
|
+
self._deliveries.clear()
|
|
209
|
+
for task in self._tasks:
|
|
210
|
+
task.cancel()
|
|
211
|
+
|
|
212
|
+
# -- The component contract ----------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def engine_ids(self) -> frozenset[str]:
|
|
216
|
+
return self._engine_ids
|
|
217
|
+
|
|
218
|
+
def profiles(self) -> Mapping[str, HRIComponentProfile]:
|
|
219
|
+
return self._profiles
|
|
220
|
+
|
|
221
|
+
def engine_profile(self) -> HRIEngineProfileType | None:
|
|
222
|
+
return self._profile
|
|
223
|
+
|
|
224
|
+
def on_error(self, callback: OnError) -> None:
|
|
225
|
+
self._on_error = callback
|
|
226
|
+
|
|
227
|
+
def keep_running(self, command_id: str) -> None:
|
|
228
|
+
self._kept.add(command_id)
|
|
229
|
+
|
|
230
|
+
async def run(self, unit: CommandUnit) -> CompletedStatus:
|
|
231
|
+
try:
|
|
232
|
+
return await self._execute(unit)
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
# The parent aborts the command, so the child halts it, unless other clients
|
|
235
|
+
# of the parent still hold the component: then the command runs on.
|
|
236
|
+
if unit.command_id in self._kept:
|
|
237
|
+
self._kept.discard(unit.command_id)
|
|
238
|
+
else:
|
|
239
|
+
self._spawn(self._abort(unit))
|
|
240
|
+
raise
|
|
241
|
+
finally:
|
|
242
|
+
self._kept.discard(unit.command_id)
|
|
243
|
+
|
|
244
|
+
async def set_parameter(
|
|
245
|
+
self, ref: str, parameters: Sequence[Parameter]
|
|
246
|
+
) -> tuple[SetParameterResult, CompletedStatus]:
|
|
247
|
+
changed: asyncio.Future[CompletedStatus] = asyncio.get_running_loop().create_future()
|
|
248
|
+
|
|
249
|
+
def record(result: SetParameterResult) -> bool:
|
|
250
|
+
if not self._owns(result.command_id) or result.command_id in self._parameters:
|
|
251
|
+
return False
|
|
252
|
+
self._parameters[result.command_id] = changed
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
result = await self._request(
|
|
256
|
+
"rois.command.set_parameter",
|
|
257
|
+
SetParameterParams(component_ref=ref, parameters=list(parameters)),
|
|
258
|
+
SetParameterResult,
|
|
259
|
+
record,
|
|
260
|
+
)
|
|
261
|
+
if result.return_code is not ReturnCode.OK:
|
|
262
|
+
return result, CompletedStatus.ERROR
|
|
263
|
+
# The child applied the values before its reply and sends completed right after
|
|
264
|
+
# it, so the parent replies to its client only once the values are applied.
|
|
265
|
+
try:
|
|
266
|
+
async with asyncio.timeout(self._request_timeout):
|
|
267
|
+
status = await changed
|
|
268
|
+
except TimeoutError:
|
|
269
|
+
logger.warning(
|
|
270
|
+
"Child engine %s did not complete %s.", self.engine_id, result.command_id
|
|
271
|
+
)
|
|
272
|
+
status = CompletedStatus.ERROR
|
|
273
|
+
finally:
|
|
274
|
+
self._parameters.pop(result.command_id, None)
|
|
275
|
+
return result, status
|
|
276
|
+
|
|
277
|
+
async def get_parameter(self, ref: str) -> GetParameterResult:
|
|
278
|
+
return await self._request(
|
|
279
|
+
"rois.command.get_parameter", GetParameterParams(component_ref=ref), GetParameterResult
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def command_result(self, command_id: str) -> GetCommandResultResult:
|
|
283
|
+
return await self._request(
|
|
284
|
+
"rois.command.get_command_result",
|
|
285
|
+
GetCommandResultParams(command_id=command_id, condition=""),
|
|
286
|
+
GetCommandResultResult,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def query(self, ref: str, query_type: str, arguments: Sequence[Argument]) -> QueryResult:
|
|
290
|
+
return await self._request(
|
|
291
|
+
"rois.query.query",
|
|
292
|
+
QueryParams(query_type=query_type, condition=_condition(ref, arguments)),
|
|
293
|
+
QueryResult,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def subscribe(
|
|
297
|
+
self, ref: str, event_type: str, arguments: Sequence[Argument], deliver: Deliver
|
|
298
|
+
) -> SubscribeResult:
|
|
299
|
+
recorded = False
|
|
300
|
+
|
|
301
|
+
def record(result: SubscribeResult) -> bool:
|
|
302
|
+
nonlocal recorded
|
|
303
|
+
if not self._owns(result.subscribe_id) or result.subscribe_id in self._deliveries:
|
|
304
|
+
return False
|
|
305
|
+
self._deliveries[result.subscribe_id] = deliver
|
|
306
|
+
recorded = True
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
def end_late(result: SubscribeResult) -> None:
|
|
310
|
+
# Nobody holds a subscription whose reply came too late, so end it. An id
|
|
311
|
+
# that another request recorded belongs to a subscription someone holds.
|
|
312
|
+
if not self._owns(result.subscribe_id):
|
|
313
|
+
return
|
|
314
|
+
if result.subscribe_id in self._deliveries and not recorded:
|
|
315
|
+
return
|
|
316
|
+
self._spawn(self.unsubscribe(result.subscribe_id))
|
|
317
|
+
|
|
318
|
+
return await self._request(
|
|
319
|
+
"rois.event.subscribe",
|
|
320
|
+
SubscribeParams(event_type=event_type, condition=_condition(ref, arguments)),
|
|
321
|
+
SubscribeResult,
|
|
322
|
+
record,
|
|
323
|
+
end_late,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def unsubscribe(self, subscribe_id: str) -> None:
|
|
327
|
+
self._deliveries.pop(subscribe_id, None)
|
|
328
|
+
await self._request(
|
|
329
|
+
"rois.event.unsubscribe",
|
|
330
|
+
UnsubscribeParams(subscribe_id=subscribe_id),
|
|
331
|
+
UnsubscribeResult,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def event_detail(self, event_id: str) -> GetEventDetailResult:
|
|
335
|
+
return await self._request(
|
|
336
|
+
"rois.event.get_event_detail",
|
|
337
|
+
GetEventDetailParams(event_id=event_id, condition=""),
|
|
338
|
+
GetEventDetailResult,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
async def error_detail(self, error_id: str) -> GetErrorDetailResult:
|
|
342
|
+
return await self._request(
|
|
343
|
+
"rois.system.get_error_detail",
|
|
344
|
+
GetErrorDetailParams(error_id=error_id, condition=""),
|
|
345
|
+
GetErrorDetailResult,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# -- Requests ------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
async def _execute(self, unit: CommandUnit) -> CompletedStatus:
|
|
351
|
+
"""Send one command to the child and wait until it completes.
|
|
352
|
+
|
|
353
|
+
The child must accept the command within its timeout, from the profile, or it
|
|
354
|
+
completes with TIMEOUT: the timeout judges whether the command started.
|
|
355
|
+
"""
|
|
356
|
+
future: asyncio.Future[CompletedStatus] = asyncio.get_running_loop().create_future()
|
|
357
|
+
self._running[unit.command_id] = future
|
|
358
|
+
# This engine waited the delay already, so the child must not wait it again.
|
|
359
|
+
command = unit.model_copy(update={"delay_time": None})
|
|
360
|
+
try:
|
|
361
|
+
reply = await self._request(
|
|
362
|
+
"rois.command.execute",
|
|
363
|
+
ExecuteParams(command_unit_list=[command]),
|
|
364
|
+
ExecuteResult,
|
|
365
|
+
timeout=self._start_timeout(unit),
|
|
366
|
+
)
|
|
367
|
+
if reply.return_code is not ReturnCode.OK:
|
|
368
|
+
return _REFUSED_STATUS.get(reply.return_code, CompletedStatus.ERROR)
|
|
369
|
+
return await future
|
|
370
|
+
finally:
|
|
371
|
+
self._running.pop(unit.command_id, None)
|
|
372
|
+
|
|
373
|
+
async def _abort(self, unit: CommandUnit) -> None:
|
|
374
|
+
"""Stop an aborted command on the child, then release the component there."""
|
|
375
|
+
profile = self._profiles.get(unit.component_ref)
|
|
376
|
+
if profile is not None and any(m.name == "stop" for m in profile.command_profiles):
|
|
377
|
+
stop = CommandUnit(
|
|
378
|
+
component_ref=unit.component_ref,
|
|
379
|
+
command_type="stop",
|
|
380
|
+
command_id=f"{unit.command_id}/stop",
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
async with asyncio.timeout(self._request_timeout):
|
|
384
|
+
await self._execute(stop)
|
|
385
|
+
except TimeoutError:
|
|
386
|
+
logger.warning(
|
|
387
|
+
"Child engine %s did not complete the stop of %s.",
|
|
388
|
+
self.engine_id,
|
|
389
|
+
unit.component_ref,
|
|
390
|
+
)
|
|
391
|
+
await self._request(
|
|
392
|
+
"rois.command.release", ReleaseParams(component_ref=unit.component_ref), ReleaseResult
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _start_timeout(self, unit: CommandUnit) -> float:
|
|
396
|
+
"""How long the child has to accept a command: the request timeout, or less."""
|
|
397
|
+
profile = self._profiles.get(unit.component_ref)
|
|
398
|
+
message = None
|
|
399
|
+
if profile is not None:
|
|
400
|
+
message = next(
|
|
401
|
+
(m for m in profile.command_profiles if m.name == unit.command_type), None
|
|
402
|
+
)
|
|
403
|
+
if message is None or not message.timeout:
|
|
404
|
+
return self._request_timeout
|
|
405
|
+
return min(self._request_timeout, message.timeout / 1000)
|
|
406
|
+
|
|
407
|
+
async def _request[R: BaseModel](
|
|
408
|
+
self,
|
|
409
|
+
method: str,
|
|
410
|
+
params: BaseModel,
|
|
411
|
+
result_type: type[R],
|
|
412
|
+
on_reply: Callable[[R], bool] | None = None,
|
|
413
|
+
on_late: Callable[[R], None] | None = None,
|
|
414
|
+
*,
|
|
415
|
+
timeout: float | None = None,
|
|
416
|
+
) -> R:
|
|
417
|
+
"""Send one request and wait for its reply.
|
|
418
|
+
|
|
419
|
+
``on_reply`` runs when an OK reply arrives, before the next message from the
|
|
420
|
+
child is handled, and turns the reply into ERROR by returning False. A child
|
|
421
|
+
that fails answers ERROR, and one that is too slow answers TIMEOUT, after
|
|
422
|
+
``timeout`` seconds or the request timeout. ``on_late`` runs when an OK reply
|
|
423
|
+
arrives after the caller stopped waiting, because of the timeout or because the
|
|
424
|
+
caller was cancelled.
|
|
425
|
+
"""
|
|
426
|
+
failed = result_type.model_validate({"return_code": ReturnCode.ERROR})
|
|
427
|
+
if not self._open:
|
|
428
|
+
return failed
|
|
429
|
+
self._next_id += 1
|
|
430
|
+
request_id = self._next_id
|
|
431
|
+
future: asyncio.Future[BaseModel] = asyncio.get_running_loop().create_future()
|
|
432
|
+
self._pending[request_id] = (future, result_type, on_reply)
|
|
433
|
+
message = {
|
|
434
|
+
"jsonrpc": _JSONRPC_VERSION,
|
|
435
|
+
"id": request_id,
|
|
436
|
+
"method": method,
|
|
437
|
+
"params": params.model_dump(mode="json"),
|
|
438
|
+
}
|
|
439
|
+
try:
|
|
440
|
+
await self._send(json.dumps(message))
|
|
441
|
+
async with asyncio.timeout(timeout if timeout is not None else self._request_timeout):
|
|
442
|
+
reply = await future
|
|
443
|
+
except TimeoutError:
|
|
444
|
+
logger.warning("Child engine %s did not answer %s in time.", self.engine_id, method)
|
|
445
|
+
self._remember_late(request_id, result_type, on_late)
|
|
446
|
+
return result_type.model_validate({"return_code": ReturnCode.TIMEOUT})
|
|
447
|
+
except asyncio.CancelledError:
|
|
448
|
+
# Cancelling the caller cancels the future it waits on, unless the reply
|
|
449
|
+
# had already resolved it.
|
|
450
|
+
if future.cancelled() or not future.done():
|
|
451
|
+
self._remember_late(request_id, result_type, on_late)
|
|
452
|
+
elif on_late is not None:
|
|
453
|
+
# The reply came, but the caller is gone before it could pass it on.
|
|
454
|
+
arrived = future.result()
|
|
455
|
+
if isinstance(arrived, result_type) and _is_ok(arrived):
|
|
456
|
+
on_late(arrived)
|
|
457
|
+
raise
|
|
458
|
+
except Exception:
|
|
459
|
+
logger.exception("Could not send %s to child engine %s", method, self.engine_id)
|
|
460
|
+
return failed
|
|
461
|
+
finally:
|
|
462
|
+
self._pending.pop(request_id, None)
|
|
463
|
+
assert isinstance(reply, result_type)
|
|
464
|
+
return reply
|
|
465
|
+
|
|
466
|
+
def _remember_late(
|
|
467
|
+
self, request_id: int, result_type: type[BaseModel], on_late: _OnLate | None
|
|
468
|
+
) -> None:
|
|
469
|
+
if on_late is None:
|
|
470
|
+
return
|
|
471
|
+
self._late[request_id] = (result_type, on_late)
|
|
472
|
+
while len(self._late) > _LATE_KEPT:
|
|
473
|
+
del self._late[next(iter(self._late))]
|
|
474
|
+
|
|
475
|
+
def _handle_reply(self, message: Mapping[str, Any]) -> None:
|
|
476
|
+
request_id = message.get("id")
|
|
477
|
+
if not isinstance(request_id, int):
|
|
478
|
+
return
|
|
479
|
+
entry = self._pending.pop(request_id, None)
|
|
480
|
+
if entry is None:
|
|
481
|
+
late = self._late.pop(request_id, None)
|
|
482
|
+
if late is not None:
|
|
483
|
+
reply = self._reply_of(message, late[0])
|
|
484
|
+
if _is_ok(reply):
|
|
485
|
+
late[1](reply)
|
|
486
|
+
return
|
|
487
|
+
future, result_type, on_reply = entry
|
|
488
|
+
reply = self._reply_of(message, result_type)
|
|
489
|
+
if on_reply is not None and _is_ok(reply) and not on_reply(reply):
|
|
490
|
+
logger.warning(
|
|
491
|
+
"Child engine %s assigned an id outside its engine ids, or one in use.",
|
|
492
|
+
self.engine_id,
|
|
493
|
+
)
|
|
494
|
+
reply = result_type.model_validate({"return_code": ReturnCode.ERROR})
|
|
495
|
+
if not future.done():
|
|
496
|
+
future.set_result(reply)
|
|
497
|
+
|
|
498
|
+
def _reply_of(self, message: Mapping[str, Any], result_type: type[BaseModel]) -> BaseModel:
|
|
499
|
+
"""The result model of a reply, or ERROR for an error or a reply outside the catalog."""
|
|
500
|
+
if "error" in message:
|
|
501
|
+
logger.warning(
|
|
502
|
+
"Child engine %s answered an error: %s", self.engine_id, message["error"]
|
|
503
|
+
)
|
|
504
|
+
return result_type.model_validate({"return_code": ReturnCode.ERROR})
|
|
505
|
+
try:
|
|
506
|
+
return result_type.model_validate(message.get("result"))
|
|
507
|
+
except ValidationError:
|
|
508
|
+
logger.warning("Child engine %s sent a reply outside the catalog.", self.engine_id)
|
|
509
|
+
return result_type.model_validate({"return_code": ReturnCode.ERROR})
|
|
510
|
+
|
|
511
|
+
def _owns(self, assigned_id: str) -> bool:
|
|
512
|
+
"""Whether an id starts with the id of an engine of the child, and a slash."""
|
|
513
|
+
engine_id, separator, rest = assigned_id.partition("/")
|
|
514
|
+
return bool(separator and rest) and engine_id in self._engine_ids
|
|
515
|
+
|
|
516
|
+
def _handle_notification(self, method: str, params: Any) -> None:
|
|
517
|
+
try:
|
|
518
|
+
if method == "rois.command.completed":
|
|
519
|
+
completed = CompletedParams.model_validate(params)
|
|
520
|
+
running = self._running.get(completed.command_id)
|
|
521
|
+
if running is None:
|
|
522
|
+
running = self._parameters.get(completed.command_id)
|
|
523
|
+
if running is not None and not running.done():
|
|
524
|
+
running.set_result(completed.status)
|
|
525
|
+
elif method == "rois.event.notify_event":
|
|
526
|
+
event = NotifyEventParams.model_validate(params)
|
|
527
|
+
deliver = self._deliveries.get(event.subscribe_id)
|
|
528
|
+
if deliver is not None:
|
|
529
|
+
deliver(event)
|
|
530
|
+
elif method == "rois.system.profile_changed":
|
|
531
|
+
self._spawn(self._rediscover())
|
|
532
|
+
elif method == "rois.system.notify_error":
|
|
533
|
+
self._spawn(self._pass_error(NotifyErrorParams.model_validate(params)))
|
|
534
|
+
except ValidationError:
|
|
535
|
+
logger.warning(
|
|
536
|
+
"Child engine %s sent a %s outside the catalog.", self.engine_id, method
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
async def _pass_error(self, error: NotifyErrorParams) -> None:
|
|
540
|
+
"""Pass an error of the child up, with the component its detail names.
|
|
541
|
+
|
|
542
|
+
The error id stays the child's, so get_error_detail routes back to it. An error
|
|
543
|
+
whose detail names no component of the child is an error of the engine.
|
|
544
|
+
"""
|
|
545
|
+
if self._on_error is None or not self._owns(error.error_id):
|
|
546
|
+
return
|
|
547
|
+
detail = await self.error_detail(error.error_id)
|
|
548
|
+
ref = next((r.value for r in detail.results if r.name == "component_ref"), "")
|
|
549
|
+
if ref not in self._profiles:
|
|
550
|
+
ref = ""
|
|
551
|
+
self._on_error(ref, error.error_id, error.error_type)
|
|
552
|
+
|
|
553
|
+
# -- Profile -------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
def _apply_profile(self, result: GetProfileResult) -> None:
|
|
556
|
+
profile = result.profile
|
|
557
|
+
assert profile is not None
|
|
558
|
+
engine_ids = _engine_ids(profile)
|
|
559
|
+
for engine_id in engine_ids:
|
|
560
|
+
if not engine_id or "/" in engine_id:
|
|
561
|
+
raise ValueError(
|
|
562
|
+
f"Invalid engine id {engine_id!r}: it must be non-empty and free of slashes."
|
|
563
|
+
)
|
|
564
|
+
if engine_ids.count(engine_id) > 1:
|
|
565
|
+
raise ValueError(f"Engine id {engine_id} appears twice in the child.")
|
|
566
|
+
profiles: dict[str, HRIComponentProfile] = {}
|
|
567
|
+
for ref in profile.component_ids:
|
|
568
|
+
owner = ref.split("/", 1)[0]
|
|
569
|
+
if "/" not in ref or owner not in engine_ids:
|
|
570
|
+
raise ValueError(
|
|
571
|
+
f"Component ref {ref!r} does not start with an engine id of the child."
|
|
572
|
+
)
|
|
573
|
+
if ref not in result.component_profiles:
|
|
574
|
+
raise ValueError(f"The child engine sent no profile for {ref!r}.")
|
|
575
|
+
profiles[ref] = result.component_profiles[ref]
|
|
576
|
+
if self._check_engine_ids is not None:
|
|
577
|
+
self._check_engine_ids(self, frozenset(engine_ids))
|
|
578
|
+
self._profile = profile
|
|
579
|
+
self._profiles = profiles
|
|
580
|
+
self._engine_ids = frozenset(engine_ids)
|
|
581
|
+
|
|
582
|
+
async def _rediscover(self) -> None:
|
|
583
|
+
result = await self._request(
|
|
584
|
+
"rois.system.get_profile", GetProfileParams(condition=""), GetProfileResult
|
|
585
|
+
)
|
|
586
|
+
if result.return_code is not ReturnCode.OK or result.profile is None:
|
|
587
|
+
logger.warning("Could not read the changed profile of child engine %s.", self.engine_id)
|
|
588
|
+
return
|
|
589
|
+
try:
|
|
590
|
+
self._apply_profile(result)
|
|
591
|
+
except ValueError as exc:
|
|
592
|
+
logger.warning(
|
|
593
|
+
"Refused the changed profile of child engine %s: %s", self.engine_id, exc
|
|
594
|
+
)
|
|
595
|
+
if self._on_refused is not None:
|
|
596
|
+
await self._on_refused(str(exc))
|
|
597
|
+
return
|
|
598
|
+
if self._on_profile_changed is not None:
|
|
599
|
+
self._on_profile_changed()
|
|
600
|
+
|
|
601
|
+
def _spawn(self, coroutine: Awaitable[None]) -> None:
|
|
602
|
+
task = asyncio.ensure_future(coroutine)
|
|
603
|
+
self._tasks.add(task)
|
|
604
|
+
task.add_done_callback(self._tasks.discard)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _condition(ref: str, arguments: Sequence[Argument]) -> str:
|
|
608
|
+
"""The condition that selects one component of the child and gives the arguments."""
|
|
609
|
+
return all_of(eq(COMPONENT_REF, ref), *(eq(a.name, a.value) for a in arguments))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _is_ok(reply: BaseModel) -> bool:
|
|
613
|
+
return getattr(reply, "return_code", None) is ReturnCode.OK
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _engine_ids(profile: HRIEngineProfileType) -> list[str]:
|
|
617
|
+
"""The id of an engine and of every engine below it."""
|
|
618
|
+
ids = [profile.identifier.code]
|
|
619
|
+
for sub_profile in profile.sub_profiles:
|
|
620
|
+
ids.extend(_engine_ids(sub_profile))
|
|
621
|
+
return ids
|