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.
Files changed (76) hide show
  1. pyobs/application.py +5 -0
  2. pyobs/cli/_cli.py +14 -5
  3. pyobs/cli/pyobsd.py +12 -0
  4. pyobs/comm/comm.py +40 -52
  5. pyobs/comm/local/localcomm.py +1 -6
  6. pyobs/comm/proxy.py +0 -6
  7. pyobs/comm/xmpp/rpc.py +10 -5
  8. pyobs/comm/xmpp/serializer.py +181 -1
  9. pyobs/comm/xmpp/xep_0009/rpc.py +10 -2
  10. pyobs/comm/xmpp/xmppclient.py +36 -0
  11. pyobs/comm/xmpp/xmppcomm.py +138 -70
  12. pyobs/events/testevent.py +1 -0
  13. pyobs/images/image.py +2 -1
  14. pyobs/images/processors/astrometry/_dotnet_response_saver.py +6 -2
  15. pyobs/images/processors/detection/_source_catalog.py +2 -2
  16. pyobs/images/processors/photometry/_sep_aperture_photometry.py +6 -1
  17. pyobs/interfaces/IAcquisition.py +20 -4
  18. pyobs/interfaces/IAutoFocus.py +28 -17
  19. pyobs/interfaces/IBinning.py +8 -2
  20. pyobs/interfaces/IConfig.py +6 -3
  21. pyobs/interfaces/IFitsHeaderAfter.py +2 -1
  22. pyobs/interfaces/IFitsHeaderBefore.py +9 -2
  23. pyobs/interfaces/IFlatField.py +4 -3
  24. pyobs/interfaces/IFocusModel.py +9 -4
  25. pyobs/interfaces/IFocuser.py +8 -7
  26. pyobs/interfaces/IModule.py +5 -0
  27. pyobs/interfaces/IWeather.py +21 -22
  28. pyobs/interfaces/__init__.py +18 -7
  29. pyobs/interfaces/interface.py +9 -0
  30. pyobs/mixins/fitsheader.py +2 -7
  31. pyobs/mixins/fitsnamespace.py +4 -2
  32. pyobs/mixins/weatheraware.py +2 -1
  33. pyobs/modules/camera/basevideo.py +5 -1
  34. pyobs/modules/camera/dummycamera.py +4 -2
  35. pyobs/modules/flatfield/flatfield.py +1 -1
  36. pyobs/modules/focus/focusmodel.py +15 -19
  37. pyobs/modules/focus/focusseries.py +14 -18
  38. pyobs/modules/image/imagewatcher.py +5 -2
  39. pyobs/modules/module.py +129 -19
  40. pyobs/modules/pointing/_baseguiding.py +5 -5
  41. pyobs/modules/pointing/acquisition.py +21 -12
  42. pyobs/modules/pointing/dummyacquisition.py +13 -15
  43. pyobs/modules/pointing/guidingstatistics/guidingstatistics.py +4 -2
  44. pyobs/modules/pointing/guidingstatistics/pixeloffset.py +5 -5
  45. pyobs/modules/pointing/guidingstatistics/skyoffsets.py +4 -4
  46. pyobs/modules/pointing/guidingstatistics/uptime.py +3 -3
  47. pyobs/modules/robotic/mastermind.py +4 -4
  48. pyobs/modules/robotic/pointing.py +3 -27
  49. pyobs/modules/roof/basedome.py +3 -3
  50. pyobs/modules/roof/baseroof.py +3 -3
  51. pyobs/modules/telescope/basetelescope.py +22 -15
  52. pyobs/modules/telescope/dummytelescope.py +21 -10
  53. pyobs/modules/weather/weather.py +46 -33
  54. pyobs/modules/weather/weather_state.py +1 -1
  55. pyobs/robotic/scripts/calibration/skyflats.py +1 -1
  56. pyobs/robotic/scripts/control/cases.py +3 -2
  57. pyobs/robotic/scripts/control/conditional.py +3 -2
  58. pyobs/robotic/scripts/imaging/imaging.py +4 -3
  59. pyobs/robotic/scripts/script.py +3 -2
  60. pyobs/robotic/storage/lco/scripts/default.py +4 -3
  61. pyobs/robotic/storage/lco/task.py +2 -1
  62. pyobs/robotic/task.py +2 -1
  63. pyobs/utils/enums.py +3 -0
  64. pyobs/utils/exceptions.py +9 -0
  65. pyobs/utils/focusseries/base.py +5 -0
  66. pyobs/utils/focusseries/photometry.py +5 -0
  67. pyobs/utils/focusseries/projection.py +5 -0
  68. pyobs/utils/gui/camera/datadisplaywidget.py +2 -2
  69. pyobs/utils/parallel.py +2 -17
  70. pyobs/utils/units.py +48 -0
  71. {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/METADATA +4 -4
  72. {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/RECORD +75 -75
  73. pyobs/utils/types.py +0 -242
  74. {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/WHEEL +0 -0
  75. {pyobs_core-2.0.0.dev9.dist-info → pyobs_core-2.0.0.dev11.dist-info}/entry_points.txt +0 -0
  76. {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
- config_file = os.path.expanduser(os.path.join("~", ".config", "pyobs.yaml"))
46
- if not os.path.exists(config_file):
47
- config_file = os.path.expanduser(os.path.join("/", "etc", "pyobs.yaml"))
48
- if not os.path.exists(config_file):
49
- return
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.state is not None:
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
- raise ValueError(
197
- f'Proxy obtained from given name "{name_or_object}" is not of requested type "{obj_type}".'
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"]
@@ -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
- simple_results = await remote_client.module.execute(method, *args, sender=self.name)
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
- result.append(None)
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
- result.append(None)
56
- continue
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, comm=self._comm)
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)
@@ -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
  ]
@@ -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 _handle_error(self, iq):
9
- pass
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)
@@ -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