arcsecond 3.20.1__tar.gz → 3.20.2__tar.gz

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 (116) hide show
  1. {arcsecond-3.20.1 → arcsecond-3.20.2}/PKG-INFO +1 -1
  2. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/alpaca/commands.py +55 -26
  3. arcsecond-3.20.2/arcsecond/alpaca/dome_probe.py +89 -0
  4. arcsecond-3.20.1/arcsecond/alpaca/dome_probe.py → arcsecond-3.20.2/arcsecond/alpaca/probe.py +114 -120
  5. arcsecond-3.20.2/arcsecond/alpaca/telescope_probe.py +180 -0
  6. {arcsecond-3.20.1 → arcsecond-3.20.2}/pyproject.toml +1 -1
  7. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_alpaca_probe.py +343 -9
  8. {arcsecond-3.20.1 → arcsecond-3.20.2}/uv.lock +1 -1
  9. {arcsecond-3.20.1 → arcsecond-3.20.2}/.docker/Dockerfile_postgres +0 -0
  10. {arcsecond-3.20.1 → arcsecond-3.20.2}/.docker/Dockerfile_redis +0 -0
  11. {arcsecond-3.20.1 → arcsecond-3.20.2}/.flake8 +0 -0
  12. {arcsecond-3.20.1 → arcsecond-3.20.2}/.github/dependabot.yml +0 -0
  13. {arcsecond-3.20.1 → arcsecond-3.20.2}/.github/workflows/pythonpublish.yml +0 -0
  14. {arcsecond-3.20.1 → arcsecond-3.20.2}/.github/workflows/tests.yml +0 -0
  15. {arcsecond-3.20.1 → arcsecond-3.20.2}/.gitignore +0 -0
  16. {arcsecond-3.20.1 → arcsecond-3.20.2}/LICENSE +0 -0
  17. {arcsecond-3.20.1 → arcsecond-3.20.2}/Makefile +0 -0
  18. {arcsecond-3.20.1 → arcsecond-3.20.2}/README.md +0 -0
  19. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/__init__.py +0 -0
  20. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/__version__.py +0 -0
  21. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/alpaca/__init__.py +0 -0
  22. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/__init__.py +0 -0
  23. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/config.py +0 -0
  24. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/constants.py +0 -0
  25. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/endpoint.py +0 -0
  26. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/main.py +0 -0
  27. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/api/resources.py +0 -0
  28. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cli.py +0 -0
  29. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/__init__.py +0 -0
  30. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/auth.py +0 -0
  31. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/resources.py +0 -0
  32. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/__init__.py +0 -0
  33. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/constants.py +0 -0
  34. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/context.py +0 -0
  35. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  36. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  37. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  38. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  39. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  40. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/errors.py +0 -0
  41. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/logger.py +0 -0
  42. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/uploader.py +0 -0
  43. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/utils.py +0 -0
  44. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploader/walker.py +0 -0
  45. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/cloud/uploads.py +0 -0
  46. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/errors.py +0 -0
  47. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/__init__.py +0 -0
  48. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/backups.py +0 -0
  49. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/checks.py +0 -0
  50. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/constants.py +0 -0
  51. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/database.py +0 -0
  52. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/__init__.py +0 -0
  53. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/constants.py +0 -0
  54. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/containers.py +0 -0
  55. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  56. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/images.py +0 -0
  57. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/docker/utils.py +0 -0
  58. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/keygen/__init__.py +0 -0
  59. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/keygen/client.py +0 -0
  60. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/keygen/utils.py +0 -0
  61. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/local.py +0 -0
  62. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/main.py +0 -0
  63. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
  64. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/setup.py +0 -0
  65. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/utils.py +0 -0
  66. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/hosting/validation.py +0 -0
  67. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/__init__.py +0 -0
  68. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/commands.py +0 -0
  69. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/detection.py +0 -0
  70. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/proxy.py +0 -0
  71. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/registry.py +0 -0
  72. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/runtime.py +0 -0
  73. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/__init__.py +0 -0
  74. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/base.py +0 -0
  75. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/filewatch.py +0 -0
  76. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/mdns.py +0 -0
  77. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/network.py +0 -0
  78. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/sources/opencv.py +0 -0
  79. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/imagesources/store.py +0 -0
  80. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/options.py +0 -0
  81. {arcsecond-3.20.1 → arcsecond-3.20.2}/arcsecond/targets.py +0 -0
  82. {arcsecond-3.20.1 → arcsecond-3.20.2}/examples/example_upload_files.py +0 -0
  83. {arcsecond-3.20.1 → arcsecond-3.20.2}/requirements.txt +0 -0
  84. {arcsecond-3.20.1 → arcsecond-3.20.2}/setup.cfg +0 -0
  85. {arcsecond-3.20.1 → arcsecond-3.20.2}/setup.py +0 -0
  86. {arcsecond-3.20.1 → arcsecond-3.20.2}/sonar-project.properties +0 -0
  87. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/__init__.py +0 -0
  88. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/api/__init__.py +0 -0
  89. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/api/test_api.py +0 -0
  90. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/api/test_api_endpoint.py +0 -0
  91. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/api/test_config.py +0 -0
  92. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/api/test_targets.py +0 -0
  93. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/__init__.py +0 -0
  94. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/__init__.py +0 -0
  95. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  96. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  97. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  98. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  99. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  100. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  101. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/conftest.py +0 -0
  102. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/fixtures/file1.fits +0 -0
  103. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_cli.py +0 -0
  104. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_hosting_backups_restore.py +0 -0
  105. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_hosting_database.py +0 -0
  106. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_hosting_local.py +0 -0
  107. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_hosting_utils_secret_key.py +0 -0
  108. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_commands.py +0 -0
  109. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_detection.py +0 -0
  110. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_mdns.py +0 -0
  111. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_network.py +0 -0
  112. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_proxy.py +0 -0
  113. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_store.py +0 -0
  114. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_imagesources_webcam.py +0 -0
  115. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/test_targets_planning.py +0 -0
  116. {arcsecond-3.20.1 → arcsecond-3.20.2}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.20.1
