pyobs-core 2.0.0.dev1__py3-none-any.whl → 2.0.0.dev4__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.
Files changed (73) hide show
  1. pyobs/application.py +11 -2
  2. pyobs/comm/comm.py +88 -4
  3. pyobs/comm/commlogging.py +5 -2
  4. pyobs/comm/local/localcomm.py +102 -43
  5. pyobs/comm/proxy.py +25 -0
  6. pyobs/comm/xmpp/rpc.py +169 -140
  7. pyobs/comm/xmpp/serializer.py +338 -0
  8. pyobs/comm/xmpp/xep_0009_timeout/rpc.py +6 -19
  9. pyobs/comm/xmpp/xmppcomm.py +230 -98
  10. pyobs/events/log.py +18 -2
  11. pyobs/interfaces/IBinning.py +16 -19
  12. pyobs/interfaces/IConfig.py +6 -24
  13. pyobs/interfaces/ICooling.py +5 -14
  14. pyobs/interfaces/IExposure.py +17 -21
  15. pyobs/interfaces/IExposureTime.py +13 -19
  16. pyobs/interfaces/IFilters.py +13 -17
  17. pyobs/interfaces/IFocuser.py +14 -19
  18. pyobs/interfaces/IGain.py +14 -19
  19. pyobs/interfaces/IImageFormat.py +15 -19
  20. pyobs/interfaces/IImageType.py +13 -10
  21. pyobs/interfaces/IMode.py +13 -32
  22. pyobs/interfaces/IModule.py +7 -21
  23. pyobs/interfaces/IMotion.py +18 -13
  24. pyobs/interfaces/IMultiFiber.py +22 -47
  25. pyobs/interfaces/IOffsetsAltAz.py +12 -12
  26. pyobs/interfaces/IOffsetsRaDec.py +12 -12
  27. pyobs/interfaces/IPointingAltAz.py +12 -10
  28. pyobs/interfaces/IPointingHGS.py +12 -12
  29. pyobs/interfaces/IPointingHelioprojective.py +12 -12
  30. pyobs/interfaces/IPointingRaDec.py +12 -10
  31. pyobs/interfaces/IReady.py +13 -11
  32. pyobs/interfaces/IRotation.py +13 -6
  33. pyobs/interfaces/IRunning.py +13 -1
  34. pyobs/interfaces/ITemperatures.py +18 -11
  35. pyobs/interfaces/IVideo.py +7 -10
  36. pyobs/interfaces/IWindow.py +16 -17
  37. pyobs/interfaces/__init__.py +48 -24
  38. pyobs/mixins/fitsheader.py +5 -2
  39. pyobs/mixins/motionstatus.py +9 -12
  40. pyobs/mixins/waitformotion.py +6 -3
  41. pyobs/mixins/weatheraware.py +1 -1
  42. pyobs/modules/camera/basecamera.py +36 -38
  43. pyobs/modules/camera/basespectrograph.py +0 -8
  44. pyobs/modules/camera/basevideo.py +5 -21
  45. pyobs/modules/camera/dummycamera.py +232 -199
  46. pyobs/modules/camera/dummyspectrograph.py +0 -11
  47. pyobs/modules/camera/dummyvideo.py +69 -0
  48. pyobs/modules/camera/pipelinecamera.py +1 -10
  49. pyobs/modules/flatfield/flatfield.py +19 -24
  50. pyobs/modules/module.py +49 -4
  51. pyobs/modules/pointing/autoguiding.py +2 -12
  52. pyobs/modules/pointing/scienceframeguiding.py +0 -16
  53. pyobs/modules/roof/baseroof.py +8 -11
  54. pyobs/modules/roof/dummyroof.py +3 -0
  55. pyobs/modules/telescope/basetelescope.py +16 -27
  56. pyobs/modules/telescope/dummytelescope.py +181 -215
  57. pyobs/modules/utils/dummymode.py +12 -35
  58. pyobs/robotic/scripts/control/selector.py +2 -1
  59. pyobs/robotic/scripts/utils/callmodule.py +39 -17
  60. pyobs/utils/parallel.py +15 -3
  61. {pyobs_core-2.0.0.dev1.dist-info → pyobs_core-2.0.0.dev4.dist-info}/METADATA +1 -1
  62. {pyobs_core-2.0.0.dev1.dist-info → pyobs_core-2.0.0.dev4.dist-info}/RECORD +65 -71
  63. pyobs/comm/dbus/__init__.py +0 -3
  64. pyobs/comm/dbus/dbuscomm.py +0 -591
  65. pyobs/comm/dbus/patch.py +0 -112
  66. pyobs/interfaces/ILatLon.py +0 -41
  67. pyobs/utils/simulation/__init__.py +0 -11
  68. pyobs/utils/simulation/camera.py +0 -284
  69. pyobs/utils/simulation/telescope.py +0 -200
  70. pyobs/utils/simulation/world.py +0 -107
  71. {pyobs_core-2.0.0.dev1.dist-info → pyobs_core-2.0.0.dev4.dist-info}/WHEEL +0 -0
  72. {pyobs_core-2.0.0.dev1.dist-info → pyobs_core-2.0.0.dev4.dist-info}/entry_points.txt +0 -0
  73. {pyobs_core-2.0.0.dev1.dist-info → pyobs_core-2.0.0.dev4.dist-info}/licenses/LICENSE +0 -0
