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.
@@ -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
+ ]
@@ -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