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.
Files changed (68) 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 +26 -1
  5. pyobs/comm/xmpp/rpc.py +9 -4
  6. pyobs/comm/xmpp/serializer.py +181 -1
  7. pyobs/comm/xmpp/xep_0009/rpc.py +10 -2
  8. pyobs/comm/xmpp/xmppclient.py +36 -0
  9. pyobs/comm/xmpp/xmppcomm.py +100 -46
  10. pyobs/events/testevent.py +1 -0
  11. pyobs/images/image.py +2 -1
  12. pyobs/images/processors/astrometry/_dotnet_response_saver.py +6 -2
  13. pyobs/images/processors/detection/_source_catalog.py +2 -2
  14. pyobs/images/processors/photometry/_sep_aperture_photometry.py +6 -1
  15. pyobs/interfaces/IAcquisition.py +40 -4
  16. pyobs/interfaces/IAutoFocus.py +2 -1
  17. pyobs/interfaces/IAutoGuiding.py +19 -1
  18. pyobs/interfaces/IBinning.py +8 -2
  19. pyobs/interfaces/IConfig.py +6 -3
  20. pyobs/interfaces/IFlatField.py +4 -3
  21. pyobs/interfaces/IFocusModel.py +1 -1
  22. pyobs/interfaces/IFocuser.py +8 -7
  23. pyobs/interfaces/IMode.py +3 -3
  24. pyobs/interfaces/IModule.py +5 -0
  25. pyobs/interfaces/__init__.py +13 -4
  26. pyobs/interfaces/interface.py +9 -0
  27. pyobs/modules/camera/basevideo.py +5 -1
  28. pyobs/modules/camera/dummycamera.py +4 -2
  29. pyobs/modules/flatfield/flatfield.py +1 -1
  30. pyobs/modules/focus/__init__.py +2 -1
  31. pyobs/modules/focus/dummyautofocus.py +111 -0
  32. pyobs/modules/focus/focusseries.py +9 -0
  33. pyobs/modules/image/imagewatcher.py +5 -2
  34. pyobs/modules/module.py +128 -11
  35. pyobs/modules/pointing/_baseguiding.py +38 -9
  36. pyobs/modules/pointing/acquisition.py +68 -21
  37. pyobs/modules/pointing/dummyacquisition.py +93 -21
  38. pyobs/modules/pointing/dummyguiding.py +58 -3
  39. pyobs/modules/robotic/pointing.py +3 -27
  40. pyobs/modules/telescope/basetelescope.py +3 -1
  41. pyobs/modules/telescope/dummytelescope.py +18 -8
  42. pyobs/modules/utils/dummymode.py +9 -11
  43. pyobs/modules/utils/telegram.py +14 -5
  44. pyobs/modules/weather/__init__.py +2 -1
  45. pyobs/modules/weather/mockweather.py +144 -0
  46. pyobs/modules/weather/weather.py +2 -2
  47. pyobs/robotic/scheduler/targets/__init__.py +2 -1
  48. pyobs/robotic/scheduler/targets/heliocentricpolar.py +40 -0
  49. pyobs/robotic/scripts/calibration/skyflats.py +1 -1
  50. pyobs/robotic/storage/lco/_schedulereader.py +1 -1
  51. pyobs/robotic/storage/lco/observationarchive.py +14 -3
  52. pyobs/robotic/storage/lco/scripts/script.py +55 -2
  53. pyobs/robotic/storage/lco/task.py +21 -14
  54. pyobs/robotic/storage/lco/taskarchive.py +2 -2
  55. pyobs/robotic/storage/lco/taskrunner.py +1 -1
  56. pyobs/utils/enums.py +27 -1
  57. pyobs/utils/exceptions.py +9 -0
  58. pyobs/utils/gui/camera/datadisplaywidget.py +1 -1
  59. pyobs/utils/offsets/__init__.py +2 -2
  60. pyobs/utils/offsets/applyaltazoffsets.py +9 -8
  61. pyobs/utils/offsets/applyoffsets.py +15 -4
  62. pyobs/utils/offsets/applyradecoffsets.py +9 -8
  63. pyobs/utils/units.py +48 -0
  64. {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/METADATA +4 -4
  65. {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/RECORD +68 -64
  66. {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/WHEEL +1 -1
  67. {pyobs_core-2.0.0.dev10.dist-info → pyobs_core-2.0.0.dev13.dist-info}/entry_points.txt +0 -0
  68. {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
- 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
@@ -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
- 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
@@ -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