3
+ Version: 3.20.2
4
4
  Summary: CLI for arcsecond.io
5
5
  Project-URL: Homepage, https://github.com/arcsecond-io/cli
6
6
  Project-URL: Issues, https://github.com/arcsecond-io/cli/issues
@@ -1,14 +1,14 @@
1
1
  """
2
2
  Click command group: ``arcsecond alpaca``.
3
3
 
4
- First citizen: ``arcsecond alpaca probe dome`` — a read-only diagnostic that
5
- captures what surface a given Alpaca dome server exposes (device metadata,
6
- ``SupportedActions``, ``CommandString`` / ``CommandBool`` / ``CommandBlind``
7
- passthrough behaviour, and local host hints when the target is this machine).
8
-
9
- Structure leaves room for ``arcsecond alpaca probe telescope`` /
10
- ``probe camera`` / ``probe focuser`` and ``arcsecond alpaca discover`` later
11
- under the same group.
4
+ ``arcsecond alpaca probe dome`` and ``arcsecond alpaca probe telescope`` are
5
+ read-only diagnostics that capture what surface a given Alpaca device server
6
+ exposes (device metadata, ``SupportedActions``, ``CommandString`` /
7
+ ``CommandBool`` / ``CommandBlind`` passthrough behaviour, and local host hints
8
+ when the target is this machine), one JSON report per run.
9
+
10
+ Structure leaves room for ``probe camera`` / ``probe focuser`` and
11
+ ``arcsecond alpaca discover`` later under the same group.
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -17,11 +17,14 @@ import json
17
17
  import os
18
18
  from datetime import datetime, timezone
19
19
  from pathlib import Path
20
+ from typing import Callable
20
21
 
21
22
  import click
22
23
 
23
24
  from ..errors import ArcsecondError
24
- from .dome_probe import HOST_HINTS_REASON_LOCAL, ProbeProgress, probe_dome
25
+ from .dome_probe import probe_dome
26
+ from .probe import HOST_HINTS_REASON_LOCAL, ProbeProgress, ProbeResult
27
+ from .telescope_probe import probe_telescope
25
28
 
26
29
 
27
30
  @click.group(name="alpaca", help="Diagnostics for local ASCOM Alpaca devices.")
@@ -122,20 +125,10 @@ def _format_progress(label: str, ok: bool, detail: str | None) -> str:
122
125
  return f" {tag} {label}{suffix}"
123
126
 
124
127
 
125
- @probe_group.command(
126
- name="dome",
127
- help=(
128
- "Probe a local Alpaca dome device read-only and write a JSON report.\n\n"
129
- "Captures device metadata, SupportedActions, and the behaviour of the "
130
- "legacy CommandString / CommandBool / CommandBlind passthroughs. "
131
- "Useful for figuring out whether a proprietary driver (e.g. TCSGalil) "
132
- "exposes any vendor-specific extension surface beyond the standard "
133
- "ASCOM IDome interface. Local host hints (open ports, COM ProgIDs) "
134
- "are added when HOST is this machine."
135
- ),
136
- )
137
- @_add_options(_COMMON_OPTIONS)
138
- def probe_dome_cmd(
128
+ def _run_probe(
129
+ kind: str,
130
+ runner: Callable[..., ProbeResult],
131
+ *,
139
132
  host: str,
140
133
  port: int,
141
134
  device_number: int,
@@ -144,10 +137,11 @@ def probe_dome_cmd(
144
137
  collect_host_info: bool | None,
145
138
  output_path: str | None,
146
139
  ) -> None:
147
- output_path = output_path or _default_output_path("dome")
140
+ """Drive one probe from the terminal: banner, progress lines, report, summary."""
141
+ output_path = output_path or _default_output_path(kind)
148
142
 
149
143
  click.echo(
150
- click.style("Alpaca dome probe", bold=True)
144
+ click.style(f"Alpaca {kind} probe", bold=True)
151
145
  + f" → {protocol}://{host}:{port} (device {device_number})"
152
146
  )
153
147
  if allow_active:
@@ -163,7 +157,7 @@ def probe_dome_cmd(
163
157
  )
164
158
 
165
159
  try:
166
- result = probe_dome(
160
+ result = runner(
167
161
  host=host,
168
162
  port=port,
169
163
  device_number=device_number,
@@ -192,3 +186,38 @@ def probe_dome_cmd(
192
186
  click.echo(f" SupportedActions: {supported_n} entries")
193
187
  click.echo(f" Host hints: {_describe_host_hints(result.report)}")
194
188
  click.echo(f" Report written to {click.style(os.fspath(output_path), fg='cyan')}")
189
+
190
+
191
+ @probe_group.command(
192
+ name="dome",
193
+ help=(
194
+ "Probe a local Alpaca dome device read-only and write a JSON report.\n\n"
195
+ "Captures device metadata, SupportedActions, and the behaviour of the "
196
+ "legacy CommandString / CommandBool / CommandBlind passthroughs. "
197
+ "Useful for figuring out whether a proprietary driver (e.g. TCSGalil) "
198
+ "exposes any vendor-specific extension surface beyond the standard "
199
+ "ASCOM IDome interface. Local host hints (open ports, COM ProgIDs) "
200
+ "are added when HOST is this machine."
201
+ ),
202
+ )
203
+ @_add_options(_COMMON_OPTIONS)
204
+ def probe_dome_cmd(**options) -> None:
205
+ _run_probe("dome", probe_dome, **options)
206
+
207
+
208
+ @probe_group.command(
209
+ name="telescope",
210
+ help=(
211
+ "Probe a local Alpaca telescope (mount) device read-only and write a "
212
+ "JSON report.\n\n"
213
+ "Captures device metadata, optics, site, pointing and tracking state, "
214
+ "capabilities, CanMoveAxis and AxisRates per movable axis, "
215
+ "SupportedActions, and the behaviour of the legacy CommandString / "
216
+ "CommandBool / CommandBlind passthroughs. Never moves the mount. "
217
+ "Local host hints (open ports, COM ProgIDs) are added when HOST is "
218
+ "this machine."
219
+ ),
220
+ )
221
+ @_add_options(_COMMON_OPTIONS)
222
+ def probe_telescope_cmd(**options) -> None:
223
+ _run_probe("telescope", probe_telescope, **options)
@@ -0,0 +1,89 @@
1
+ """
2
+ Read-only diagnostic for an ASCOM Alpaca *dome* device.
3
+
4
+ The standard ASCOM Alpaca ``IDome`` interface only exposes a single
5
+ ``OpenShutter`` / ``CloseShutter`` pair. Multi-shutter domes (e.g. DFM domes
6
+ driven by TCSGalil) coordinate their shutters internally; if the driver does
7
+ not advertise per-shutter custom actions via ``SupportedActions``, there is
8
+ no canonical client-side way to drive the upper and lower shutters
9
+ independently. This probe writes down exactly what surface a dome server
10
+ exposes, so that decision can be made on evidence.
11
+
12
+ The engine, the report layout and the safety rules live in :mod:`.probe`;
13
+ this module only says which dome properties to read.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Callable, Iterable
19
+
20
+ from .probe import ProbeProgress, ProbeResult, probe_device
21
+ from .probe import iter_probe_labels as _iter_probe_labels
22
+
23
+ # Read-only device descriptors and dome state, in the order we want them
24
+ # presented in the report. Names are PascalCase to match the alpyca surface.
25
+ _READ_ONLY_PROPERTIES: tuple[str, ...] = (
26
+ "Name",
27
+ "Description",
28
+ "DriverInfo",
29
+ "DriverVersion",
30
+ "InterfaceVersion",
31
+ "ShutterStatus",
32
+ "Altitude",
33
+ "Azimuth",
34
+ "AtHome",
35
+ "AtPark",
36
+ "Slewing",
37
+ "Slaved",
38
+ "CanFindHome",
39
+ "CanPark",
40
+ "CanSetAltitude",
41
+ "CanSetPark",
42
+ "CanSetShutter",
43
+ "CanSlave",
44
+ "CanSyncAzimuth",
45
+ )
46
+
47
+
48
+ def probe_dome(
49
+ host: str,
50
+ port: int,
51
+ device_number: int = 0,
52
+ *,
53
+ protocol: str = "http",
54
+ allow_active: bool = False,
55
+ collect_host_info: bool | None = None,
56
+ dome_factory: Callable[[str, int, str], Any] | None = None,
57
+ progress: ProbeProgress | None = None,
58
+ ) -> ProbeResult:
59
+ """
60
+ Build a JSON-serialisable diagnostic report for an Alpaca dome at
61
+ ``protocol://host:port`` (device_number). See :func:`.probe.probe_device`
62
+ for the ``collect_host_info`` semantics.
63
+
64
+ ``dome_factory(address, device_number, protocol)`` is only there for
65
+ tests — production callers leave it ``None`` and the real
66
+ ``alpaca.dome.Dome`` is used.
67
+ """
68
+ if dome_factory is None:
69
+ from alpaca.dome import Dome # local import: keep cost off cold paths
70
+
71
+ dome_factory = Dome
72
+
73
+ return probe_device(
74
+ "dome",
75
+ host,
76
+ port,
77
+ device_number,
78
+ properties=_READ_ONLY_PROPERTIES,
79
+ device_factory=dome_factory,
80
+ protocol=protocol,
81
+ allow_active=allow_active,
82
+ collect_host_info=collect_host_info,
83
+ progress=progress,
84
+ )
85
+
86
+
87
+ def iter_probe_labels() -> Iterable[str]:
88
+ """Convenience for tests / docs: enumerate the labels emitted by a run."""
89
+ return _iter_probe_labels(_READ_ONLY_PROPERTIES)
@@ -1,27 +1,20 @@
1
1
  """
2
- Read-only diagnostic for an ASCOM Alpaca *dome* device.
3
-
4
- The standard ASCOM Alpaca ``IDome`` interface only exposes a single
5
- ``OpenShutter`` / ``CloseShutter`` pair. Multi-shutter domes (e.g. DFM domes
6
- driven by TCSGalil) coordinate their shutters internally; if the driver does
7
- not advertise per-shutter custom actions via ``SupportedActions``, there is
8
- no canonical client-side way to drive the upper and lower shutters
9
- independently.
10
-
11
- This module produces a structured JSON report describing exactly what surface
12
- a given Alpaca dome server exposes:
13
-
14
- * environment / connectivity
15
- * standard read-only device metadata and dome state
16
- * the raw ``SupportedActions`` list
17
- * legacy ``CommandString`` / ``CommandBool`` / ``CommandBlind`` passthrough
18
- behaviour against a small set of safe candidate commands
19
- * best-effort local host hints (open ports, COM ProgIDs), collected
20
- automatically when the target is this machine, the only case in which
21
- they describe the Alpaca server's host at all
22
-
23
- It never issues motion-class commands. ``CommandBlind`` and active Galil DMC
24
- verbs are gated behind ``allow_active=True``.
2
+ The read-only probe engine shared by every ``arcsecond alpaca probe <device>``
3
+ command.
4
+
5
+ A probe never issues motion-class commands. It reads a device's standard
6
+ properties, its ``SupportedActions`` list, and how the legacy
7
+ ``CommandString`` / ``CommandBool`` / ``CommandBlind`` passthroughs behave
8
+ against a small set of safe candidate commands, and writes it all out as one
9
+ JSON report in which every outcome, including every error, is data.
10
+
11
+ ``CommandBlind`` and active Galil DMC verbs are gated behind
12
+ ``allow_active=True``. Local host hints (open ports, COM ProgIDs) are attached
13
+ automatically when the target is this machine, the only case in which they
14
+ describe the Alpaca server's host at all.
15
+
16
+ Device modules (``dome_probe``, ``telescope_probe``) own only what differs per
17
+ device: the property list, the alpyca class, and any extra read-only section.
25
18
  """
