arcsecond 3.10.2__tar.gz → 3.12.0__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 (126) hide show
  1. {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/tests.yml +1 -1
  2. {arcsecond-3.10.2 → arcsecond-3.12.0}/PKG-INFO +2 -1
  3. arcsecond-3.12.0/arcsecond/alpaca/commands.py +186 -0
  4. arcsecond-3.12.0/arcsecond/alpaca/dome_probe.py +432 -0
  5. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/endpoint.py +6 -2
  6. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/resources.py +6 -2
  7. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cli.py +8 -1
  8. arcsecond-3.12.0/arcsecond/hosting/__init__.py +3 -0
  9. arcsecond-3.12.0/arcsecond/hosting/backups.py +969 -0
  10. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/local.py +15 -4
  11. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/main.py +10 -10
  12. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/setup.py +2 -1
  13. arcsecond-3.12.0/arcsecond/hosting/utils.py +48 -0
  14. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/commands.py +73 -42
  15. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/proxy.py +32 -21
  16. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/registry.py +8 -4
  17. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/base.py +4 -3
  18. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/filewatch.py +8 -6
  19. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/opencv.py +10 -5
  20. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/targets.py +35 -14
  21. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/config.js +4 -1
  22. arcsecond-3.12.0/docs/backups.md +158 -0
  23. arcsecond-3.12.0/docs/install-windows.md +215 -0
  24. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/install.md +7 -0
  25. {arcsecond-3.10.2 → arcsecond-3.12.0}/package-lock.json +12 -12
  26. {arcsecond-3.10.2 → arcsecond-3.12.0}/pyproject.toml +2 -1
  27. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_api_endpoint.py +3 -1
  28. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_targets.py +3 -1
  29. arcsecond-3.12.0/tests/cloud/uploader/datafiles/__init__.py +0 -0
  30. arcsecond-3.12.0/tests/test_alpaca_probe.py +403 -0
  31. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_cli.py +3 -3
  32. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_hosting_local.py +23 -7
  33. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_targets_planning.py +4 -1
  34. arcsecond-3.10.2/arcsecond/hosting/__init__.py +0 -2
  35. arcsecond-3.10.2/arcsecond/hosting/utils.py +0 -24
  36. {arcsecond-3.10.2 → arcsecond-3.12.0}/.docker/Dockerfile_postgres +0 -0
  37. {arcsecond-3.10.2 → arcsecond-3.12.0}/.docker/Dockerfile_redis +0 -0
  38. {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/dependabot.yml +0 -0
  39. {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/docsdeploy.yml +0 -0
  40. {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/pythonpublish.yml +0 -0
  41. {arcsecond-3.10.2 → arcsecond-3.12.0}/.gitignore +0 -0
  42. {arcsecond-3.10.2 → arcsecond-3.12.0}/LICENSE +0 -0
  43. {arcsecond-3.10.2 → arcsecond-3.12.0}/Makefile +0 -0
  44. {arcsecond-3.10.2 → arcsecond-3.12.0}/README.md +0 -0
  45. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/__init__.py +0 -0
  46. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/__version__.py +0 -0
  47. {arcsecond-3.10.2/arcsecond/cloud/uploader/allskycameraimages → arcsecond-3.12.0/arcsecond/alpaca}/__init__.py +0 -0
  48. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/__init__.py +0 -0
  49. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/config.py +0 -0
  50. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/constants.py +0 -0
  51. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/main.py +0 -0
  52. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/__init__.py +0 -0
  53. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/auth.py +0 -0
  54. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/resources.py +0 -0
  55. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  56. {arcsecond-3.10.2/arcsecond/cloud/uploader/datafiles → arcsecond-3.12.0/arcsecond/cloud/uploader/allskycameraimages}/__init__.py +0 -0
  57. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
  58. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
  59. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
  60. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
  61. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/constants.py +0 -0
  62. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/context.py +0 -0
  63. {arcsecond-3.10.2/arcsecond/imagesources → arcsecond-3.12.0/arcsecond/cloud/uploader/datafiles}/__init__.py +0 -0
  64. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  65. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  66. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  67. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  68. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/errors.py +0 -0
  69. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/logger.py +0 -0
  70. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  71. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/utils.py +0 -0
  72. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/walker.py +0 -0
  73. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploads.py +0 -0
  74. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/errors.py +0 -0
  75. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/checks.py +0 -0
  76. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/constants.py +0 -0
  77. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/__init__.py +0 -0
  78. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/constants.py +0 -0
  79. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/containers.py +0 -0
  80. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  81. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/images.py +0 -0
  82. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/utils.py +0 -0
  83. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/__init__.py +0 -0
  84. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/client.py +0 -0
  85. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/utils.py +0 -0
  86. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
  87. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/validation.py +0 -0
  88. {arcsecond-3.10.2/arcsecond/imagesources/sources → arcsecond-3.12.0/arcsecond/imagesources}/__init__.py +0 -0
  89. {arcsecond-3.10.2/tests → arcsecond-3.12.0/arcsecond/imagesources/sources}/__init__.py +0 -0
  90. {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/options.py +0 -0
  91. {arcsecond-3.10.2 → arcsecond-3.12.0}/deploy.sh +0 -0
  92. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/theme/custom.css +0 -0
  93. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/theme/index.js +0 -0
  94. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/api-basics.md +0 -0
  95. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/img/icon-logo.png +0 -0
  96. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/index.md +0 -0
  97. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/public/img/icon-logo.png +0 -0
  98. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/resources.md +0 -0
  99. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/rotate-postgres-password.md +0 -0
  100. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/upload.md +0 -0
  101. {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/webcam.md +0 -0
  102. {arcsecond-3.10.2 → arcsecond-3.12.0}/examples/example_upload_files.py +0 -0
  103. {arcsecond-3.10.2 → arcsecond-3.12.0}/examples/example_upload_images.py +0 -0
  104. {arcsecond-3.10.2 → arcsecond-3.12.0}/package.json +0 -0
  105. {arcsecond-3.10.2 → arcsecond-3.12.0}/poetry.lock +0 -0
  106. {arcsecond-3.10.2 → arcsecond-3.12.0}/requirements.txt +0 -0
  107. {arcsecond-3.10.2 → arcsecond-3.12.0}/setup.cfg +0 -0
  108. {arcsecond-3.10.2 → arcsecond-3.12.0}/setup.py +0 -0
  109. {arcsecond-3.10.2 → arcsecond-3.12.0}/sonar-project.properties +0 -0
  110. {arcsecond-3.10.2/tests/api → arcsecond-3.12.0/tests}/__init__.py +0 -0
  111. {arcsecond-3.10.2/tests/cloud → arcsecond-3.12.0/tests/api}/__init__.py +0 -0
  112. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_api.py +0 -0
  113. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_config.py +0 -0
  114. {arcsecond-3.10.2/tests/cloud/uploader → arcsecond-3.12.0/tests/cloud}/__init__.py +0 -0
  115. {arcsecond-3.10.2/tests/cloud/uploader/allskycameraimages → arcsecond-3.12.0/tests/cloud/uploader}/__init__.py +0 -0
  116. {arcsecond-3.10.2/tests/cloud/uploader/datafiles → arcsecond-3.12.0/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
  117. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
  118. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
  119. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  120. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  121. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  122. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  123. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  124. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/conftest.py +0 -0
  125. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/fixtures/file1.fits +0 -0
  126. {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/utils.py +0 -0
@@ -58,7 +58,7 @@ jobs:
58
58
  path: .
59
59
 
60
60
  - name: SonarQube Scan
61
- uses: SonarSource/sonarcloud-github-action@master
61
+ uses: SonarSource/sonarqube-scan-action@v5
62
62
  env:
63
63
  SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
64
64
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcsecond
3
- Version: 3.10.2
3
+ Version: 3.12.0
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
@@ -36,6 +36,7 @@ Classifier: Operating System :: OS Independent
36
36
  Classifier: Programming Language :: Python :: 3
37
37
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
38
38
  Requires-Python: >=3.9
39
+ Requires-Dist: alpyca==3.1.2
39
40
  Requires-Dist: click>=8
40
41
  Requires-Dist: configparser
41
42
  Requires-Dist: docker
@@ -0,0 +1,186 @@
1
+ """
2
+ Click command group: ``arcsecond alpaca``.
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 optional local host hints).
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.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from ..errors import ArcsecondError
24
+ from .dome_probe import ProbeProgress, probe_dome
25
+
26
+
27
+ @click.group(name="alpaca", help="Diagnostics for local ASCOM Alpaca devices.")
28
+ def alpaca_group() -> None:
29
+ pass
30
+
31
+
32
+ @alpaca_group.group(name="probe", help="Inspect an Alpaca device read-only.")
33
+ def probe_group() -> None:
34
+ pass
35
+
36
+
37
+ _COMMON_OPTIONS = [
38
+ click.option(
39
+ "--host",
40
+ required=True,
41
+ metavar="HOST",
42
+ help="Alpaca server hostname or IP (without scheme).",
43
+ ),
44
+ click.option(
45
+ "--port",
46
+ type=int,
47
+ required=True,
48
+ metavar="PORT",
49
+ help="Alpaca server TCP port (e.g. 11111).",
50
+ ),
51
+ click.option(
52
+ "--device-number",
53
+ type=int,
54
+ default=0,
55
+ show_default=True,
56
+ help="Alpaca device number on the server.",
57
+ ),
58
+ click.option(
59
+ "--protocol",
60
+ type=click.Choice(["http", "https"], case_sensitive=False),
61
+ default="http",
62
+ show_default=True,
63
+ help="Protocol to reach the Alpaca server with.",
64
+ ),
65
+ click.option(
66
+ "--allow-active",
67
+ is_flag=True,
68
+ default=False,
69
+ help=(
70
+ "Also send CommandBlind and active-class probes. OFF by default "
71
+ "to keep the run safe on real hardware."
72
+ ),
73
+ ),
74
+ click.option(
75
+ "--collect-host-info",
76
+ is_flag=True,
77
+ default=False,
78
+ help=(
79
+ "Additionally collect best-effort local OS hints (open ports, "
80
+ "COM ProgIDs matching TCS/Galil). Read-only."
81
+ ),
82
+ ),
83
+ click.option(
84
+ "--output",
85
+ "output_path",
86
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True),
87
+ default=None,
88
+ help="Where to write the JSON report. Defaults to cwd with a UTC timestamp.",
89
+ ),
90
+ ]
91
+
92
+
93
+ def _add_options(options):
94
+ def _wrap(fn):
95
+ for opt in reversed(options):
96
+ fn = opt(fn)
97
+ return fn
98
+
99
+ return _wrap
100
+
101
+
102
+ def _default_output_path(kind: str) -> str:
103
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
104
+ return str(Path.cwd() / f"alpaca_{kind}_probe_{stamp}.json")
105
+
106
+
107
+ def _format_progress(label: str, ok: bool, detail: str | None) -> str:
108
+ tag = click.style("[OK ]", fg="green") if ok else click.style("[ERR]", fg="red")
109
+ suffix = f" — {detail}" if detail else ""
110
+ return f" {tag} {label}{suffix}"
111
+
112
+
113
+ @probe_group.command(
114
+ name="dome",
115
+ help=(
116
+ "Probe a local Alpaca dome device read-only and write a JSON report.\n\n"
117
+ "Captures device metadata, SupportedActions, and the behaviour of the "
118
+ "legacy CommandString / CommandBool / CommandBlind passthroughs. "
119
+ "Useful for figuring out whether a proprietary driver (e.g. TCSGalil) "
120
+ "exposes any vendor-specific extension surface beyond the standard "
121
+ "ASCOM IDome interface."
122
+ ),
123
+ )
124
+ @_add_options(_COMMON_OPTIONS)
125
+ def probe_dome_cmd(
126
+ host: str,
127
+ port: int,
128
+ device_number: int,
129
+ protocol: str,
130
+ allow_active: bool,
131
+ collect_host_info: bool,
132
+ output_path: str | None,
133
+ ) -> None:
134
+ output_path = output_path or _default_output_path("dome")
135
+
136
+ click.echo(
137
+ click.style("Alpaca dome probe", bold=True)
138
+ + f" → {protocol}://{host}:{port} (device {device_number})"
139
+ )
140
+ if allow_active:
141
+ click.echo(
142
+ click.style(" ⚠ ", fg="yellow")
143
+ + "--allow-active is ON: CommandBlind and active-class probes will be sent."
144
+ )
145
+
146
+ progress = ProbeProgress(
147
+ on_probe=lambda label, ok, detail: click.echo(
148
+ _format_progress(label, ok, detail)
149
+ )
150
+ )
151
+
152
+ try:
153
+ result = probe_dome(
154
+ host=host,
155
+ port=port,
156
+ device_number=device_number,
157
+ protocol=protocol.lower(),
158
+ allow_active=allow_active,
159
+ collect_host_info=collect_host_info,
160
+ progress=progress,
161
+ )
162
+ except RuntimeError as exc:
163
+ raise ArcsecondError(str(exc)) from exc
164
+
165
+ try:
166
+ with open(output_path, "w", encoding="utf-8") as fp:
167
+ json.dump(result.report, fp, indent=2, sort_keys=False)
168
+ fp.write("\n")
169
+ except OSError as exc:
170
+ raise ArcsecondError(
171
+ f"Could not write report to {output_path}: {exc}"
172
+ ) from exc
173
+
174
+ counts = result.counts
175
+ supported = result.report.get("supported_actions", {})
176
+ supported_n = (
177
+ len(supported.get("value") or []) if supported.get("ok") else 0
178
+ )
179
+
180
+ click.echo("")
181
+ click.echo(click.style("Summary", bold=True))
182
+ click.echo(f" Probes: {counts.ok}/{counts.total} OK")
183
+ click.echo(f" SupportedActions: {supported_n} entries")
184
+ click.echo(
185
+ f" Report written to {click.style(os.fspath(output_path), fg='cyan')}"
186
+ )
@@ -0,0 +1,432 @@
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
+ * optional best-effort local host hints (open ports, COM ProgIDs)
20
+
21
+ It never issues motion-class commands. ``CommandBlind`` and active Galil DMC
22
+ verbs are gated behind ``allow_active=True``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import platform
29
+ import socket
30
+ import subprocess
31
+ import sys
32
+ import traceback
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime, timezone
35
+ from typing import Any, Callable, Iterable
36
+
37
+ # Read-only device descriptors and dome state, in the order we want them
38
+ # presented in the report. Names are PascalCase to match the alpyca surface.
39
+ _READ_ONLY_PROPERTIES: tuple[str, ...] = (
40
+ "Name",
41
+ "Description",
42
+ "DriverInfo",
43
+ "DriverVersion",
44
+ "InterfaceVersion",
45
+ "ShutterStatus",
46
+ "Altitude",
47
+ "Azimuth",
48
+ "AtHome",
49
+ "AtPark",
50
+ "Slewing",
51
+ "Slaved",
52
+ "CanFindHome",
53
+ "CanPark",
54
+ "CanSetAltitude",
55
+ "CanSetPark",
56
+ "CanSetShutter",
57
+ "CanSlave",
58
+ "CanSyncAzimuth",
59
+ )
60
+
61
+ # Safe Galil DMC read-style verbs: print/report values without commanding
62
+ # motion. ``MG`` is "message" (print expression), ``TE`` is "tell error",
63
+ # ``TP`` is "tell position".
64
+ _READ_ONLY_COMMANDS: tuple[str, ...] = (
65
+ "MG TIME",
66
+ "MG _BGA",
67
+ "TE",
68
+ "TP",
69
+ )
70
+
71
+ # Active commands: kept separate and only sent when allow_active=True.
72
+ # ``XQ#STATUS`` jumps to a label called STATUS if it exists; whether that
73
+ # label has side effects depends on the driver, hence "active".
74
+ _ACTIVE_COMMANDS: tuple[str, ...] = (
75
+ "XQ#STATUS",
76
+ )
77
+
78
+ _RESPONSE_TRUNCATE = 2000
79
+
80
+
81
+ @dataclass
82
+ class ProbeProgress:
83
+ """Optional callback bag for streaming progress to the caller (CLI)."""
84
+
85
+ on_probe: Callable[[str, bool, str | None], None] | None = None
86
+
87
+ def emit(self, label: str, ok: bool, detail: str | None = None) -> None:
88
+ if self.on_probe is not None:
89
+ self.on_probe(label, ok, detail)
90
+
91
+
92
+ @dataclass
93
+ class ProbeCounts:
94
+ total: int = 0
95
+ ok: int = 0
96
+
97
+ @property
98
+ def failed(self) -> int:
99
+ return self.total - self.ok
100
+
101
+
102
+ @dataclass
103
+ class ProbeResult:
104
+ report: dict
105
+ counts: ProbeCounts = field(default_factory=ProbeCounts)
106
+
107
+
108
+ def _truncate(value: Any) -> Any:
109
+ if isinstance(value, str) and len(value) > _RESPONSE_TRUNCATE:
110
+ return value[:_RESPONSE_TRUNCATE] + f"... [truncated, total {len(value)} chars]"
111
+ return value
112
+
113
+
114
+ def _coerce_jsonable(value: Any) -> Any:
115
+ """Make values from alpyca safe to dump as JSON."""
116
+ # IntEnum is a subclass of int, so check it *before* the primitive branch.
117
+ # We want {"name": "shutterClosed", "value": 1} not just 1.
118
+ from enum import Enum
119
+ if isinstance(value, Enum):
120
+ return {"name": value.name, "value": value.value}
121
+ if isinstance(value, (str, int, float, bool)) or value is None:
122
+ return value
123
+ if isinstance(value, (list, tuple)):
124
+ return [_coerce_jsonable(item) for item in value]
125
+ if isinstance(value, dict):
126
+ return {str(k): _coerce_jsonable(v) for k, v in value.items()}
127
+ return str(value)
128
+
129
+
130
+ def _capture(call: Callable[[], Any]) -> dict:
131
+ """Run ``call()`` and capture either its value or its exception."""
132
+ try:
133
+ raw = call()
134
+ except Exception as exc: # noqa: BLE001 — errors are data here
135
+ return {
136
+ "ok": False,
137
+ "error": f"{type(exc).__name__}: {exc}",
138
+ }
139
+ return {"ok": True, "value": _truncate(_coerce_jsonable(raw))}
140
+
141
+
142
+ def _build_environment(
143
+ host: str,
144
+ port: int,
145
+ device_number: int,
146
+ protocol: str,
147
+ connected_outcome: dict,
148
+ ) -> dict:
149
+ try:
150
+ from importlib.metadata import PackageNotFoundError, version
151
+
152
+ alpaca_version = version("alpyca")
153
+ except PackageNotFoundError:
154
+ alpaca_version = "unknown"
155
+ except Exception: # noqa: BLE001
156
+ alpaca_version = "unknown"
157
+
158
+ return {
159
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
160
+ "host": host,
161
+ "port": port,
162
+ "device_number": device_number,
163
+ "protocol": protocol,
164
+ "alpyca_version": alpaca_version,
165
+ "python_version": sys.version.split()[0],
166
+ "platform": platform.platform(),
167
+ "hostname": socket.gethostname(),
168
+ "connected": connected_outcome,
169
+ }
170
+
171
+
172
+ def _probe_device_metadata(
173
+ dome: Any,
174
+ progress: ProbeProgress,
175
+ counts: ProbeCounts,
176
+ ) -> dict:
177
+ section: dict = {}
178
+ for name in _READ_ONLY_PROPERTIES:
179
+ outcome = _capture(lambda n=name: getattr(dome, n))
180
+ section[name] = outcome
181
+ counts.total += 1
182
+ if outcome["ok"]:
183
+ counts.ok += 1
184
+ progress.emit(name, True, str(outcome["value"]))
185
+ else:
186
+ progress.emit(name, False, outcome["error"])
187
+ return section
188
+
189
+
190
+ def _probe_supported_actions(
191
+ dome: Any,
192
+ progress: ProbeProgress,
193
+ counts: ProbeCounts,
194
+ ) -> dict:
195
+ outcome = _capture(lambda: dome.SupportedActions)
196
+ counts.total += 1
197
+ if outcome["ok"]:
198
+ counts.ok += 1
199
+ actions = outcome["value"] or []
200
+ progress.emit("SupportedActions", True, f"{len(actions)} entries")
201
+ else:
202
+ progress.emit("SupportedActions", False, outcome["error"])
203
+ return outcome
204
+
205
+
206
+ def _send_command(
207
+ method: Callable[..., Any],
208
+ command: str,
209
+ raw: bool,
210
+ ) -> dict:
211
+ entry: dict = {"command": command, "raw": raw}
212
+ try:
213
+ response = method(command, raw)
214
+ except Exception as exc: # noqa: BLE001
215
+ entry.update(ok=False, error=f"{type(exc).__name__}: {exc}")
216
+ else:
217
+ entry.update(ok=True, response=_truncate(_coerce_jsonable(response)))
218
+ return entry
219
+
220
+
221
+ def _probe_command_passthrough(
222
+ dome: Any,
223
+ *,
224
+ allow_active: bool,
225
+ progress: ProbeProgress,
226
+ counts: ProbeCounts,
227
+ ) -> dict:
228
+ section: dict = {
229
+ "command_string": [],
230
+ "command_bool": [],
231
+ "command_blind": [],
232
+ }
233
+
234
+ commands: tuple[str, ...] = _READ_ONLY_COMMANDS
235
+ if allow_active:
236
+ commands = commands + _ACTIVE_COMMANDS
237
+
238
+ for cmd in commands:
239
+ entry = _send_command(dome.CommandString, cmd, False)
240
+ section["command_string"].append(entry)
241
+ counts.total += 1
242
+ counts.ok += 1 if entry["ok"] else 0
243
+ progress.emit(
244
+ f"CommandString({cmd!r})",
245
+ entry["ok"],
246
+ entry.get("error") or str(entry.get("response", ""))[:80],
247
+ )
248
+
249
+ entry = _send_command(dome.CommandBool, cmd, False)
250
+ section["command_bool"].append(entry)
251
+ counts.total += 1
252
+ counts.ok += 1 if entry["ok"] else 0
253
+ progress.emit(
254
+ f"CommandBool({cmd!r})",
255
+ entry["ok"],
256
+ entry.get("error") or str(entry.get("response", ""))[:80],
257
+ )
258
+
259
+ if allow_active:
260
+ # CommandBlind is fire-and-forget — only attempt under --allow-active.
261
+ for cmd in commands:
262
+ entry = _send_command(dome.CommandBlind, cmd, False)
263
+ section["command_blind"].append(entry)
264
+ counts.total += 1
265
+ counts.ok += 1 if entry["ok"] else 0
266
+ progress.emit(
267
+ f"CommandBlind({cmd!r})",
268
+ entry["ok"],
269
+ entry.get("error") or "sent",
270
+ )
271
+ else:
272
+ section["command_blind_skipped_reason"] = (
273
+ "CommandBlind is fire-and-forget; gated behind --allow-active."
274
+ )
275
+
276
+ return section
277
+
278
+
279
+ def _safe_subprocess(cmd: list[str], timeout: float = 5.0) -> dict:
280
+ try:
281
+ completed = subprocess.run(
282
+ cmd,
283
+ capture_output=True,
284
+ text=True,
285
+ timeout=timeout,
286
+ check=False,
287
+ )
288
+ except FileNotFoundError:
289
+ return {"ok": False, "error": f"command not found: {cmd[0]}"}
290
+ except subprocess.TimeoutExpired:
291
+ return {"ok": False, "error": "timeout"}
292
+ except Exception as exc: # noqa: BLE001
293
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
294
+ return {
295
+ "ok": True,
296
+ "returncode": completed.returncode,
297
+ "stdout": _truncate(completed.stdout),
298
+ "stderr": _truncate(completed.stderr),
299
+ }
300
+
301
+
302
+ def _collect_windows_host_hints() -> dict:
303
+ hints: dict = {"os": "windows"}
304
+ hints["netstat"] = _safe_subprocess(["netstat", "-ano"], timeout=8.0)
305
+
306
+ progids: list[str] = []
307
+ try:
308
+ import winreg # type: ignore[import-not-found]
309
+ except ImportError:
310
+ hints["com_progids"] = {"ok": False, "error": "winreg unavailable"}
311
+ else:
312
+ try:
313
+ root = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "")
314
+ i = 0
315
+ while True:
316
+ try:
317
+ name = winreg.EnumKey(root, i)
318
+ except OSError:
319
+ break
320
+ upper = name.upper()
321
+ if "TCS" in upper or "GALIL" in upper:
322
+ progids.append(name)
323
+ i += 1
324
+ winreg.CloseKey(root)
325
+ hints["com_progids"] = {"ok": True, "matches": progids}
326
+ except Exception as exc: # noqa: BLE001
327
+ hints["com_progids"] = {
328
+ "ok": False,
329
+ "error": f"{type(exc).__name__}: {exc}",
330
+ }
331
+ return hints
332
+
333
+
334
+ def _collect_posix_host_hints() -> dict:
335
+ hints: dict = {"os": platform.system().lower()}
336
+ # Try ss first (Linux), fall back to lsof (macOS / Linux).
337
+ hints["listening_sockets"] = _safe_subprocess(["ss", "-ltnp"], timeout=5.0)
338
+ if not hints["listening_sockets"].get("ok"):
339
+ hints["listening_sockets"] = _safe_subprocess(
340
+ ["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"], timeout=5.0
341
+ )
342
+ return hints
343
+
344
+
345
+ def _collect_host_hints() -> dict:
346
+ if os.name == "nt":
347
+ return _collect_windows_host_hints()
348
+ return _collect_posix_host_hints()
349
+
350
+
351
+ def probe_dome(
352
+ host: str,
353
+ port: int,
354
+ device_number: int = 0,
355
+ *,
356
+ protocol: str = "http",
357
+ allow_active: bool = False,
358
+ collect_host_info: bool = False,
359
+ dome_factory: Callable[[str, int, str], Any] | None = None,
360
+ progress: ProbeProgress | None = None,
361
+ ) -> ProbeResult:
362
+ """
363
+ Build a JSON-serialisable diagnostic report for an Alpaca dome at
364
+ ``protocol://host:port`` (device_number).
365
+
366
+ ``dome_factory(address, device_number, protocol)`` is only there for
367
+ tests — production callers leave it ``None`` and the real
368
+ ``alpaca.dome.Dome`` is used.
369
+ """
370
+ progress = progress or ProbeProgress()
371
+ counts = ProbeCounts()
372
+ address = f"{host}:{port}"
373
+
374
+ if dome_factory is None:
375
+ from alpaca.dome import Dome # local import: keep cost off cold paths
376
+
377
+ dome_factory = Dome
378
+
379
+ # Build dome client. A failure here is fatal (host unreachable, or the
380
+ # alpyca constructor itself blew up) so we surface it to the caller.
381
+ try:
382
+ dome = dome_factory(address, device_number, protocol)
383
+ except Exception as exc: # noqa: BLE001
384
+ raise RuntimeError(
385
+ f"Could not construct Alpaca Dome client for "
386
+ f"{protocol}://{address} (device {device_number}): "
387
+ f"{type(exc).__name__}: {exc}\n"
388
+ + traceback.format_exc()
389
+ ) from exc
390
+
391
+ connected_outcome = _capture(lambda: dome.Connected)
392
+ counts.total += 1
393
+ counts.ok += 1 if connected_outcome["ok"] else 0
394
+ progress.emit(
395
+ "Connected", connected_outcome["ok"],
396
+ connected_outcome.get("error") or str(connected_outcome.get("value")),
397
+ )
398
+
399
+ environment = _build_environment(
400
+ host, port, device_number, protocol, connected_outcome
401
+ )
402
+
403
+ device_metadata = _probe_device_metadata(dome, progress, counts)
404
+ supported_actions = _probe_supported_actions(dome, progress, counts)
405
+ command_passthrough = _probe_command_passthrough(
406
+ dome,
407
+ allow_active=allow_active,
408
+ progress=progress,
409
+ counts=counts,
410
+ )
411
+
412
+ report: dict = {
413
+ "environment": environment,
414
+ "device_metadata": device_metadata,
415
+ "supported_actions": supported_actions,
416
+ "command_passthrough": command_passthrough,
417
+ }
418
+
419
+ if collect_host_info:
420
+ report["host_hints"] = _collect_host_hints()
421
+
422
+ return ProbeResult(report=report, counts=counts)
423
+
424
+
425
+ def iter_probe_labels() -> Iterable[str]:
426
+ """Convenience for tests / docs: enumerate the labels emitted by a run."""
427
+ yield "Connected"
428
+ yield from _READ_ONLY_PROPERTIES
429
+ yield "SupportedActions"
430
+ for cmd in _READ_ONLY_COMMANDS:
431
+ yield f"CommandString({cmd!r})"
432
+ yield f"CommandBool({cmd!r})"
@@ -68,7 +68,9 @@ class ArcsecondAPIEndpoint(object):
68
68
  payload = {}
69
69
  if json:
70
70
  payload.update(json)
71
- payload.update({key: value for key, value in fields.items() if value is not None})
71
+ payload.update(
72
+ {key: value for key, value in fields.items() if value is not None}
73
+ )
72
74
  return payload or None
73
75
 
74
76
  def _extract_results(self, response):
@@ -151,7 +153,9 @@ class ArcsecondAPIEndpoint(object):
151
153
 
152
154
  identifier = self._extract_identifier(existing)
153
155
  if identifier is None:
154
- return None, ArcsecondError(f"Could not find an identifier for '{match_value}'.")
156
+ return None, ArcsecondError(
157
+ f"Could not find an identifier for '{match_value}'."
158
+ )
155
159
 
156
160
  return self.update(identifier, json=payload)
157
161
 
@@ -90,7 +90,9 @@ class ArcsecondTargetListsResource(ArcsecondAPIEndpoint):
90
90
  )
91
91
  return ArcsecondAPIEndpoint.update(self, id_name_uuid, json=payload)
92
92
 
93
- def upsert(self, match_field="name", json=None, targets=None, target_key=None, **fields):
93
+ def upsert(
94
+ self, match_field="name", json=None, targets=None, target_key=None, **fields
95
+ ):
94
96
  payload = self._build_payload(
95
97
  json=json, targets=targets, target_key=target_key, **fields
96
98
  )
@@ -104,7 +106,9 @@ class ArcsecondTargetListsResource(ArcsecondAPIEndpoint):
104
106
 
105
107
  def set_targets(self, id_name_uuid, targets, target_key=None):
106
108
  target_key = target_key or self.target_relation_key
107
- return self.update(id_name_uuid, **{target_key: self._normalise_target_payloads(targets)})
109
+ return self.update(
110
+ id_name_uuid, **{target_key: self._normalise_target_payloads(targets)}
111
+ )
108
112
 
109
113
  def clear_targets(self, id_name_uuid, target_key=None):
110
114
  return self.set_targets(id_name_uuid, [], target_key=target_key)