pyobs/application.py CHANGED
@@ -140,8 +140,17 @@ class Application:
140
140
  class_name = cfg["class"]
141
141
  klass = get_class_from_string(class_name)
142
142
 
143
- # create event loop
144
- self._loop = klass.new_event_loop()
143
+ # create event loop — if top-level class doesn't override new_event_loop,
144
+ # check child modules for one that does (e.g. pyobs_gui.GUI in a MultiModule)
145
+ loop_class = klass
146
+ if klass.new_event_loop is Module.new_event_loop:
147
+ for mod_cfg in cfg.get("modules", {}).values():
148
+ if isinstance(mod_cfg, dict) and "class" in mod_cfg:
149
+ child_klass = get_class_from_string(mod_cfg["class"])
150
+ if child_klass.new_event_loop is not Module.new_event_loop:
151
+ loop_class = child_klass
152
+ break
153
+ self._loop = loop_class.new_event_loop()
145
154
  asyncio.set_event_loop(self._loop)
146
155
 
147
156
  # create module and open it
pyobs/comm/comm.py CHANGED
@@ -4,12 +4,14 @@ import asyncio
4
4
  import functools
5
5
  import inspect
6
6
  import logging
7
+ import sys
7
8
  from collections.abc import Callable, Coroutine
8
9
  from typing import TYPE_CHECKING, Any, overload
9
10
 
10
11
  import pyobs.interfaces
11
12
  from pyobs.events import Event, LogEvent, ModuleClosedEvent
12
13
  from pyobs.interfaces import Interface
14
+ from pyobs.utils.enums import ModuleState
13
15
 
14
16
  from .commlogging import CommLoggingHandler
15
17
  from .proxy import Proxy, ProxyType, _ProxyContext
@@ -18,6 +20,7 @@ if TYPE_CHECKING:
18
20
  from pyobs.modules import Module
19
21
 
20
22
  StateCallback = Callable[[Any], None]
23
+ PresenceCallback = Callable[["ModuleState", str], None]
21
24
 
22
25
  log = logging.getLogger(__name__)
23
26
 
@@ -64,9 +67,14 @@ class Comm:
64
67
  """Open module."""
65
68
 
66
69
  # add handler to global logger
67
- handler = CommLoggingHandler(self)
68
- handler.setLevel(logging.INFO)
69
- logging.getLogger().addHandler(handler)
70
+ root_logger = logging.getLogger()
71
+ if not any(isinstance(h, CommLoggingHandler) for h in root_logger.handlers):
72
+ from pyobs.utils.logging.context import ModuleNameFilter
73
+
74
+ handler = CommLoggingHandler(self)
75
+ handler.setLevel(logging.INFO)
76
+ handler.addFilter(ModuleNameFilter())
77
+ root_logger.addHandler(handler)
70
78
 
71
79
  # start logging thread
72
80
  self._logging_task = asyncio.create_task(self._logging())
@@ -129,7 +137,7 @@ class Comm:
129
137
 
130
138
  # subscribe to state
131
139
  for interface in interfaces:
132
- if getattr(interface, "state", None) is not None:
140
+ if getattr(interface, "State", None) is not None:
133
141
  await self.subscribe_state(client, interface, functools.partial(proxy.update_state, interface))
134
142
 
135
143
  self._proxies[client] = proxy
@@ -442,6 +450,68 @@ class Comm:
442
450
  async def _set_state(self, interface: type[Interface], state: Any) -> None:
