Coverage for core / src / sensorkit / core / impl / controller.py: 85%
212 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2from __future__ import annotations
4import asyncio
5import contextlib
6from _contextvars import ContextVar
7from datetime import UTC, datetime
8from typing import Callable, ClassVar, Collection, Mapping, override
10import uuid_utils.compat as uuid
11from loguru import logger
13from sensorkit.backend.base import Entity
14from sensorkit.backend.request import CallContext
15from sensorkit.core.controller import (
16 AbortRequestMessage,
17 AbortResponseMessage,
18 ControllerDevice,
19 ControllerEnableState,
20 ControllerEnableStateRequest,
21 ControllerInterface,
22 ControllerOperatingState,
23 ControllerState,
24 ExecuteRequestMessage,
25 ExecuteResponseMessage,
26 InternalControllerState,
27 TaskExecutionResult,
28 TaskExecutionState,
29 TaskFinishInfo,
30 TaskHandlerCallback,
31 abort_task_request,
32 execute_task_request,
33 set_enable_state_request,
34)
35from sensorkit.core.device import DeviceClient
36from sensorkit.core.entity import ControllerDetails, EntityInfo
37from sensorkit.core.impl.entity import EntityImpl
38from sensorkit.core.task import Task, TaskExecution, TaskInfo
39from sensorkit.data.context import Context, ContextSubscription
42class ControllerDeviceMap(Mapping[str, ControllerDevice]):
43 """Ordered mapping of device names to their ControllerDevice instances, created on demand."""
45 def __init__(self):
46 self._dict: dict[str, ControllerDevice] = {}
48 def get_or_add(self, name: str, *, get_client_func: Callable[[str], DeviceClient]):
49 """Return the ControllerDevice for the given name, creating it if it does not yet exist."""
50 if name not in self._dict:
51 client = get_client_func(name)
52 self._dict[name] = ControllerDevice(client, ContextSubscription(client))
54 return self._dict[name]
56 def __getitem__(self, key, /):
57 return self._dict[key]
59 def __len__(self):
60 return len(self._dict)
62 def __iter__(self):
63 return iter(self._dict)
66class ControllerImpl(EntityImpl, ControllerInterface):
67 """Helper for implementing server-side functionality of a Controller."""
69 current: ClassVar[ContextVar[ControllerImpl | None]] = ContextVar("current_controller", default=None)
71 def __init__(self, **kwargs):
72 super().__init__(**kwargs)
74 self._enable_hooks: list[Callable[[], None]] = []
75 self._disable_hooks: list[Callable[[], None]] = []
76 self._task_handlers: dict[type[Task], TaskHandlerCallback] = {}
77 self._task_asyncio: asyncio.Task | None = None
78 self._devices = ControllerDeviceMap()
80 @override
81 def task_running(self):
82 return bool(self._task_asyncio)
84 @override
85 def on_enable(self, func: Callable[[], None]):
86 self._enable_hooks.append(func)
87 return func
89 @override
90 def on_disable(self, func: Callable[[], None]):
91 self._disable_hooks.append(func)
92 return func
94 @override
95 async def init_impl(self):
96 self._state = await ControllerState.recover_or_init(
97 self,
98 enable_state=ControllerEnableState(enabled=True),
99 operating_state=ControllerOperatingState(current=InternalControllerState.UNKNOWN),
100 execution_state=TaskExecutionState(execution=None),
101 )
103 @override
104 async def attach_impl(self):
105 if self._state.enable_state.enabled:
106 await self._call_with_context(self._enable_hooks)
108 await self.handle_request(set_enable_state_request, self._set_enable_state)
109 await self.handle_request(abort_task_request, self._abort_request)
110 await self.handle_request(execute_task_request, self._execute_request)
112 await self.start_device_subscriptions()
114 @override
115 async def detach_impl(self):
116 await self.stop_device_subscriptions()
118 @override
119 def use_device(self, name: str, *, subscribe: list[type] | None = None) -> DeviceClient:
120 """Register a controlled device and return a client for it.
122 Registers a device that this controller interacts with. Optionally subscribes to
123 specific keyword types published by the device. Subscribed keywords are automatically
124 cached and made available in contexts via `update_context()`.
126 Note:
127 Subscriptions remain inactive until `start_device_subscriptions()` is called.
128 When using the declarative API, this occurs automatically after initialization.
130 Args:
131 name: Entity identifier for the device (e.g., "mount", "camera").
132 subscribe: Optional list of keyword model types (e.g., Pydantic models) to
133 subscribe to. Each subscribed keyword will be monitored, and its latest
134 value cached for inclusion in contexts.
136 Returns:
137 A client instance for interacting with the registered device.
138 Access the subscription object via `get_device(name).subscription` to
139 retrieve cached values directly.
141 Raises:
142 KeyError: If attempting to access a device via `get_device(name)` that
143 hasn't been registered with `use_device()`.
145 Examples:
146 Basic device registration without subscriptions:
148 >>> camera = self.use_device("camera")
150 Register device and subscribe to specific keyword types:
152 >>> mount = self.use_device("mount", subscribe=[AltAzPointing, RADecPointing])
154 Access cached subscription values from the ControlledDevice instance:
156 >>> pointing = self.get_device("mount").get(AltAzPointing)
157 """
158 device = self._devices.get_or_add(
159 name,
160 get_client_func=lambda _: self.sensorkit().device(Entity((name,))),
161 )
163 if subscribe:
164 for kw_type in subscribe:
165 device.subscription.add(kw_type)
167 return device.client
169 @override
170 def get_device(self, name: str) -> ControllerDevice:
171 return self._devices[name]
173 @override
174 def all_devices(self) -> Collection[ControllerDevice]:
175 return self._devices.values()
177 @override
178 async def start_device_subscriptions(self):
179 """Start all keyword subscriptions registered via `use_device`.
181 This is called automatically by the declarative API after init
182 callbacks have run. It may also be called manually if the
183 controller is not using the declarative API.
184 """
185 for device in self._devices.values():
186 await device.subscription.start()
188 @override
189 async def stop_device_subscriptions(self):
190 """Stop all keyword subscriptions registered via `use_device`.
192 Called automatically by the declarative API before deinit
193 callbacks run.
194 """
195 for device in self._devices.values():
196 await device.subscription.stop()
198 @override
199 async def update_context(
200 self,
201 *args,
202 **kwargs,
203 ) -> Context:
204 """Update the context of the currently executing task.
206 Merges the base context with fresh snapshots from all device keyword
207 subscriptions and additional provided values. The updated context is
208 persisted to the execution state and returned.
210 This method allows task handlers to refresh their context mid-execution,
211 incorporating the latest device state.
213 Args:
214 **kwargs: Additional literal key-value pairs to include in the context.
216 Returns:
217 The newly updated `Context` containing (in precedence order,
218 highest-last): current task context, fresh device snapshots,
219 and *kwargs*.
221 Raises:
222 RuntimeError: If called when no task is currently executing.
224 Examples:
225 Update context with latest device data during task execution:
227 >>> async def my_task_handler(self, task):
228 ... # ... some work ...
229 ... ctx = await self.update_context()
230 ... pointing = ctx.get(AltAzPointing)
232 Add custom values while updating:
234 >>> ctx = await self.update_context(iteration=5, timestamp=time.time())
235 """
236 current = self._state.execution_state
238 if not current.executing or current.execution is None:
239 raise RuntimeError("no task executing")
241 ctx = Context(current.context)
243 for device in self._devices.values():
244 device.subscription.snapshot(into=ctx)
246 ctx.set(*args)
248 for key, value in kwargs.items():
249 ctx.set_value(key, value)
251 await self._state.update(
252 self,
253 TaskExecutionState(
254 executing=current.executing,
255 aborting=current.aborting,
256 execution=current.execution,
257 context=ctx,
258 ),
259 )
261 return ctx
263 async def _set_enable_state(self, request: ControllerEnableStateRequest):
264 if request.enable == self._state.enable_state.enabled:
265 return
267 await self._state.update(self, ControllerEnableState(enabled=request.enable))
269 if request.enable:
270 await self._call_with_context(self._enable_hooks)
271 else:
272 #TODO: Abort. Need a nice way of calling a local "loopback" request.
273 # await self.call_loopback(abort_task_request, AbortRequestMessage(task_id=None))
275 await self._call_with_context(self._disable_hooks)
277 async def _abort_request(
278 self,
279 requested: AbortRequestMessage,
280 call: CallContext[AbortResponseMessage, None],
281 ):
282 # Cannot abort if no task is running.
283 if not self.task_running():
284 call.reject(response=AbortResponseMessage(aborting=False, task_id=None))
285 return
287 aio_task = self._task_asyncio
288 task_id = self._state.execution_state.execution.task_id
290 # If given, ensure the task_id matches what is running.
291 if requested.task_id and task_id != requested.task_id:
292 call.reject(response=AbortResponseMessage(aborting=False, task_id=None))
293 return
295 # Send an accept response and update status indicating we are about to abort.
296 logger.debug(f"aborting running task {task_id}")
297 call.accept(response=AbortResponseMessage(aborting=True, task_id=task_id))
298 await self._state.update(
299 self,
300 TaskExecutionState(
301 executing=True,
302 aborting=True,
303 execution=self._state.execution_state.execution
304 ),
305 )
307 # Abort the asyncio.Task running the SensorKit Task.
308 aio_task.cancel("Aborted by remote request")
310 # Wait for the task to die.
311 with contextlib.suppress(asyncio.CancelledError, Exception):
312 await call.progress_from_task(aio_task, cadence=5, ttl=10)
314 if aio_task.done():
315 # Success.
316 await call.succeed(result=None)
317 else:
318 await call.fail()
320 async def _execute_request(
321 self,
322 msg: ExecuteRequestMessage,
323 call: CallContext[ExecuteResponseMessage, TaskExecutionResult],
324 ):
325 task = msg.task
327 # Mint the execution envelope: the controller owns identity (task_id, controller_id) and
328 # records the client-supplied execution parameters (context, end_time).
329 execution = TaskExecution(
330 task=task,
331 task_id=uuid.uuid7(),
332 controller_id=str(self.entity),
333 context=msg.context,
334 expiry_time=msg.expiry_time,
335 )
337 # Inject information about this task execution into the task context.
338 execution.context.set(
339 TaskInfo(
340 task=task,
341 task_id=execution.task_id,
342 controller_id=execution.controller_id,
343 )
344 )
346 # Send the initial response.
347 response = ExecuteResponseMessage(execution=execution)
349 if not self._state.enable_state.enabled:
350 logger.warning(f"Rejecting {task.task_type} task: Controller is disabled")
351 call.reject(response=response)
352 return
354 if not msg.interrupt and self.task_running():
355 logger.warning(f"Rejecting {task.task_type} task: Task already in progress")
356 call.reject(response=response)
357 return
359 if type(task) not in self._task_handlers:
360 logger.warning(f"Rejecting {task.task_type} task: No handler registered")
361 call.reject(response=response)
362 return
364 call.accept(response=response)
365 aio_task: asyncio.Task | None = None
367 try:
368 # Interrupt the current task, if there is one.
369 if self._task_asyncio:
370 logger.info(f"Interrupting {self._state.execution_state.execution.task.task_type} task")
371 self._task_asyncio.cancel("Interrupted")
373 with contextlib.suppress(asyncio.CancelledError):
374 await call.progress_from_task(
375 self._task_asyncio,
376 cadence=6.0,
377 ttl=10.0,
378 )
380 # Execute the task.
381 start_time = datetime.now(UTC)
383 with self.enter_context():
384 logger.info(f"Executing {task.task_type} task")
385 aio_task = asyncio.create_task(self._execute_task(execution))
387 self._task_asyncio = aio_task
389 await call.progress_from_task(
390 self._task_asyncio,
391 cadence=6.0,
392 ttl=10.0,
393 )
395 end_time = datetime.now(UTC)
396 except asyncio.CancelledError:
397 with self.enter_context():
398 logger.warning(f"Execution of {task.task_type} task cancelled")
400 try:
401 await call.fail()
402 except Exception as e:
403 logger.warning(f"Error logging cancelled task event ({type(e).__name__})")
405 raise
406 except Exception as e:
407 with self.enter_context():
408 logger.error(f"Error executing {task.task_type} task")
409 logger.opt(exception=e).debug(f"{task.task_type} ({execution.task_id}) failed")
411 try:
412 await call.fail()
413 except Exception:
414 logger.warning(f"Error logging failed task event ({type(e).__name__})")
415 else:
416 with self.enter_context():
417 logger.info(f"Finished {task.task_type} task")
419 await call.succeed(
420 result=TaskExecutionResult(
421 task_id=execution.task_id,
422 start_time=start_time,
423 end_time=end_time,
424 )
425 )
426 finally:
427 # Clear the task object only if it wasn't overwritten in the meantime.
428 if self._task_asyncio is aio_task:
429 self._task_asyncio = None
431 if not aio_task.done():
432 aio_task.cancel()
434 with contextlib.suppress(asyncio.CancelledError, Exception):
435 await aio_task
437 async def _execute_task(self, execution: TaskExecution):
438 # Associate the envelope with the task so the handler can reach it via `task.execution`.
439 task = execution.task
440 task.associate_execution(execution)
441 logger.debug(f"execution begun {execution=}")
442 finish_info = TaskFinishInfo(aborted=True)
444 # Emit the execution start event, seeding the executing state's context from the
445 # incoming execution context. This becomes the base layer that `update_context`
446 # merges device snapshots onto and that is ultimately sent to downstream cameras.
447 await self._state.update(
448 self,
449 TaskExecutionState(executing=True, execution=execution, context=execution.context),
450 self._state.operating_state.derive(target=task.target_state()),
451 )
453 try:
454 # Find the user-supplied task handler callback for this task type.
455 handler = self._task_handlers.get(type(task))
456 assert handler
458 # Run the task handler.
459 await handler(task)
460 except Exception as e:
461 finish_info = TaskFinishInfo(error=str(e))
462 raise
463 else:
464 finish_info = TaskFinishInfo()
465 finally:
466 try:
467 await self._state.update(
468 self,
469 TaskExecutionState(finished=finish_info, execution=execution),
470 self._state.operating_state.derive(
471 current=ts if (ts := task.target_state()) is not None else ControllerOperatingState.NO_CHANGE,
472 target=None,
473 ),
474 )
475 except Exception as e:
476 logger.warning(f"Error logging final task execution state ({type(e).__name__})")
478 @override
479 def task_handler[T: Task](self, task_model: type[T]):
480 def decorator(func: TaskHandlerCallback[T]):
481 if task_model in self._task_handlers:
482 raise RuntimeError("multiple handlers for task type")
484 self._task_handlers[task_model] = func
485 return func
487 return decorator
489 @override
490 def entity_info(self) -> EntityInfo:
491 return EntityInfo(
492 entity_type="controller",
493 details=ControllerDetails(
494 supported_tasks=[t.__name__ for t in self._task_handlers.keys()],
495 controlled_devices=list(self._devices.keys()),
496 ),
497 )
499 @override
500 async def set_internal_state(self, state: InternalControllerState):
501 await self._state.update(self, self._state.operating_state.derive(current=state))