pyobs-core 2.0.0.dev10__py3-none-any.whl → 2.0.0.dev13__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 +26 -1
- pyobs/comm/xmpp/rpc.py +9 -4
- 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 +100 -46
- 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 +40 -4
- pyobs/interfaces/IAutoFocus.py +2 -1
- pyobs/interfaces/IAutoGuiding.py +19 -1
- pyobs/interfaces/IBinning.py +8 -2
- pyobs/interfaces/IConfig.py +6 -3
- pyobs/interfaces/IFlatField.py +4 -3
- pyobs/interfaces/IFocusModel.py +1 -1
- pyobs/interfaces/IFocuser.py +8 -7
- pyobs/interfaces/IMode.py +3 -3
- pyobs/interfaces/IModule.py +5 -0
- pyobs/interfaces/__init__.py +13 -4
- pyobs/interfaces/interface.py +9 -0
- pyobs/modules/camera/basevideo.py +5 -1
- pyobs/modules/camera/dummycamera.py +4 -2
- pyobs/modules/flatfield/flatfield.py +1 -1
- pyobs/modules/focus/__init__.py +2 -1
- pyobs/modules/focus/dummyautofocus.py +111 -0
- pyobs/modules/focus/focusseries.py +9 -0
- pyobs/modules/image/imagewatcher.py +5 -2
- pyobs/modules/module.py +128 -11
- pyobs/modules/pointing/_baseguiding.py +38 -9
- pyobs/modules/pointing/acquisition.py +68 -21
- pyobs/modules/pointing/dummyacquisition.py +93 -21
- pyobs/modules/pointing/dummyguiding.py +58 -3
- pyobs/modules/robotic/pointing.py +3 -27
- pyobs/modules/telescope/basetelescope.py +3 -1
- pyobs/modules/telescope/dummytelescope.py +18 -8
- pyobs/modules/utils/dummymode.py +9 -11
- pyobs/modules/utils/telegram.py +14 -5
- pyobs/modules/weather/__init__.py +2 -1
- pyobs/modules/weather/mockweather.py +144 -0
- pyobs/modules/weather/weather.py +2 -2
- pyobs/robotic/scheduler/targets/__init__.py +2 -1
- pyobs/robotic/scheduler/targets/heliocentricpolar.py +40 -0
- pyobs/robotic/scripts/calibration/skyflats.py +1 -1
- pyobs/robotic/storage/lco/_schedulereader.py +1 -1
- pyobs/robotic/storage/lco/observationarchive.py +14 -3
- pyobs/robotic/storage/lco/scripts/script.py +55 -2
- pyobs/robotic/storage/lco/task.py +21 -14
- pyobs/robotic/storage/lco/taskarchive.py +2 -2
- pyobs/robotic/storage/lco/taskrunner.py +1 -1
- pyobs/utils/enums.py +27 -1
- pyobs/utils/exceptions.py +9 -0
- pyobs/utils/gui/camera/datadisplaywidget.py +1 -1
- pyobs/utils/offsets/__init__.py +2 -2
- pyobs/utils/offsets/applyaltazoffsets.py +9 -8
- pyobs/utils/offsets/applyoffsets.py +15 -4
- pyobs/utils/offsets/applyradecoffsets.py +9 -8
- pyobs/utils/units.py +48 -0
- {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/METADATA +4 -4
- {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/RECORD +68 -64
- {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/WHEEL +1 -1
- {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/entry_points.txt +0 -0
- {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.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
|
|
@@ -265,6 +267,12 @@ class Comm:
|
|
|
265
267
|
for interface, callback in self._state_subscriptions.pop(sender, []):
|
|
266
268
|
await self.unsubscribe_state(sender, interface, callback)
|
|
267
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
|
+
|
|
268
276
|
return True
|
|
269
277
|
|
|
270
278
|
@property
|
|
@@ -434,6 +442,7 @@ class Comm:
|
|
|
434
442
|
|
|
435
443
|
# we also want to register all events derived from the given one
|
|
436
444
|
event_classes = self._get_derived_events(event_class)
|
|
445
|
+
self._registered_events.update(event_classes)
|
|
437
446
|
|
|
438
447
|
# do we have a handler?
|
|
439
448
|
if handler:
|
|
@@ -590,11 +599,27 @@ class Comm:
|
|
|
590
599
|
module: Name of remote module.
|
|
591
600
|
callback: Called with (ModuleState, error_string) on each update.
|
|
592
601
|
"""
|
|
602
|
+
self._presence_subscriptions.setdefault(module, []).append(callback)
|
|
593
603
|
await self._subscribe_presence(module, callback)
|
|
594
604
|
|
|
595
605
|
async def _subscribe_presence(self, module: str, callback: PresenceCallback) -> None:
|
|
596
606
|
pass
|
|
597
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
|
+
|
|
598
623
|
def _send_event_to_module(self, event: Event, from_client: str) -> None:
|
|
599
624
|
"""Send an event to all connected modules.
|
|
600
625
|
|
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
|
|
@@ -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
|
|