443
451
  pass
444
452
 
453
+ @staticmethod
454
+ def _interface_from_capabilities(caps_cls: type) -> type:
455
+ outer_name = caps_cls.__qualname__.rsplit(".", 1)[0]
456
+ return getattr(sys.modules[caps_cls.__module__], outer_name)
457
+
458
+ async def set_capabilities(self, capabilities: Any) -> None:
459
+ """Publish capabilities for this module.
460
+
461
+ Called by Module.open() for each interface that defines a Capabilities
462
+ dataclass. Not intended to be called directly by module authors after
463
+ that point — capabilities are fixed for the module lifetime.
464
+
465
+ Args:
466
+ capabilities: Capabilities dataclass instance.
467
+ """
468
+ interface = Comm._interface_from_capabilities(type(capabilities))
469
+ await self._set_capabilities(interface, capabilities)
470
+
471
+ async def _set_capabilities(self, interface: type[Interface], capabilities: Any) -> None:
472
+ pass
473
+
474
+ async def get_capabilities(self, module: str, interface: type[Interface]) -> Any | None:
475
+ """Fetch and deserialize capabilities for a remote module's interface.
476
+
477
+ Args:
478
+ module: Module name (e.g. "camera").
479
+ interface: Interface class whose Capabilities dataclass to fetch.
480
+
481
+ Returns:
482
+ Deserialized Capabilities dataclass instance, or None if not published.
483
+ """
484
+ return await self._get_capabilities(module, interface)
485
+
486
+ async def _get_capabilities(self, module: str, interface: type[Interface]) -> Any | None:
487
+ return None
488
+
489
+ async def set_presence(self, state: ModuleState, error_string: str = "") -> None:
490
+ """Publish presence for this module (module lifecycle state).
491
+
492
+ Called automatically by Module.set_state — not intended to be called
493
+ directly by module authors.
494
+
495
+ Args:
496
+ state: Current module lifecycle state.
497
+ error_string: Error message, used when state is ERROR.
498
+ """
499
+ await self._set_presence(state, error_string)
500
+
501
+ async def _set_presence(self, state: ModuleState, error_string: str = "") -> None:
502
+ pass
503
+
504
+ def get_client_state(self, module: str) -> tuple[ModuleState, str] | None:
505
+ """Return the last known presence state of a connected module.
506
+
507
+ Returns (ModuleState, error_string) or None if the module is not connected.
508
+ This replaces the old get_state()/get_error_string() RPC pattern.
509
+ """
510
+ return self._get_client_state(module)
511
+
512
+ def _get_client_state(self, module: str) -> tuple[ModuleState, str] | None:
513
+ return None
514
+
445
515
  async def subscribe_state(
446
516
  self,
447
517
  module: str,
@@ -491,6 +561,20 @@ class Comm:
491
561
  ) -> None:
492
562
  pass
493
563
 
564
+ async def subscribe_presence(self, module: str, callback: PresenceCallback) -> None:
565
+ """Subscribe to presence updates for a given module.
566
+
567
+ Delivers the current value immediately, then on every change.
568
+
569
+ Args:
570
+ module: Name of remote module.
571
+ callback: Called with (ModuleState, error_string) on each update.
572
+ """
573
+ await self._subscribe_presence(module, callback)
574
+
575
+ async def _subscribe_presence(self, module: str, callback: PresenceCallback) -> None:
576
+ pass
577
+
494
578
  def _send_event_to_module(self, event: Event, from_client: str) -> None:
