pyobs-core 2.0.0.dev9__py3-none-any.whl → 2.0.0.dev11__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.
- pyobs/application.py +5 -0
- pyobs/cli/_cli.py +14 -5
- pyobs/cli/pyobsd.py +12 -0
- pyobs/comm/comm.py +40 -52
- pyobs/comm/local/localcomm.py +1 -6
- pyobs/comm/proxy.py +0 -6
- pyobs/comm/xmpp/rpc.py +10 -5
- pyobs/comm/xmpp/serializer.py +181 -1
- pyobs/comm/xmpp/xep_0009/rpc.py +10 -2
- pyobs/comm/xmpp/xmppclient.py +36 -0
- pyobs/comm/xmpp/xmppcomm.py +138 -70
- pyobs/events/testevent.py +1 -0
- pyobs/images/image.py +2 -1
- pyobs/images/processors/astrometry/_dotnet_response_saver.py +6 -2
- pyobs/images/processors/detection/_source_catalog.py +2 -2
- pyobs/images/processors/photometry/_sep_aperture_photometry.py +6 -1
- pyobs/interfaces/IAcquisition.py +20 -4
- pyobs/interfaces/IAutoFocus.py +28 -17
- pyobs/interfaces/IBinning.py +8 -2
- pyobs/interfaces/IConfig.py +6 -3
- pyobs/interfaces/IFitsHeaderAfter.py +2 -1
- pyobs/interfaces/IFitsHeaderBefore.py +9 -2
- pyobs/interfaces/IFlatField.py +4 -3
- pyobs/interfaces/IFocusModel.py +9 -4
- pyobs/interfaces/IFocuser.py +8 -7
- pyobs/interfaces/IModule.py +5 -0
- pyobs/interfaces/IWeather.py +21 -22
- pyobs/interfaces/__init__.py +18 -7
- pyobs/interfaces/interface.py +9 -0
- pyobs/mixins/fitsheader.py +2 -7
- pyobs/mixins/fitsnamespace.py +4 -2
- pyobs/mixins/weatheraware.py +2 -1
- pyobs/modules/camera/basevideo.py +5 -1
- pyobs/modules/camera/dummycamera.py +4 -2
- pyobs/modules/flatfield/flatfield.py +1 -1
- pyobs/modules/focus/focusmodel.py +15 -19
- pyobs/modules/focus/focusseries.py +14 -18
- pyobs/modules/image/imagewatcher.py +5 -2
- pyobs/modules/module.py +129 -19
- pyobs/modules/pointing/_baseguiding.py +5 -5
- pyobs/modules/pointing/acquisition.py +21 -12
- pyobs/modules/pointing/dummyacquisition.py +13 -15
- pyobs/modules/pointing/guidingstatistics/guidingstatistics.py +4 -2
- pyobs/modules/pointing/guidingstatistics/pixeloffset.py +5 -5
- pyobs/modules/pointing/guidingstatistics/skyoffsets.py +4 -4
- pyobs/modules/pointing/guidingstatistics/uptime.py +3 -3
- pyobs/modules/robotic/mastermind.py +4 -4
- pyobs/modules/robotic/pointing.py +3 -27
- pyobs/modules/roof/basedome.py +3 -3
- pyobs/modules/roof/baseroof.py +3 -3
- pyobs/modules/telescope/basetelescope.py +22 -15
- pyobs/modules/telescope/dummytelescope.py +21 -10
- pyobs/modules/weather/weather.py +46 -33
- pyobs/modules/weather/weather_state.py +1 -1
- pyobs/robotic/scripts/calibration/skyflats.py +1 -1
- pyobs/robotic/scripts/control/cases.py +3 -2
- pyobs/robotic/scripts/control/conditional.py +3 -2
- pyobs/robotic/scripts/imaging/imaging.py +4 -3
- pyobs/robotic/scripts/script.py +3 -2
- pyobs/robotic/storage/lco/scripts/default.py +4 -3
- pyobs/robotic/storage/lco/task.py +2 -1
- pyobs/robotic/task.py +2 -1
- pyobs/utils/enums.py +3 -0
- pyobs/utils/exceptions.py +9 -0
- pyobs/utils/focusseries/base.py +5 -0
- pyobs/utils/focusseries/photometry.py +5 -0
- pyobs/utils/focusseries/projection.py +5 -0
- pyobs/utils/gui/camera/datadisplaywidget.py +2 -2
- pyobs/utils/parallel.py +2 -17
- pyobs/utils/units.py +48 -0
- {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/METADATA +4 -4
- {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/RECORD +75 -75
- pyobs/utils/types.py +0 -242
- {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/WHEEL +0 -0
- {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/entry_points.txt +0 -0
- {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/licenses/LICENSE +0 -0
pyobs/application.py
CHANGED
|
@@ -114,6 +114,11 @@ class Application:
|
|
|
114
114
|
class PyobsJournaldLogHandler(JournaldLogHandler):
|
|
115
115
|
"""JournaldLogHandler that adds SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE per record."""
|
|
116
116
|
|
|
117
|
+
# logging.CRITICAL and logging.FATAL are both 50, so the upstream LEVELS dict
|
|
118
|
+
# literal collides and silently maps level 50 to FATAL's priority (0, "emerg")
|
|
119
|
+
# instead of CRITICAL's (2, "crit"). Override explicitly to restore priority 2.
|
|
120
|
+
LEVELS = {**JournaldLogHandler.LEVELS, logging.CRITICAL: 2}
|
|
121
|
+
|
|
117
122
|
def __init__(self, **kw: Any) -> None:
|
|
118
123
|
super().__init__(identifier="pyobs", **kw)
|
|
119
124
|
|
pyobs/cli/_cli.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
import yaml
|
|
6
7
|
|
|
8
|
+
log = logging.getLogger("pyobs")
|
|
9
|
+
|
|
7
10
|
|
|
8
11
|
class CLI:
|
|
9
12
|
# name of section in configuration file
|
|
@@ -42,11 +45,17 @@ class CLI:
|
|
|
42
45
|
|
|
43
46
|
def _load_config(self) -> None:
|
|
44
47
|
"""Load config from config file"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
candidates = [
|
|
49
|
+
os.path.expanduser(os.path.join("~", ".config", "pyobs.yaml")),
|
|
50
|
+
os.path.join("/", "etc", "pyobs.yaml"),
|
|
51
|
+
os.path.join("/", "opt", "pyobs", "storage", "pyobs.yaml"),
|
|
52
|
+
]
|
|
53
|
+
for config_file in candidates:
|
|
54
|
+
if os.path.exists(config_file):
|
|
55
|
+
break
|
|
56
|
+
else:
|
|
57
|
+
return
|
|
58
|
+
log.info("Using config file %s.", config_file)
|
|
50
59
|
with open(config_file) as f:
|
|
51
60
|
cfg = yaml.safe_load(f)
|
|
52
61
|
if cfg is None or self.CONFIG_SECTION not in cfg:
|
pyobs/cli/pyobsd.py
CHANGED
|
@@ -31,6 +31,7 @@ class PyobsDaemonCLI(CLI):
|
|
|
31
31
|
"run_path",
|
|
32
32
|
"log_path",
|
|
33
33
|
"log_level",
|
|
34
|
+
"syslog",
|
|
34
35
|
"chuid",
|
|
35
36
|
]
|
|
36
37
|
|
|
@@ -46,6 +47,12 @@ class PyobsDaemonCLI(CLI):
|
|
|
46
47
|
choices=["critical", "error", "warning", "info", "debug"],
|
|
47
48
|
default=self._config.get("log-level", "info"),
|
|
48
49
|
)
|
|
50
|
+
self._parser.add_argument(
|
|
51
|
+
"--syslog",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="send module logs to systemd journal",
|
|
54
|
+
default=self._config.get("syslog", False),
|
|
55
|
+
)
|
|
49
56
|
self._parser.add_argument("--chuid", type=str, default=self._config.get("chuid", "pyobs:pyobs"))
|
|
50
57
|
self._parser.add_argument("-v", "--verbose", action="store_true")
|
|
51
58
|
|
|
@@ -64,6 +71,7 @@ class PyobsDaemonCLI(CLI):
|
|
|
64
71
|
str(os.path.join(self._config["path"], self._config["run_path"])),
|
|
65
72
|
str(os.path.join(self._config["path"], self._config["log_path"])),
|
|
66
73
|
log_level=self._config["log_level"],
|
|
74
|
+
syslog=self._config["syslog"],
|
|
67
75
|
chuid=self._config["chuid"],
|
|
68
76
|
verbose=self._config["verbose"],
|
|
69
77
|
)
|
|
@@ -89,6 +97,7 @@ class PyobsDaemon:
|
|
|
89
97
|
run_path: str,
|
|
90
98
|
log_path: str,
|
|
91
99
|
log_level: str = "info",
|
|
100
|
+
syslog: bool = False,
|
|
92
101
|
chuid: str | None = None,
|
|
93
102
|
verbose: bool = False,
|
|
94
103
|
**kwargs: Any,
|
|
@@ -97,6 +106,7 @@ class PyobsDaemon:
|
|
|
97
106
|
self._run_path = run_path
|
|
98
107
|
self._log_path = log_path
|
|
99
108
|
self._log_level = log_level
|
|
109
|
+
self._syslog = syslog
|
|
100
110
|
self._verbose = verbose
|
|
101
111
|
|
|
102
112
|
# parse optional user/group from chuid (format: "user:group" or "user")
|
|
@@ -298,6 +308,8 @@ class PyobsDaemon:
|
|
|
298
308
|
self._log_level,
|
|
299
309
|
self._config_file(module),
|
|
300
310
|
]
|
|
311
|
+
if self._syslog:
|
|
312
|
+
cmd.append("--syslog")
|
|
301
313
|
|
|
302
314
|
if self._verbose:
|
|
303
315
|
print(f"[DEBUG] Executing: {' '.join(cmd)}")
|
pyobs/comm/comm.py
CHANGED
|
@@ -34,10 +34,12 @@ class Comm:
|
|
|
34
34
|
|
|
35
35
|
self._proxies: dict[str, Proxy] = {}
|
|
36
36
|
self._state_subscriptions: dict[str, list[tuple[type[Interface], StateCallback]]] = {}
|
|
37
|
+
self._presence_subscriptions: dict[str, list[PresenceCallback]] = {}
|
|
37
38
|
self._module: Module | None = None
|
|
38
39
|
self._log_queue: asyncio.Queue[LogEvent] = asyncio.Queue()
|
|
39
40
|
self._logging_task: asyncio.Task[Any] | None = None
|
|
40
41
|
self._event_handlers: dict[type[Event], list[Callable[[Event, str], Coroutine[Any, Any, bool]]]] = {}
|
|
42
|
+
self._registered_events: set[type[Event]] = set()
|
|
41
43
|
self._closing = asyncio.Event()
|
|
42
44
|
|
|
43
45
|
@property
|
|
@@ -144,7 +146,7 @@ class Comm:
|
|
|
144
146
|
|
|
145
147
|
# subscribe to state
|
|
146
148
|
for interface in interfaces:
|
|
147
|
-
if interface.
|
|
149
|
+
if interface.has_own_state():
|
|
148
150
|
await self.subscribe_state(client, interface, functools.partial(proxy.update_state, interface))
|
|
149
151
|
|
|
150
152
|
self._proxies[client] = proxy
|
|
@@ -193,14 +195,25 @@ class Comm:
|
|
|
193
195
|
elif obj_type is None or isinstance(proxy, obj_type):
|
|
194
196
|
return proxy
|
|
195
197
|
else:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
message = f'Proxy obtained from given name "{name_or_object}" is not of requested type "{obj_type}".'
|
|
199
|
+
hint = self._diagnose_missing_interface(name_or_object, obj_type)
|
|
200
|
+
if hint is not None:
|
|
201
|
+
message += f" {hint}"
|
|
202
|
+
raise ValueError(message)
|
|
199
203
|
|
|
200
204
|
else:
|
|
201
205
|
# completely wrong...
|
|
202
206
|
raise ValueError(f'Given parameter is neither a name nor an object of requested type "{obj_type}".')
|
|
203
207
|
|
|
208
|
+
def _diagnose_missing_interface(self, client: str, obj_type: type[Any]) -> str | None:
|
|
209
|
+
"""Backend hook, called when a proxy exists but doesn't implement obj_type.
|
|
210
|
+
|
|
211
|
+
Backends that can tell "genuinely missing" apart from "present at an
|
|
212
|
+
incompatible version" using data already in hand return a hint string
|
|
213
|
+
to append to the ValueError; other backends just return None.
|
|
214
|
+
"""
|
|
215
|
+
return None
|
|
216
|
+
|
|
204
217
|
async def _safe_resolve_proxy(
|
|
205
218
|
self, name_or_object: str | object, obj_type: type[ProxyType] | None = None
|
|
206
219
|
) -> Any | ProxyType | None:
|
|
@@ -254,6 +267,12 @@ class Comm:
|
|
|
254
267
|
for interface, callback in self._state_subscriptions.pop(sender, []):
|
|
255
268
|
await self.unsubscribe_state(sender, interface, callback)
|
|
256
269
|
|
|
270
|
+
# tear down any presence subscriptions held for that client -- otherwise a stale
|
|
271
|
+
# callback (e.g. bound to a since-destroyed GUI widget) lingers and can blow up
|
|
272
|
+
# the next presence update for this client, silently swallowing its reconnect
|
|
273
|
+
for callback in self._presence_subscriptions.pop(sender, []):
|
|
274
|
+
await self.unsubscribe_presence(sender, callback)
|
|
275
|
+
|
|
257
276
|
return True
|
|
258
277
|
|
|
259
278
|
@property
|
|
@@ -423,6 +442,7 @@ class Comm:
|
|
|
423
442
|
|
|
424
443
|
# we also want to register all events derived from the given one
|
|
425
444
|
event_classes = self._get_derived_events(event_class)
|
|
445
|
+
self._registered_events.update(event_classes)
|
|
426
446
|
|
|
427
447
|
# do we have a handler?
|
|
428
448
|
if handler:
|
|
@@ -579,11 +599,27 @@ class Comm:
|
|
|
579
599
|
module: Name of remote module.
|
|
580
600
|
callback: Called with (ModuleState, error_string) on each update.
|
|
581
601
|
"""
|
|
602
|
+
self._presence_subscriptions.setdefault(module, []).append(callback)
|
|
582
603
|
await self._subscribe_presence(module, callback)
|
|
583
604
|
|
|
584
605
|
async def _subscribe_presence(self, module: str, callback: PresenceCallback) -> None:
|
|
585
606
|
pass
|
|
586
607
|
|
|
608
|
+
async def unsubscribe_presence(self, module: str, callback: PresenceCallback) -> None:
|
|
609
|
+
"""Unsubscribe from presence updates.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
module: Name of remote module.
|
|
613
|
+
callback: Callback that was registered.
|
|
614
|
+
"""
|
|
615
|
+
subs = self._presence_subscriptions.get(module)
|
|
616
|
+
if subs is not None and callback in subs:
|
|
617
|
+
subs.remove(callback)
|
|
618
|
+
await self._unsubscribe_presence(module, callback)
|
|
619
|
+
|
|
620
|
+
async def _unsubscribe_presence(self, module: str, callback: PresenceCallback) -> None:
|
|
621
|
+
pass
|
|
622
|
+
|
|
587
623
|
def _send_event_to_module(self, event: Event, from_client: str) -> None:
|
|
588
624
|
"""Send an event to all connected modules.
|
|
589
625
|
|
|
@@ -600,53 +636,5 @@ class Comm:
|
|
|
600
636
|
if asyncio.iscoroutine(ret):
|
|
601
637
|
asyncio.create_task(ret)
|
|
602
638
|
|
|
603
|
-
def cast_to_simple_pre(self, value: Any, annotation: Any | None = None) -> tuple[bool, Any]:
|
|
604
|
-
"""Special treatment of single parameters when converting them to be sent via Comm.
|
|
605
|
-
|
|
606
|
-
Args:
|
|
607
|
-
value: Value to be treated.
|
|
608
|
-
annotation: Annotation for value.
|
|
609
|
-
|
|
610
|
-
Returns:
|
|
611
|
-
A tuple containing a tuple that indicates whether this value should be further processed and a new value.
|
|
612
|
-
"""
|
|
613
|
-
return False, value
|
|
614
|
-
|
|
615
|
-
def cast_to_simple_post(self, value: Any, annotation: Any | None = None) -> tuple[bool, Any]:
|
|
616
|
-
"""Special treatment of single parameters when converting them to be sent via Comm.
|
|
617
|
-
|
|
618
|
-
Args:
|
|
619
|
-
value: Value to be treated.
|
|
620
|
-
annotation: Annotation for value.
|
|
621
|
-
|
|
622
|
-
Returns:
|
|
623
|
-
A tuple containing a tuple that indicates whether this value should be further processed and a new value.
|
|
624
|
-
"""
|
|
625
|
-
return False, value
|
|
626
|
-
|
|
627
|
-
def cast_to_real_pre(self, value: Any, annotation: Any | None = None) -> tuple[bool, Any]:
|
|
628
|
-
"""Special treatment of single parameters when converting them after being sent via Comm.
|
|
629
|
-
|
|
630
|
-
Args:
|
|
631
|
-
value: Value to be treated.
|
|
632
|
-
annotation: Annotation for value.
|
|
633
|
-
|
|
634
|
-
Returns:
|
|
635
|
-
A tuple containing a tuple that indicates whether this value should be further processed and a new value.
|
|
636
|
-
"""
|
|
637
|
-
return False, value
|
|
638
|
-
|
|
639
|
-
def cast_to_real_post(self, value: Any, annotation: Any | None = None) -> tuple[bool, Any]:
|
|
640
|
-
"""Special treatment of single parameters when converting them after being sent via Comm.
|
|
641
|
-
|
|
642
|
-
Args:
|
|
643
|
-
value: Value to be treated.
|
|
644
|
-
annotation: Annotation for value.
|
|
645
|
-
|
|
646
|
-
Returns:
|
|
647
|
-
A tuple containing a tuple that indicates whether this value should be further processed and a new value.
|
|
648
|
-
"""
|
|
649
|
-
return False, value
|
|
650
|
-
|
|
651
639
|
|
|
652
640
|
__all__ = ["Comm"]
|
pyobs/comm/local/localcomm.py
CHANGED
|
@@ -8,7 +8,6 @@ from pyobs.comm.comm import PresenceCallback
|
|
|
8
8
|
from pyobs.events import Event
|
|
9
9
|
from pyobs.interfaces import Interface
|
|
10
10
|
from pyobs.utils.enums import ModuleState
|
|
11
|
-
from pyobs.utils.types import cast_response_to_real
|
|
12
11
|
|
|
13
12
|
from .localnetwork import LocalNetwork
|
|
14
13
|
|
|
@@ -53,11 +52,7 @@ class LocalComm(Comm):
|
|
|
53
52
|
remote_client = self._network.get_client(client)
|
|
54
53
|
if remote_client.module is None:
|
|
55
54
|
raise ValueError
|
|
56
|
-
|
|
57
|
-
real_results = cast_response_to_real(
|
|
58
|
-
simple_results, annotation["return"], self.cast_to_real_pre, self.cast_to_real_post
|
|
59
|
-
)
|
|
60
|
-
return real_results
|
|
55
|
+
return await remote_client.module.execute(method, *args, sender=self.name)
|
|
61
56
|
|
|
62
57
|
async def send_event(self, event: Event) -> None:
|
|
63
58
|
"""Send an event to other clients."""
|
pyobs/comm/proxy.py
CHANGED
|
@@ -7,7 +7,6 @@ from collections.abc import Coroutine
|
|
|
7
7
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar, get_type_hints
|
|
8
8
|
|
|
9
9
|
from pyobs.interfaces import Interface
|
|
10
|
-
from pyobs.utils.types import cast_bound_arguments_to_simple
|
|
11
10
|
|
|
12
11
|
if TYPE_CHECKING:
|
|
13
12
|
from pyobs.comm import Comm
|
|
@@ -123,11 +122,6 @@ class Proxy:
|
|
|
123
122
|
ba = signature.bind(*args, **kwargs)
|
|
124
123
|
ba.apply_defaults()
|
|
125
124
|
|
|
126
|
-
# cast to simple types
|
|
127
|
-
cast_bound_arguments_to_simple(
|
|
128
|
-
ba, type_hints, pre=self._comm.cast_to_simple_pre, post=self._comm.cast_to_simple_post
|
|
129
|
-
)
|
|
130
|
-
|
|
131
125
|
# do request and return future
|
|
132
126
|
return await self._comm.execute(self._client, method, type_hints, *ba.args[1:])
|
|
133
127
|
|
pyobs/comm/xmpp/rpc.py
CHANGED
|
@@ -48,12 +48,13 @@ def xml_to_params(params_elem: ET.Element, names: list[str], types: dict[str, An
|
|
|
48
48
|
type_hint = types.get(name, Any)
|
|
49
49
|
value_elem = param_elem.find(f"{{{_NS}}}value")
|
|
50
50
|
if value_elem is None:
|
|
51
|
-
|
|
52
|
-
continue
|
|
51
|
+
raise ValueError(f"Malformed RPC param '{name}': missing <value> element.")
|
|
53
52
|
pyobs_value = value_elem.find(f"{{{_PYOBS_NS}}}value")
|
|
54
53
|
if pyobs_value is None:
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Malformed RPC param '{name}': missing <value> element in namespace '{_PYOBS_NS}' "
|
|
56
|
+
f"(sender may have serialized it without the required xmlns)."
|
|
57
|
+
)
|
|
57
58
|
children = list(pyobs_value)
|
|
58
59
|
result.append(xml_to_value(children[0], type_hint) if children else None)
|
|
59
60
|
return result
|
|
@@ -159,7 +160,7 @@ class RPC:
|
|
|
159
160
|
jid: str | slixmpp.JID = iq["id"]
|
|
160
161
|
if isinstance(jid, slixmpp.JID):
|
|
161
162
|
jid = jid.node
|
|
162
|
-
future = Future(annotation=annotation
|
|
163
|
+
future = Future(annotation=annotation)
|
|
163
164
|
self._futures[jid] = future
|
|
164
165
|
|
|
165
166
|
await iq.send()
|
|
@@ -214,6 +215,10 @@ class RPC:
|
|
|
214
215
|
response_params = return_to_xml(return_value, return_type)
|
|
215
216
|
self._client.plugin["xep_0009"].make_iq_method_response(iq["id"], iq["from"], response_params).send()
|
|
216
217
|
|
|
218
|
+
except exc.ForbiddenError as e:
|
|
219
|
+
e.log(log, "WARNING", f"Forbidden call to {pmethod}: {e}")
|
|
220
|
+
self._client.plugin["xep_0009"].forbidden(iq).send()
|
|
221
|
+
|
|
217
222
|
except Exception as e:
|
|
218
223
|
if isinstance(e, exc.PyObsError):
|
|
219
224
|
e.log(log, "ERROR", f"Exception in call to {pmethod}: {e}", exc_info=True)
|
pyobs/comm/xmpp/serializer.py
CHANGED
|
@@ -27,11 +27,17 @@ strip the namespace from the tag before matching (tag.split('}')[-1]).
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
29
|
import dataclasses
|
|
30
|
+
import inspect
|
|
31
|
+
import types as _builtin_types
|
|
30
32
|
from enum import StrEnum
|
|
31
|
-
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
33
|
+
from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints
|
|
32
34
|
|
|
33
35
|
from slixmpp.xmlstream import ET
|
|
34
36
|
|
|
37
|
+
from ...interfaces.interface import Interface as _Interface
|
|
38
|
+
from ...utils.enums import Unit
|
|
39
|
+
from ...utils.time import Time as _Time
|
|
40
|
+
|
|
35
41
|
# Namespace used for the <pyobs:value> wrapper in RPC
|
|
36
42
|
PYOBS_NS = "urn:pyobs:rpc:1"
|
|
37
43
|
|
|
@@ -329,10 +335,184 @@ def _parse_scalar(text: str, type_hint: Any) -> Any:
|
|
|
329
335
|
return text
|
|
330
336
|
|
|
331
337
|
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Interface schema (disco#info <pyobs:interface> blocks)
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _wire_type(hint: Any, enums: dict[str, type]) -> tuple[str, str | None]:
|
|
344
|
+
"""Map a Python type hint to a (wire_type_string, unit_string|None) pair."""
|
|
345
|
+
origin = get_origin(hint)
|
|
346
|
+
args = get_args(hint)
|
|
347
|
+
|
|
348
|
+
# Annotated[T, ...] — extract optional Unit annotation
|
|
349
|
+
if origin is Annotated:
|
|
350
|
+
inner = args[0]
|
|
351
|
+
unit = next((a for a in args[1:] if isinstance(a, Unit)), None)
|
|
352
|
+
type_str, _ = _wire_type(inner, enums)
|
|
353
|
+
return type_str, unit.value if unit else None
|
|
354
|
+
|
|
355
|
+
# T | None → optional<T> (handles typing.Union and builtin types.UnionType)
|
|
356
|
+
if origin is Union or origin is _builtin_types.UnionType:
|
|
357
|
+
non_none = [a for a in args if a is not type(None)]
|
|
358
|
+
if len(non_none) == 1:
|
|
359
|
+
inner_str, _ = _wire_type(non_none[0], enums)
|
|
360
|
+
return f"optional<{inner_str}>", None
|
|
361
|
+
return "any", None
|
|
362
|
+
|
|
363
|
+
# list[T] → array<T>
|
|
364
|
+
if origin is list:
|
|
365
|
+
inner_str, _ = _wire_type(args[0] if args else Any, enums)
|
|
366
|
+
return f"array<{inner_str}>", None
|
|
367
|
+
|
|
368
|
+
# Primitives — bool before int (bool is a subclass of int)
|
|
369
|
+
if hint is bool:
|
|
370
|
+
return "bool", None
|
|
371
|
+
if hint is int:
|
|
372
|
+
return "int32", None
|
|
373
|
+
if hint is float:
|
|
374
|
+
return "float64", None
|
|
375
|
+
if hint is str:
|
|
376
|
+
return "string", None
|
|
377
|
+
if hint is type(None):
|
|
378
|
+
return "void", None
|
|
379
|
+
|
|
380
|
+
# Time (pyobs's astropy.time.Time subclass)
|
|
381
|
+
if isinstance(hint, type) and issubclass(hint, _Time):
|
|
382
|
+
return "datetime", None
|
|
383
|
+
|
|
384
|
+
# StrEnum subclass → enum(Name) + record for <types> block
|
|
385
|
+
if isinstance(hint, type) and issubclass(hint, StrEnum) and hint is not StrEnum:
|
|
386
|
+
enums[hint.__name__] = hint
|
|
387
|
+
return f"enum({hint.__name__})", None
|
|
388
|
+
|
|
389
|
+
# dataclass → struct<Name>
|
|
390
|
+
if dataclasses.is_dataclass(hint) and isinstance(hint, type):
|
|
391
|
+
return f"struct<{hint.__name__}>", None
|
|
392
|
+
|
|
393
|
+
return "any", None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _interface_schema_to_xml(interface: type) -> ET.Element:
|
|
397
|
+
"""Build the <{ns}interface> disco#info schema element for one Interface subclass."""
|
|
398
|
+
ns = f"urn:pyobs:interface:{interface.__name__}:{interface.version}"
|
|
399
|
+
root = ET.Element(f"{{{ns}}}interface", attrib={"name": interface.__name__})
|
|
400
|
+
|
|
401
|
+
enums: dict[str, type] = {}
|
|
402
|
+
|
|
403
|
+
# Collect <command> elements from abstract methods, MRO base-first
|
|
404
|
+
seen: set[str] = set()
|
|
405
|
+
cmd_elems: list[ET.Element] = []
|
|
406
|
+
|
|
407
|
+
for base in reversed(interface.__mro__):
|
|
408
|
+
if base in (object, _Interface):
|
|
409
|
+
continue
|
|
410
|
+
for name, member in sorted(base.__dict__.items()):
|
|
411
|
+
if name.startswith("_") or name in seen:
|
|
412
|
+
continue
|
|
413
|
+
if not getattr(member, "__isabstractmethod__", False):
|
|
414
|
+
continue
|
|
415
|
+
seen.add(name)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
hints = get_type_hints(member, include_extras=True)
|
|
419
|
+
sig = inspect.signature(member)
|
|
420
|
+
except Exception:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
cmd = ET.Element("command", attrib={"name": name})
|
|
424
|
+
for param_name, param in sig.parameters.items():
|
|
425
|
+
if param_name == "self" or param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL):
|
|
426
|
+
continue
|
|
427
|
+
if param_name not in hints:
|
|
428
|
+
continue
|
|
429
|
+
type_str, unit_str = _wire_type(hints[param_name], enums)
|
|
430
|
+
attrib: dict[str, str] = {"name": param_name, "type": type_str}
|
|
431
|
+
if unit_str:
|
|
432
|
+
attrib["unit"] = unit_str
|
|
433
|
+
ET.SubElement(cmd, "parameter", attrib=attrib)
|
|
434
|
+
|
|
435
|
+
cmd_elems.append(cmd)
|
|
436
|
+
|
|
437
|
+
# Build <state> element (after commands, so enum collection covers both)
|
|
438
|
+
state_elem: ET.Element | None = None
|
|
439
|
+
if getattr(interface, "state", None) is not None and dataclasses.is_dataclass(interface.state):
|
|
440
|
+
state_node = f"state/{interface.__name__}/{interface.version}"
|
|
441
|
+
state_elem = ET.Element("state", attrib={"node": state_node})
|
|
442
|
+
try:
|
|
443
|
+
state_hints = get_type_hints(interface.state, include_extras=True)
|
|
444
|
+
except Exception:
|
|
445
|
+
state_hints = {}
|
|
446
|
+
for f in dataclasses.fields(interface.state):
|
|
447
|
+
type_str, unit_str = _wire_type(state_hints.get(f.name, Any), enums)
|
|
448
|
+
fattrib: dict[str, str] = {"name": f.name, "type": type_str}
|
|
449
|
+
if unit_str:
|
|
450
|
+
fattrib["unit"] = unit_str
|
|
451
|
+
ET.SubElement(state_elem, "field", attrib=fattrib)
|
|
452
|
+
|
|
453
|
+
# Emit in order: <types> → <command>... → <state>
|
|
454
|
+
if enums:
|
|
455
|
+
types_elem = ET.SubElement(root, "types")
|
|
456
|
+
for enum_name, enum_cls in sorted(enums.items()):
|
|
457
|
+
enum_elem = ET.SubElement(types_elem, "enum", attrib={"name": enum_name})
|
|
458
|
+
for member in enum_cls:
|
|
459
|
+
val_elem = ET.SubElement(enum_elem, "value")
|
|
460
|
+
val_elem.text = member.value
|
|
461
|
+
|
|
462
|
+
for cmd in cmd_elems:
|
|
463
|
+
root.append(cmd)
|
|
464
|
+
|
|
465
|
+
if state_elem is not None:
|
|
466
|
+
root.append(state_elem)
|
|
467
|
+
|
|
468
|
+
return root
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _event_schema_to_xml(event_cls: type) -> ET.Element:
|
|
472
|
+
"""Build the <{ns}event> disco#info schema element for one Event subclass."""
|
|
473
|
+
ns = f"urn:pyobs:event:{event_cls.__name__}:{event_cls.version}"
|
|
474
|
+
root = ET.Element(f"{{{ns}}}event", attrib={"name": event_cls.__name__})
|
|
475
|
+
|
|
476
|
+
enums: dict[str, type] = {}
|
|
477
|
+
field_elems: list[ET.Element] = []
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
hints = get_type_hints(event_cls.__init__, include_extras=True)
|
|
481
|
+
sig = inspect.signature(event_cls.__init__)
|
|
482
|
+
except Exception:
|
|
483
|
+
return root
|
|
484
|
+
|
|
485
|
+
for param_name, param in sig.parameters.items():
|
|
486
|
+
if param_name == "self" or param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL):
|
|
487
|
+
continue
|
|
488
|
+
if param_name not in hints:
|
|
489
|
+
continue
|
|
490
|
+
type_str, unit_str = _wire_type(hints[param_name], enums)
|
|
491
|
+
attrib: dict[str, str] = {"name": param_name, "type": type_str}
|
|
492
|
+
if unit_str:
|
|
493
|
+
attrib["unit"] = unit_str
|
|
494
|
+
field_elems.append(ET.Element("field", attrib=attrib))
|
|
495
|
+
|
|
496
|
+
if enums:
|
|
497
|
+
types_elem = ET.SubElement(root, "types")
|
|
498
|
+
for enum_name, enum_cls in sorted(enums.items()):
|
|
499
|
+
enum_elem = ET.SubElement(types_elem, "enum", attrib={"name": enum_name})
|
|
500
|
+
for member in enum_cls:
|
|
501
|
+
val_elem = ET.SubElement(enum_elem, "value")
|
|
502
|
+
val_elem.text = member.value
|
|
503
|
+
|
|
504
|
+
for f in field_elems:
|
|
505
|
+
root.append(f)
|
|
506
|
+
|
|
507
|
+
return root
|
|
508
|
+
|
|
509
|
+
|
|
332
510
|
__all__ = [
|
|
333
511
|
"PYOBS_NS",
|
|
334
512
|
"value_to_xml",
|
|
335
513
|
"xml_to_value",
|
|
336
514
|
"_dataclass_to_xml",
|
|
337
515
|
"_xml_to_dataclass",
|
|
516
|
+
"_interface_schema_to_xml",
|
|
517
|
+
"_event_schema_to_xml",
|
|
338
518
|
]
|
pyobs/comm/xmpp/xep_0009/rpc.py
CHANGED
|
@@ -5,8 +5,12 @@ from slixmpp.xmlstream import ET
|
|
|
5
5
|
class XEP_0009(XEP_0009_original):
|
|
6
6
|
"""Small fix for the original XEP_0009 plugin."""
|
|
7
7
|
|
|
8
|
-
def
|
|
9
|
-
|
|
8
|
+
def handle_error(self, iq):
|
|
9
|
+
"""Route RPC-level errors (e.g. forbidden, item-not-found) through the same
|
|
10
|
+
jabber_rpc_error event as transport-level errors, instead of being silently
|
|
11
|
+
dropped -- the base class's own name for this hook, previously shadowed by a
|
|
12
|
+
same-but-underscored no-op that never actually overrode it."""
|
|
13
|
+
self.xmpp.event("jabber_rpc_error", iq)
|
|
10
14
|
|
|
11
15
|
def extract_method(self, stanza):
|
|
12
16
|
xml = ET.fromstring(f"{stanza}")
|
|
@@ -16,6 +20,10 @@ class XEP_0009(XEP_0009_original):
|
|
|
16
20
|
"""Expose method to public."""
|
|
17
21
|
return self._item_not_found(iq)
|
|
18
22
|
|
|
23
|
+
def forbidden(self, iq):
|
|
24
|
+
"""Expose method to public."""
|
|
25
|
+
return self._forbidden(iq)
|
|
26
|
+
|
|
19
27
|
def send_fault(self, iq, fault_xml):
|
|
20
28
|
"""Expose method to public."""
|
|
21
29
|
return self._send_fault(iq, fault_xml)
|
pyobs/comm/xmpp/xmppclient.py
CHANGED
|
@@ -32,6 +32,7 @@ class XmppClient(slixmpp.ClientXMPP):
|
|
|
32
32
|
self._auth_event = asyncio.Event()
|
|
33
33
|
self._auth_success = False
|
|
34
34
|
self._jid_conflict = False
|
|
35
|
+
self._conflict_reason: str | None = None
|
|
35
36
|
|
|
36
37
|
# auto-accept invitations
|
|
37
38
|
self.auto_authorize = True
|
|
@@ -54,8 +55,21 @@ class XmppClient(slixmpp.ClientXMPP):
|
|
|
54
55
|
self.add_event_handler("auth_success", lambda ev: self._auth(True))
|
|
55
56
|
self.add_event_handler("failed_auth", lambda ev: self._auth(False))
|
|
56
57
|
self.add_event_handler("failed_all_auth", self.failed_all_auth)
|
|
58
|
+
self.add_event_handler("stream_error", self._stream_error)
|
|
57
59
|
self.add_filter("in", self._filter_messages)
|
|
58
60
|
|
|
61
|
+
def reconnect(self, wait: int | float = 2.0, reason: str = "Reconnecting") -> Any:
|
|
62
|
+
"""Disconnect only, instead of slixmpp's default reconnect-in-place.
|
|
63
|
+
|
|
64
|
+
xep_0199's keepalive calls this on ping timeout. slixmpp's own
|
|
65
|
+
implementation would reconnect this same client object, while
|
|
66
|
+
XmppComm's "disconnected" handler independently spins up a brand-new
|
|
67
|
+
XmppClient. The two then fight over the same JID resource, each
|
|
68
|
+
kicking the other off the server ("replaced by new connection"),
|
|
69
|
+
forever. Leaving reconnection solely to XmppComm avoids that.
|
|
70
|
+
"""
|
|
71
|
+
return self.disconnect(0.0, reason=reason)
|
|
72
|
+
|
|
59
73
|
def _filter_messages(self, stanza: StanzaBase) -> StanzaBase | None:
|
|
60
74
|
# if a user with same JID is already connected, we get a conflict
|
|
61
75
|
if '<conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" />' in str(stanza):
|
|
@@ -63,6 +77,28 @@ class XmppClient(slixmpp.ClientXMPP):
|
|
|
63
77
|
return None
|
|
64
78
|
return stanza
|
|
65
79
|
|
|
80
|
+
def _stream_error(self, error: Any) -> None:
|
|
81
|
+
"""Called when the server sends a <stream:error/>, e.g. when this connection gets
|
|
82
|
+
kicked because another session took over the same JID/resource.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
error: The stream error stanza.
|
|
86
|
+
"""
|
|
87
|
+
if error["condition"] == "conflict":
|
|
88
|
+
self._jid_conflict = True
|
|
89
|
+
self._conflict_reason = error["text"] or None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def kicked_by_conflict(self) -> bool:
|
|
93
|
+
"""Whether this client was (or is being) kicked because another session connected
|
|
94
|
+
with the same JID, rather than disconnecting for some other reason."""
|
|
95
|
+
return self._jid_conflict
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def conflict_reason(self) -> str | None:
|
|
99
|
+
"""Human-readable reason text sent alongside the conflict stream error, if any."""
|
|
100
|
+
return self._conflict_reason
|
|
101
|
+
|
|
66
102
|
async def wait_connect(self) -> bool:
|
|
67
103
|
"""Wait for client to connect.
|
|
68
104
|
|