26
19
 
27
20
  from __future__ import annotations
@@ -35,35 +28,14 @@ import sys
35
28
  import traceback
36
29
  from dataclasses import dataclass, field
37
30
  from datetime import datetime, timezone
38
- from typing import Any, Callable, Iterable
39
-
40
- # Read-only device descriptors and dome state, in the order we want them
41
- # presented in the report. Names are PascalCase to match the alpyca surface.
42
- _READ_ONLY_PROPERTIES: tuple[str, ...] = (
43
- "Name",
44
- "Description",
45
- "DriverInfo",
46
- "DriverVersion",
47
- "InterfaceVersion",
48
- "ShutterStatus",
49
- "Altitude",
50
- "Azimuth",
51
- "AtHome",
52
- "AtPark",
53
- "Slewing",
54
- "Slaved",
55
- "CanFindHome",
56
- "CanPark",
57
- "CanSetAltitude",
58
- "CanSetPark",
59
- "CanSetShutter",
60
- "CanSlave",
61
- "CanSyncAzimuth",
62
- )
31
+ from enum import Enum
32
+ from typing import Any, Callable, Iterable, Mapping
63
33
 
64
34
  # Safe Galil DMC read-style verbs: print/report values without commanding
65
35
  # motion. ``MG`` is "message" (print expression), ``TE`` is "tell error",
66
- # ``TP`` is "tell position".
36
+ # ``TP`` is "tell position". They probe whether a driver forwards
37
+ # CommandString at all; a driver without the member (the DFM TCSGalil ones)
38
+ # fails every one of them, and that is the finding.
67
39
  _READ_ONLY_COMMANDS: tuple[str, ...] = (
68
40
  "MG TIME",
69
41
  "MG _BGA",
@@ -125,8 +97,6 @@ def _coerce_jsonable(value: Any) -> Any:
125
97
  """Make values from alpyca safe to dump as JSON."""
126
98
  # IntEnum is a subclass of int, so check it *before* the primitive branch.
127
99
  # We want {"name": "shutterClosed", "value": 1} not just 1.
128
- from enum import Enum
129
-
130
100
  if isinstance(value, Enum):
131
101
  return {"name": value.name, "value": value.value}
132
102
  if isinstance(value, (str, int, float, bool)) or value is None:
@@ -135,10 +105,18 @@ def _coerce_jsonable(value: Any) -> Any:
135
105
  return [_coerce_jsonable(item) for item in value]
136
106
  if isinstance(value, dict):
137
107
  return {str(k): _coerce_jsonable(v) for k, v in value.items()}
108
+ if isinstance(value, datetime):
109
+ return value.isoformat()
110
+ # alpyca's ``Rate`` (from AxisRates) carries a range and nothing else.
111
+ if hasattr(value, "Minimum") and hasattr(value, "Maximum"):
112
+ return {
113
+ "minimum": _coerce_jsonable(value.Minimum),
114
+ "maximum": _coerce_jsonable(value.Maximum),
115
+ }
138
116
  return str(value)
139
117
 
140
118
 
141
- def _capture(call: Callable[[], Any]) -> dict:
119
+ def capture(call: Callable[[], Any]) -> dict:
142
120
  """Run ``call()`` and capture either its value or its exception."""
143
121
  try:
144
122
  raw = call()
@@ -150,7 +128,21 @@ def _capture(call: Callable[[], Any]) -> dict:
150
128
  return {"ok": True, "value": _truncate(_coerce_jsonable(raw))}
151
129
 
152
130
 
131
+ def record(
132
+ outcome: dict, label: str, progress: ProbeProgress, counts: ProbeCounts
133
+ ) -> dict:
134
+ """Count one captured outcome and report it to the caller; returns it."""
135
+ counts.total += 1
136
+ if outcome["ok"]:
137
+ counts.ok += 1
138
+ progress.emit(label, True, str(outcome["value"]))
139
+ else:
140
+ progress.emit(label, False, outcome["error"])
141
+ return outcome
142
+
143
+
153
144
  def _build_environment(
145
+ kind: str,
154
146
  host: str,
155
147
  port: int,
156
148
  device_number: int,
@@ -168,6 +160,7 @@ def _build_environment(
168
160
 
169
161
  return {
170
162
  "timestamp_utc": datetime.now(timezone.utc).isoformat(),
163
+ "device_type": kind,
171
164
  "host": host,
172
165
  "port": port,
173
166
  "device_number": device_number,
@@ -181,29 +174,25 @@ def _build_environment(
181
174
 
182
175
 
183
176
  def _probe_device_metadata(
184
- dome: Any,
177
+ device: Any,
178
+ properties: Iterable[str],
185
179
  progress: ProbeProgress,
186
180
  counts: ProbeCounts,
187
181
  ) -> dict:
188
182
  section: dict = {}
189
- for name in _READ_ONLY_PROPERTIES:
190
- outcome = _capture(lambda n=name: getattr(dome, n))
191
- section[name] = outcome
192
- counts.total += 1
193
- if outcome["ok"]:
194
- counts.ok += 1
195
- progress.emit(name, True, str(outcome["value"]))
196
- else:
197
- progress.emit(name, False, outcome["error"])
183
+ for name in properties:
184
+ section[name] = record(
185
+ capture(lambda n=name: getattr(device, n)), name, progress, counts
186
+ )
198
187
  return section
199
188
 
200
189
 
201
190
  def _probe_supported_actions(
202
- dome: Any,
191
+ device: Any,
203
192
  progress: ProbeProgress,
204
193
  counts: ProbeCounts,
205
194
  ) -> dict:
206
- outcome = _capture(lambda: dome.SupportedActions)
195
+ outcome = capture(lambda: device.SupportedActions)
207
196
  counts.total += 1
208
197
  if outcome["ok"]:
209
198
  counts.ok += 1
@@ -230,7 +219,7 @@ def _send_command(
230
219
 
231
220
 
232
221
  def _probe_command_passthrough(
233
- dome: Any,
222
+ device: Any,
234
223
  *,
235
224
  allow_active: bool,
236
225
  progress: ProbeProgress,
@@ -247,7 +236,7 @@ def _probe_command_passthrough(
247
236
  commands = commands + _ACTIVE_COMMANDS
248
237
 
249
238
  for cmd in commands:
250
- entry = _send_command(dome.CommandString, cmd, False)
239
+ entry = _send_command(device.CommandString, cmd, False)
251
240
  section["command_string"].append(entry)
252
241
  counts.total += 1
253
242
  counts.ok += 1 if entry["ok"] else 0
@@ -257,7 +246,7 @@ def _probe_command_passthrough(
257
246
  entry.get("error") or str(entry.get("response", ""))[:80],
258
247
  )
259
248
 
260
- entry = _send_command(dome.CommandBool, cmd, False)
249
+ entry = _send_command(device.CommandBool, cmd, False)
261
250
  section["command_bool"].append(entry)
262
251
  counts.total += 1
263
252
  counts.ok += 1 if entry["ok"] else 0
@@ -270,7 +259,7 @@ def _probe_command_passthrough(
270
259
  if allow_active:
271
260
  # CommandBlind is fire-and-forget — only attempt under --allow-active.
272
261
  for cmd in commands:
273
- entry = _send_command(dome.CommandBlind, cmd, False)
262
+ entry = _send_command(device.CommandBlind, cmd, False)
274
263
  section["command_blind"].append(entry)
275
264
  counts.total += 1
276
265
  counts.ok += 1 if entry["ok"] else 0
@@ -421,104 +410,109 @@ def _collect_host_hints() -> dict:
421
410
  return _collect_posix_host_hints()
422
411
 
423
412
 
424
- def probe_dome(
413
+ def _attach_host_hints(
414
+ report: dict, host: str, target_is_local: bool, collect_host_info: bool | None
415
+ ) -> None:
416
+ if collect_host_info is None:
417
+ collect_host_info = target_is_local
418
+ reason = HOST_HINTS_REASON_LOCAL
419
+ else:
420
+ reason = HOST_HINTS_REASON_REQUESTED
421
+
422
+ if collect_host_info:
423
+ report["host_hints"] = {"collected_because": reason, **_collect_host_hints()}
424
+ elif reason == HOST_HINTS_REASON_REQUESTED:
425
+ report["host_hints_skipped_reason"] = "Disabled by the caller."
426
+ else:
427
+ report["host_hints_skipped_reason"] = (
428
+ f"{host} is not this machine, so the hints would describe the wrong "
429
+ "host; pass --collect-host-info to force them."
430
+ )
431
+
432
+
433
+ ExtraProbe = Callable[[Any, ProbeProgress, ProbeCounts], Any]
434
+
435
+
436
+ def probe_device(
437
+ kind: str,
425
438
  host: str,
426
439
  port: int,
427
440
  device_number: int = 0,
428
441
  *,
442
+ properties: tuple[str, ...],
443
+ device_factory: Callable[[str, int, str], Any],
429
444
  protocol: str = "http",
430
445
  allow_active: bool = False,
431
446
  collect_host_info: bool | None = None,
432
- dome_factory: Callable[[str, int, str], Any] | None = None,
447
+ extra_probes: Mapping[str, ExtraProbe] | None = None,
433
448
  progress: ProbeProgress | None = None,
434
449
  ) -> ProbeResult:
435
450
  """
436
- Build a JSON-serialisable diagnostic report for an Alpaca dome at
437
- ``protocol://host:port`` (device_number).
451
+ Build a JSON-serialisable diagnostic report for the Alpaca ``kind`` device
452
+ at ``protocol://host:port`` (device_number).
453
+
454
+ ``properties`` are read in order into ``device_metadata``. Each
455
+ ``extra_probes`` entry adds a section under its key, right after the
456
+ metadata; it receives the device, the progress sink and the counts.
438
457
 
439
458
  ``collect_host_info`` left at ``None`` attaches the local host hints when
440
459
  ``host`` is this machine and skips them otherwise, recording which in the
441
460
  report. ``True`` forces them for a remote target (they still describe the
442
461
  probing machine, not the target); ``False`` skips them for a local one.
443
462
 
444
- ``dome_factory(address, device_number, protocol)`` is only there for
445
- tests — production callers leave it ``None`` and the real
446
- ``alpaca.dome.Dome`` is used.
463
+ ``device_factory(address, device_number, protocol)`` is the alpyca class
464
+ in production and a stand-in in tests.
447
465
  """
448
466
  progress = progress or ProbeProgress()
449
467
  counts = ProbeCounts()
450
468
  address = f"{host}:{port}"
451
469
 
452
- if dome_factory is None:
453
- from alpaca.dome import Dome # local import: keep cost off cold paths
454
-
455
- dome_factory = Dome
456
-
457
- # Build dome client. A failure here is fatal (host unreachable, or the
470
+ # Build the client. A failure here is fatal (host unreachable, or the
458
471
  # alpyca constructor itself blew up) so we surface it to the caller.
459
472
  try:
460
- dome = dome_factory(address, device_number, protocol)
473
+ device = device_factory(address, device_number, protocol)
461
474
  except Exception as exc: # noqa: BLE001
462
475
  raise RuntimeError(
463
- f"Could not construct Alpaca Dome client for "
476
+ f"Could not construct Alpaca {kind.capitalize()} client for "
464
477
  f"{protocol}://{address} (device {device_number}): "
465
478
  f"{type(exc).__name__}: {exc}\n" + traceback.format_exc()
466
479
  ) from exc
467
480
 
468
- connected_outcome = _capture(lambda: dome.Connected)
469
- counts.total += 1
470
- counts.ok += 1 if connected_outcome["ok"] else 0
471
- progress.emit(
472
- "Connected",
473
- connected_outcome["ok"],
474
- connected_outcome.get("error") or str(connected_outcome.get("value")),
481
+ connected_outcome = record(
482
+ capture(lambda: device.Connected), "Connected", progress, counts
475
483
  )
476
484
 
477
485
  target_is_local = is_local_target(host)
478
486
  environment = _build_environment(
479
- host, port, device_number, protocol, connected_outcome
487
+ kind, host, port, device_number, protocol, connected_outcome
480
488
  )
481
489
  environment["target_is_local"] = target_is_local
482
490
 
483
- device_metadata = _probe_device_metadata(dome, progress, counts)
484
- supported_actions = _probe_supported_actions(dome, progress, counts)
485
- command_passthrough = _probe_command_passthrough(
486
- dome,
491
+ report: dict = {
492
+ "environment": environment,
493
+ "device_metadata": _probe_device_metadata(device, properties, progress, counts),
494
+ }
495
+ for key, run in (extra_probes or {}).items():
496
+ report[key] = run(device, progress, counts)
497
+ report["supported_actions"] = _probe_supported_actions(device, progress, counts)
498
+ report["command_passthrough"] = _probe_command_passthrough(
499
+ device,
487
500
  allow_active=allow_active,
488
501
  progress=progress,
489
502
  counts=counts,
490
503
  )
491
504
 
492
- report: dict = {
493
- "environment": environment,
494
- "device_metadata": device_metadata,
495
- "supported_actions": supported_actions,
496
- "command_passthrough": command_passthrough,
497
- }
498
-
499
- if collect_host_info is None:
500
- collect_host_info = target_is_local
501
- reason = HOST_HINTS_REASON_LOCAL
502
- else:
503
- reason = HOST_HINTS_REASON_REQUESTED
504
-
505
- if collect_host_info:
506
- report["host_hints"] = {"collected_because": reason, **_collect_host_hints()}
507
- elif reason == HOST_HINTS_REASON_REQUESTED:
508
- report["host_hints_skipped_reason"] = "Disabled by the caller."
509
- else:
510
- report["host_hints_skipped_reason"] = (
511
- f"{host} is not this machine, so the hints would describe the wrong "
512
- "host; pass --collect-host-info to force them."
513
- )
514
-
505
+ _attach_host_hints(report, host, target_is_local, collect_host_info)
515
506
  return ProbeResult(report=report, counts=counts)
516
507
 
517
508
 
518
- def iter_probe_labels() -> Iterable[str]:
509
+ def iter_probe_labels(
510
+ properties: Iterable[str], extra_labels: Iterable[str] = ()
511
+ ) -> Iterable[str]:
519
512
  """Convenience for tests / docs: enumerate the labels emitted by a run."""
520
513
  yield "Connected"
521
- yield from _READ_ONLY_PROPERTIES
514
+ yield from properties
515
+ yield from extra_labels
522
516
  yield "SupportedActions"
523
517
  for cmd in _READ_ONLY_COMMANDS:
524
518
  yield f"CommandString({cmd!r})"