495
579
  """Send an event to all connected modules.
496
580
 
pyobs/comm/commlogging.py CHANGED
@@ -34,9 +34,12 @@ class CommLoggingHandler(logging.Handler):
34
34
  # format message
35
35
  msg = self._formatter.format(rec) # noqa: UP031
36
36
 
37
- # create and send event
37
+ # create and send event — use pyobs_module if set (MultiModule context)
38
38
  time = datetime.fromtimestamp(rec.created, tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")
39
- entry = LogEvent(time, rec.levelname, os.path.basename(rec.pathname), rec.funcName, rec.lineno, msg)
39
+ sender = getattr(rec, "pyobs_module", "") or ""
40
+ entry = LogEvent(
41
+ time, rec.levelname, os.path.basename(rec.pathname), rec.funcName, rec.lineno, msg, sender=sender
42
+ )
40
43
  self._comm.log_message(entry)
41
44
 
42
45
 
@@ -1,9 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Callable, Coroutine
2
4
  from typing import Any
3
5
 
4
6
  from pyobs.comm import Comm
7
+ from pyobs.comm.comm import PresenceCallback
5
8
  from pyobs.events import Event
6
9
  from pyobs.interfaces import Interface
10
+ from pyobs.utils.enums import ModuleState
7
11
  from pyobs.utils.types import cast_response_to_real
8
12
 
9
13
  from .localnetwork import LocalNetwork
@@ -17,6 +21,13 @@ class LocalComm(Comm):
17
21
  self._network = LocalNetwork()
18
22
  self._network.connect_client(self)
19
23
 
24
+ # in-memory state and capabilities storage
25
+ self._states: dict[str, Any] = {} # node -> state object
26
+ self._state_handlers: dict[str, list[tuple[type[Interface], Callable[[Any], None]]]] = {}
27
+ self._capabilities: dict[type[Interface], Any] = {} # interface -> Capabilities object
28
+ self._presence: tuple[ModuleState, str] = (ModuleState.READY, "")
29
+ self._presence_callbacks: dict[str, list[PresenceCallback]] = {}
30
+
20
31
  @property
21
32
  def name(self) -> str:
22
33
  """Name of this client."""
@@ -24,54 +35,21 @@ class LocalComm(Comm):
24
35
 
25
36
  @property
26
37
  def clients(self) -> list[str]:
27
- """Returns list of currently connected clients.
28
-
29
- Returns:
30
- (list) List of currently connected clients.
31
- """
38
+ """Returns list of currently connected clients."""
32
39
  return self._network.get_client_names()
33
40
 
34
41
  async def get_interfaces(self, client: str) -> list[type[Interface]]:
35
- """Returns list of interfaces for given client.
36
-
37
- Args:
38
- client: Name of client.
39
-
40
- Returns:
41
- List of supported interfaces.
42
-
43
- Raises:
44
- IndexError: If client cannot be found.
45
- """
42
+ """Returns list of interfaces for given client."""
46
43
  remote_client = self._network.get_client(client)
47
44
  return [] if not remote_client.has_module else remote_client.module.interfaces
48
45
 
49
46
  async def _supports_interface(self, client: str, interface: type[Interface]) -> bool:
50
- """Checks, whether the given client supports the given interface.
51
-
52
- Args:
53
- client: Client to check.
54
- interface: Interface to check.
55
-
56
- Returns:
57
- Whether interface is supported.
58
- """
47
+ """Checks whether the given client supports the given interface."""
59
48
  interfaces = await self.get_interfaces(client)
60
49
  return interface in interfaces
61
50
 
62
51
  async def execute(self, client: str, method: str, annotation: dict[str, Any], *args: Any) -> Any:
63
- """Execute a given method on a remote client.
64
-
65
- Args:
66
- client (str): ID of client.
67
- method (str): Method to call.
68
- annotation: Method annotation.
69
- *args: List of parameters for given method.
70
-
71
- Returns:
72
- Passes through return from method call.
73
- """
74
-
52
+ """Execute a given method on a remote client."""
75
53
  remote_client = self._network.get_client(client)
76
54
  if remote_client.module is None:
77
55
  raise ValueError
@@ -82,17 +60,98 @@ class LocalComm(Comm):
82
60
  return real_results
83
61
 
84
62
  async def send_event(self, event: Event) -> None:
85
- """Send an event to other clients.
86
-
87
- Args:
88
- event (Event): Event to send
89
- """
63
+ """Send an event to other clients."""
64
+ from pyobs.events import LogEvent as _LogEvent
90
65
 
66
+ sender = self.name
67
+ if isinstance(event, _LogEvent) and event.sender:
68
+ sender = event.sender
91
69
  remote_clients = self._network.get_clients()
92
70
  for client in remote_clients:
93
- client._send_event_to_module(event, self.name)
71
+ client._send_event_to_module(event, sender)
94
72
 
95
73
  async def _register_events(
96
74
  self, events: list[type[Event]], handler: Callable[[Event, str], Coroutine[Any, Any, bool]] | None = None
97
75
  ) -> None:
98
76
  pass
77
+
78
+ # -------------------------------------------------------------------------
79
+ # State
80
+ # -------------------------------------------------------------------------
81
+
82
+ async def _set_state(self, interface: type[Interface], state: Any) -> None:
83
+ """Publish state locally and dispatch to subscribers."""
84
+ node = f"pyobs:state:{self._name}:{interface.__name__}:{interface.version}"
85
+ self._states[node] = state
86
+
87
+ # dispatch to all subscribers for this node
88
+ if node in self._state_handlers:
89
+ for _, callback in self._state_handlers[node]:
90
+ callback(state)
91
+
92
+ async def _subscribe_state(self, module: str, interface: type[Interface], callback: Callable[[Any], None]) -> None:
93
+ """Subscribe to state updates from a remote module."""
94
+ node = f"pyobs:state:{module}:{interface.__name__}:{interface.version}"
95
+
96
+ if node not in self._state_handlers:
97
+ self._state_handlers[node] = []
98
+ self._state_handlers[node].append((interface, callback))
99
+
100
+ # also register on the remote comm so it can dispatch to us
101
+ try:
102
+ remote = self._network.get_client(module)
103
+ if node not in remote._state_handlers:
104
+ remote._state_handlers[node] = []
105
+ remote._state_handlers[node].append((interface, callback))
106
+
107
+ # deliver current value immediately if available
108
+ if node in remote._states:
109
+ callback(remote._states[node])
110
+ except KeyError:
111
+ pass # remote not connected yet
112
+
113
+ # -------------------------------------------------------------------------
114
+ # Capabilities
115
+ # -------------------------------------------------------------------------
116
+
117
+ async def _set_capabilities(self, interface: type[Interface], capabilities: Any) -> None:
118
+ """Store capabilities locally."""
119
+ self._capabilities[interface] = capabilities
120
+
121
+ async def _get_capabilities(self, module: str, interface: type[Interface]) -> Any | None:
122
+ """Fetch capabilities from a remote module."""
123
+ if not hasattr(interface, "Capabilities"):
124
+ return None
125
+ try:
126
+ remote = self._network.get_client(module)
127
+ return remote._capabilities.get(interface)
128
+ except KeyError:
129
+ return None
130
+
131
+ # -------------------------------------------------------------------------
132
+ # Presence
133
+ # -------------------------------------------------------------------------
134
+
135
+ async def _set_presence(self, state: ModuleState, error_string: str = "") -> None:
136
+ """Store presence state and dispatch to all subscribers."""
137
+ self._presence = (state, error_string)
138
+ for client in self._network.get_clients():
139
+ for cb in client._presence_callbacks.get(self._name, []):
140
+ cb(state, error_string)
141
+
142
+ def _get_client_state(self, module: str) -> tuple[ModuleState, str] | None:
143
+ """Return presence state of a connected module."""
144
+ try:
145
+ remote = self._network.get_client(module)
146
+ return remote._presence
147
+ except KeyError:
148
+ return None
149
+
150
+ async def _subscribe_presence(self, module: str, callback: PresenceCallback) -> None:
151
+ """Register a presence callback and deliver the current state immediately."""
152
+ self._presence_callbacks.setdefault(module, []).append(callback)
153
+ try:
154
+ remote = self._network.get_client(module)
155
+ callback(*remote._presence)
156
+ except KeyError:
157
+ pass
pyobs/comm/proxy.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import inspect
4
5
  import types
5
6
  from collections.abc import Coroutine
@@ -169,6 +170,30 @@ class Proxy:
169
170
  """Latest known state for the given interface, or None if nothing has arrived yet."""
170
171
  return self._state.get(interface)
171
172
 
173
+ async def wait_for_state(
174
+ self,
175
+ interface: type[Interface],
176
+ timeout: float = 10.0,
177
+ ) -> Any:
178
+ """Return state immediately if available, otherwise wait for the first update."""
179
+ if self._state.get(interface) is not None:
180
+ return self._state[interface]
181
+
182
+ event = asyncio.Event()
183
+
184
+ def _notify(state: Any) -> None:
185
+ self._state[interface] = state
186
+ event.set()
187
+
188
+ # patch in a one-shot notifier alongside the existing callback
189
+ await self._comm.subscribe_state(self._client, interface, _notify)
190
+ try:
191
+ await asyncio.wait_for(event.wait(), timeout=timeout)
192
+ finally:
193
+ await self._comm.unsubscribe_state(self._client, interface, _notify)
194
+
195
+ return self._state.get(interface)
196
+
172
197
 
173
198
  class _ProxyContext(Generic[ProxyType]):
174
199
  """Returned by Comm.proxy() / Object.proxy() / Comm.safe_proxy(). Must be used as: