arcsecond 3.17.2__tar.gz → 3.18.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 (112) hide show
  1. {arcsecond-3.17.2 → arcsecond-3.18.0}/PKG-INFO +1 -1
  2. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/commands.py +45 -13
  3. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/detection.py +15 -11
  4. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/proxy.py +6 -0
  5. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/registry.py +7 -2
  6. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/network.py +42 -0
  7. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/store.py +30 -12
  8. {arcsecond-3.17.2 → arcsecond-3.18.0}/pyproject.toml +1 -1
  9. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_commands.py +78 -1
  10. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_detection.py +29 -0
  11. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_network.py +66 -1
  12. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_proxy.py +3 -2
  13. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_store.py +52 -1
  14. {arcsecond-3.17.2 → arcsecond-3.18.0}/uv.lock +1 -1
  15. {arcsecond-3.17.2 → arcsecond-3.18.0}/.docker/Dockerfile_postgres +0 -0
  16. {arcsecond-3.17.2 → arcsecond-3.18.0}/.docker/Dockerfile_redis +0 -0
  17. {arcsecond-3.17.2 → arcsecond-3.18.0}/.flake8 +0 -0
  18. {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/dependabot.yml +0 -0
  19. {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/workflows/pythonpublish.yml +0 -0
  20. {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/workflows/tests.yml +0 -0
  21. {arcsecond-3.17.2 → arcsecond-3.18.0}/.gitignore +0 -0
  22. {arcsecond-3.17.2 → arcsecond-3.18.0}/LICENSE +0 -0
  23. {arcsecond-3.17.2 → arcsecond-3.18.0}/Makefile +0 -0
  24. {arcsecond-3.17.2 → arcsecond-3.18.0}/README.md +0 -0
  25. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/__init__.py +0 -0
  26. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/__version__.py +0 -0
  27. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/__init__.py +0 -0
  28. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/commands.py +0 -0
  29. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/dome_probe.py +0 -0
  30. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/__init__.py +0 -0
  31. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/config.py +0 -0
  32. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/constants.py +0 -0
  33. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/endpoint.py +0 -0
  34. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/main.py +0 -0
  35. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/resources.py +0 -0
  36. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cli.py +0 -0
  37. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/__init__.py +0 -0
  38. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/auth.py +0 -0
  39. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/resources.py +0 -0
  40. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  41. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/constants.py +0 -0
  42. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/context.py +0 -0
  43. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  44. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  45. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  46. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  47. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  48. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/errors.py +0 -0
  49. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/logger.py +0 -0
  50. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  51. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/utils.py +0 -0
  52. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/walker.py +0 -0
  53. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploads.py +0 -0
  54. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/errors.py +0 -0
  55. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/__init__.py +0 -0
  56. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/backups.py +0 -0
  57. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/checks.py +0 -0
  58. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/constants.py +0 -0
  59. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/database.py +0 -0
  60. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/__init__.py +0 -0
  61. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/constants.py +0 -0
  62. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/containers.py +0 -0
  63. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  64. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/images.py +0 -0
  65. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/utils.py +0 -0
  66. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/__init__.py +0 -0
  67. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/client.py +0 -0
  68. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/utils.py +0 -0
  69. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/local.py +0 -0
  70. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/main.py +0 -0
  71. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
  72. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/setup.py +0 -0
  73. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/utils.py +0 -0
  74. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/validation.py +0 -0
  75. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/__init__.py +0 -0
  76. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/runtime.py +0 -0
  77. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/__init__.py +0 -0
  78. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/base.py +0 -0
  79. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
  80. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/opencv.py +0 -0
  81. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/options.py +0 -0
  82. {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/targets.py +0 -0
  83. {arcsecond-3.17.2 → arcsecond-3.18.0}/examples/example_upload_files.py +0 -0
  84. {arcsecond-3.17.2 → arcsecond-3.18.0}/requirements.txt +0 -0
  85. {arcsecond-3.17.2 → arcsecond-3.18.0}/setup.cfg +0 -0
  86. {arcsecond-3.17.2 → arcsecond-3.18.0}/setup.py +0 -0
  87. {arcsecond-3.17.2 → arcsecond-3.18.0}/sonar-project.properties +0 -0
  88. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/__init__.py +0 -0
  89. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/__init__.py +0 -0
  90. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_api.py +0 -0
  91. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_api_endpoint.py +0 -0
  92. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_config.py +0 -0
  93. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_targets.py +0 -0
  94. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/__init__.py +0 -0
  95. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/__init__.py +0 -0
  96. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  97. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  98. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  99. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  100. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  101. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  102. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/conftest.py +0 -0
  103. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/fixtures/file1.fits +0 -0
  104. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_alpaca_probe.py +0 -0
  105. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_cli.py +0 -0
  106. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_backups_restore.py +0 -0
  107. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_database.py +0 -0
  108. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_local.py +0 -0
  109. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_utils_secret_key.py +0 -0
  110. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_webcam.py +0 -0
  111. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_targets_planning.py +0 -0
  112. {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.17.2
3
+ Version: 3.18.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
@@ -8,7 +8,9 @@ Three commands, three jobs, and no overlap between them:
8
8
  not a separate thing to learn: same list, same ``add``,
9
9
  same ``forget``, and the URL is simply what you pass to
10
10
  ``add`` instead of a device index.
11
- ``arcsecond allsky`` the same, for all-sky cameras writing JPEGs to disk.
11
+ ``arcsecond allsky`` the same, for all-sky cameras: the JPEG their software
12
+ keeps up to date, given as a path when that software
13
+ runs here and as an address when it runs elsewhere.
12
14
  ``arcsecond proxy`` starting and inspecting the one proxy that serves
13
15
  everything registered above.
14
16
 
@@ -47,6 +49,7 @@ import click
47
49
 
48
50
  from . import detection, runtime, store
49
51
  from .sources.network import (
52
+ HTTP_SCHEMES,
50
53
  RTSP_SCHEMES,
51
54
  SUPPORTED_SCHEMES,
52
55
  build_network_source,
@@ -639,13 +642,15 @@ def webcam_test_cmd(camera, timeout):
639
642
  _test_usb(registered)
640
643
  return
641
644
 
642
- if registered is not None and registered.kind == NET:
645
+ # Every camera registered by address is tested the same way, whether it is
646
+ # a network camera or an all-sky camera published over HTTP.
647
+ if registered is not None and registered.url:
643
648
  _test_url(_expand_env_vars(registered.url), timeout, suggest=False)
644
649
  return
645
650
 
646
651
  if registered is not None:
647
652
  _fail(
648
- f"{registered.id} is an all-sky camera.",
653
+ f"{registered.id} is an all-sky camera writing to this machine.",
649
654
  f"\nIt reads images from {registered.target} — check that path directly.",
650
655
  )
651
656
 
@@ -798,27 +803,54 @@ def allsky_detect_cmd():
798
803
  name="add",
799
804
  help=(
800
805
  "Register an all-sky camera, so that the proxy serves it.\n\n"
801
- "PATH is the JPEG your all-sky software keeps up to date. It may be a "
802
- "fixed file, a symlink, or a glob — with a glob, the newest matching "
803
- "file wins.\n\n"
806
+ "TARGET is the JPEG your all-sky software keeps up to date. Give a path "
807
+ "when that software runs on this machine — a fixed file, a symlink, or "
808
+ "a glob, in which case the newest matching file wins — or an "
809
+ "`http://...` address when it runs on another machine and publishes "
810
+ "the image. Write a password as ${VARIABLE} to keep it out of your "
811
+ "shell history: only the variable name is written to disk.\n\n"
804
812
  "Prints the camera's id. That id is the only handle you need "
805
813
  "afterwards, and it does not change."
806
814
  ),
807
815
  )
808
- @click.argument("path")
816
+ @click.argument("target")
809
817
  @click.option("--label", default=None, help="A name for yourself, e.g. 'Roof'.")
810
- def allsky_add_cmd(path, label):
811
- target = path.strip()
818
+ def allsky_add_cmd(target, label):
819
+ target = target.strip()
812
820
  if not target:
813
- _fail("give the path to the JPEG your all-sky software writes.")
821
+ _fail("give the path or address of the JPEG your all-sky software writes.")
814
822
 
815
- if urlsplit(target).scheme.lower() in SUPPORTED_SCHEMES:
823
+ scheme = urlsplit(target).scheme.lower()
824
+
825
+ if scheme in RTSP_SCHEMES:
816
826
  _fail(
817
- "an all-sky camera is registered by path, not by address.",
818
- "\nA camera reached over the network is a webcam: "
827
+ "an all-sky camera is registered by the JPEG it publishes, not by a "
828
+ "video stream.",
829
+ "\nA camera sending video is a webcam: "
819
830
  f"arcsecond webcam add '{target}'",
820
831
  )
821
832
 
833
+ if scheme in HTTP_SCHEMES:
834
+ # Expanded only to validate it — what is stored is what was typed, so a
835
+ # password stays a variable name on disk. Doing it here is what makes an
836
+ # unset variable a complaint about the line just typed, rather than a
837
+ # camera quietly missing from the next `proxy start`.
838
+ _expand_env_vars(target)
839
+ _add(Camera(id="", kind=ALLSKY, url=target, label=label))
840
+ return
841
+
842
+ # A single letter is a Windows drive, not a scheme: `C:\allsky\latest.jpg`
843
+ # is a path and must stay one. Anything longer was meant as an address, and
844
+ # registering it as a filename would only fail later, out of sight.
845
+ if len(scheme) > 1:
846
+ _fail(
847
+ f"{target!r} is not a path, and {scheme}:// is not an address the "
848
+ "proxy can read.",
849
+ "\nAn all-sky camera is registered by path: "
850
+ "arcsecond allsky add /srv/allsky/latest.jpg",
851
+ "or by address: arcsecond allsky add http://sky.local/latest.jpg",
852
+ )
853
+
822
854
  # Registered whether or not the file is there yet: all-sky software often
823
855
  # writes its first image only at dusk, and refusing until then would mean
824
856
  # coming back to retype this.
@@ -14,10 +14,11 @@ A camera is matched to a probe result on its *identity* (device index, URL,
14
14
  path), never on its id — an id is a name given at registration, and a device
15
15
  nobody has registered yet does not have one.
16
16
 
17
- Network cameras cannot be discovered: there is no protocol to ask a subnet
18
- which of it is a camera, so a network camera is never *new*. It can still be
19
- confirmed present or reported missing, and that is done with a plain TCP
20
- connection to the address rather than by pulling a frame. Opening an RTSP
17
+ A camera registered by address cannot be discovered: there is no protocol to
18
+ ask a subnet which of it is a camera, so neither a network camera nor an
19
+ all-sky camera published over HTTP is ever *new*. Either can still be confirmed
20
+ present or reported missing, and that is done with a plain TCP connection to
21
+ the address rather than by pulling a frame. Opening an RTSP
21
22
  stream to decide takes seconds per camera and can wake hardware that would
22
23
  rather be left alone; ``arcsecond webcam test`` is there for when the question
23
24
  really is "does this send me a picture?".
@@ -34,7 +35,7 @@ from urllib.parse import urlsplit
34
35
 
35
36
  from .sources.base import DetectedDevice
36
37
  from .sources.filewatch import detect_allsky
37
- from .store import ALLSKY, NET, USB, Camera
38
+ from .store import ALLSKY, USB, Camera
38
39
 
39
40
  logger = logging.getLogger(__name__)
40
41
 
@@ -96,7 +97,7 @@ def _network_endpoint(url: str) -> Optional[tuple]:
96
97
 
97
98
 
98
99
  def _is_reachable(camera: Camera, timeout: float) -> tuple:
99
- """``(reachable, why)`` for one network camera.
100
+ """``(reachable, why)`` for one camera registered by address.
100
101
 
101
102
  Only the address is contacted, and the answer never contains the URL's
102
103
  password: ``Camera.target`` is redacted, and the endpoint printed here is
@@ -169,11 +170,14 @@ def _settle_allsky(camera: Camera) -> tuple:
169
170
  def _settle_unmatched(camera: Camera, check_network: bool, reachability: dict) -> tuple:
170
171
  """``(present, why)`` for a registered camera no probe accounted for.
171
172
 
172
- A network camera was never going to be found by probing, and an all-sky
173
- camera writing to a path outside the well-known list was not either — so
174
- neither is missing merely for having gone unprobed.
173
+ A camera reached over the network was never going to be found by probing,
174
+ and an all-sky camera writing to a path outside the well-known list was not
175
+ either — so neither is missing merely for having gone unprobed.
176
+
177
+ Which check applies follows the address, not the kind: an all-sky camera
178
+ registered by URL is confirmed the way every other camera at an address is.
175
179
  """
176
- if camera.kind == NET:
180
+ if camera.url:
177
181
  if not check_network:
178
182
  return True, "not contacted"
179
183
  return reachability[camera.id]
@@ -212,7 +216,7 @@ def report(
212
216
 
213
217
  unmatched = [c for c in registered if c.id not in matched]
214
218
  reachability = _reachability(
215
- [c for c in unmatched if c.kind == NET and check_network], timeout
219
+ [c for c in unmatched if c.url and check_network], timeout
216
220
  )
217
221
 
218
222
  for camera in unmatched:
@@ -30,6 +30,12 @@ proxy covers every camera it can reach. They are not a separate kind on the
30
30
  wire — ``kind`` is ``webcam`` for both, and ``extra.transport`` says whether it
31
31
  is reached over ``usb``, ``rtsp`` or ``http``.
32
32
 
33
+ An all-sky camera is the same story: ``kind`` is ``allsky`` whether its image
34
+ is read from a file on this host or fetched from another machine over HTTP,
35
+ and ``extra.transport`` is ``file`` or ``http`` accordingly. A client that
36
+ knows what an all-sky camera is therefore needs no change to see one running
37
+ on the observatory PC next door.
38
+
33
39
  The backend reads the ``LIVE_IMAGE_PROXY_URL`` environment variable
34
40
  (``WEBCAM_PROXY_URL`` is accepted as a deprecated fallback). When set, it
35
41
  delegates detection and streaming to this proxy.
@@ -20,7 +20,7 @@ from typing import Optional
20
20
 
21
21
  from .sources.base import FrameSource, SourceInfo
22
22
  from .sources.filewatch import FileWatchSource
23
- from .sources.network import build_network_source
23
+ from .sources.network import build_allsky_source, build_network_source
24
24
  from .sources.opencv import OpenCVWebcamSource
25
25
  from .store import ALLSKY, NET, USB, Camera
26
26
 
@@ -31,7 +31,10 @@ def build_source(camera: Camera) -> FrameSource:
31
31
  """The right :class:`FrameSource` for ``camera``.
32
32
 
33
33
  ``camera.url`` must already have had its ``${VARIABLE}`` expanded — see
34
- ``store.expanded``.
34
+ ``store.expanded``. An all-sky camera carries a URL instead of a path when
35
+ its software runs on another machine and publishes the image over HTTP;
36
+ either way it is served as one kind, ``allsky``, and ``extra.transport``
37
+ says which of the two it is.
35
38
  """
36
39
  if camera.kind == USB:
37
40
  return OpenCVWebcamSource(
@@ -43,6 +46,8 @@ def build_source(camera: Camera) -> FrameSource:
43
46
  if camera.kind == NET:
44
47
  return build_network_source(camera.id, camera.url, camera.label)
45
48
  if camera.kind == ALLSKY:
49
+ if camera.url:
50
+ return build_allsky_source(camera.id, camera.url, camera.label)
46
51
  return FileWatchSource(camera.id, camera.path, camera.label)
47
52
  raise KeyError(f"Unknown camera kind: {camera.kind!r}")
48
53
 
@@ -8,6 +8,10 @@ Two transports, chosen from the URL scheme:
8
8
  http:// https:// JPEG bytes passed through untouched, either a still
9
9
  image fetched repeatedly or an MJPEG stream
10
10
 
11
+ An all-sky camera whose software runs on another machine publishes its JPEG
12
+ over HTTP, and is fetched by exactly the same code — see ``AllSkyHTTPSource``,
13
+ which differs from a webcam only in what it calls itself and how often it asks.
14
+
11
15
  Camera URLs often carry a password. Nothing here ever lets one out: every
12
16
  place a URL is logged, reported or put in an error message goes through
13
17
  ``redact_url`` first. That matters because the proxy forwards error text
@@ -31,6 +35,10 @@ SUPPORTED_SCHEMES = RTSP_SCHEMES + HTTP_SCHEMES
31
35
 
32
36
  _RTSP_FRAME_INTERVAL = 0.1 # seconds → ~10 fps, same as the USB webcam source
33
37
  _SNAPSHOT_INTERVAL = 1.0 # seconds — still-image cameras rarely update faster
38
+ # All-sky software writes one image every 30-120 seconds, so asking every
39
+ # second would be sixty questions for one answer. Kept in step with the
40
+ # file-watch source, which polls a local all-sky image at the same cadence.
41
+ _ALLSKY_SNAPSHOT_INTERVAL = 5.0 # seconds
34
42
  _JPEG_QUALITY = 60 # 0-100, only used on the RTSP path
35
43
  _CONNECT_TIMEOUT = 10.0 # seconds
36
44
  _READ_TIMEOUT = 30.0 # seconds without a byte before we call the stream dead
@@ -271,6 +279,40 @@ class HTTPImageSource(FrameSource):
271
279
  )
272
280
 
273
281
 
282
+ class AllSkyHTTPSource(HTTPImageSource):
283
+ """An all-sky camera that publishes its JPEG over HTTP.
284
+
285
+ Fetched exactly like any other HTTP camera — conditional requests, and a
286
+ digest comparison for a server offering neither ETag nor Last-Modified —
287
+ so a sky that has not changed is never sent on twice. What differs is what
288
+ it calls itself and how often it asks: an all-sky camera reached over the
289
+ network is an all-sky camera still, just as a webcam is a webcam whether
290
+ it arrives over USB or over RTSP.
291
+ """
292
+
293
+ kind = "allsky"
294
+ poll_interval = _ALLSKY_SNAPSHOT_INTERVAL
295
+
296
+
297
+ def build_allsky_source(
298
+ source_id: str, url: str, label: Optional[str] = None
299
+ ) -> FrameSource:
300
+ """The source for an all-sky camera registered by address.
301
+
302
+ HTTP only. An all-sky camera is registered by the JPEG its software
303
+ publishes, and an RTSP address is a video stream — `arcsecond allsky add`
304
+ turns one away with the command that does take it, so reaching this with
305
+ one would be a bug rather than something an operator typed.
306
+ """
307
+ scheme = urlsplit(url).scheme.lower()
308
+ if scheme not in HTTP_SCHEMES:
309
+ raise KeyError(
310
+ f"{redact_url(url)} cannot be served as an all-sky camera. "
311
+ f"Expected one of: {', '.join(s + '://' for s in HTTP_SCHEMES)}."
312
+ )
313
+ return AllSkyHTTPSource(source_id, url, label)
314
+
315
+
274
316
  def build_network_source(
275
317
  source_id: str, url: str, label: Optional[str] = None
276
318
  ) -> FrameSource:
@@ -16,10 +16,18 @@ The file lives next to the CLI's own configuration, in
16
16
  "cameras": {
17
17
  "k3f": {"kind": "usb", "index": 0},
18
18
  "p9x": {"kind": "net", "url": "rtsp://admin:${DOME_PW}@10.0.0.4/s"},
19
- "r4t": {"kind": "allsky", "path": "/srv/allsky/latest.jpg"}
19
+ "r4t": {"kind": "allsky", "path": "/srv/allsky/latest.jpg"},
20
+ "w8n": {"kind": "allsky", "url": "http://sky.local/latest.jpg"}
20
21
  }
21
22
  }
22
23
 
24
+ An all-sky camera carries a path when its software writes to this machine, and
25
+ a URL when that software runs elsewhere and publishes the image over HTTP. That
26
+ says how the image is fetched, not what kind of camera it is — the same way a
27
+ webcam is one kind whether it arrives over a USB cable or over RTSP. A store
28
+ holding one is still version 2: an older CLI ignores such an entry rather than
29
+ losing it, since it rewrites the entries it does not understand untouched.
30
+
23
31
  Identifiers
24
32
  -----------
25
33
  Each camera gets a three-character id — ``k3f``, ``p9x`` — and that id is the
@@ -81,7 +89,9 @@ class SourceStoreError(Exception):
81
89
  class Camera:
82
90
  """One registered camera.
83
91
 
84
- Exactly one of ``index`` / ``url`` / ``path`` is set, according to ``kind``.
92
+ Exactly one of ``index`` / ``url`` / ``path`` is set: an index for a USB
93
+ webcam, a path for an all-sky camera writing to this machine, and a URL for
94
+ a network camera or for an all-sky camera reached over HTTP.
85
95
  ``url`` is the URL as typed, with any ``${VARIABLE}`` still in place.
86
96
  """
87
97
 
@@ -89,8 +99,8 @@ class Camera:
89
99
  kind: str
90
100
  label: Optional[str] = None
91
101
  index: Optional[int] = None # usb
92
- url: Optional[str] = None # net
93
- path: Optional[str] = None # allsky
102
+ url: Optional[str] = None # net, and allsky published over HTTP
103
+ path: Optional[str] = None # allsky writing to this machine
94
104
  # What probing learned about a USB camera: width, height, fps. Recorded at
95
105
  # registration, when the device is opened anyway, so that listing it later
96
106
  # can report them without opening anything. Absent for a camera registered
@@ -109,17 +119,20 @@ class Camera:
109
119
  return (USB, self.index)
110
120
  if self.kind == NET:
111
121
  return (NET, self.url)
112
- return (ALLSKY, self.path)
122
+ # The same URL registered as an all-sky camera and as a webcam is two
123
+ # registrations, not one: the kind is part of what was asked for, and
124
+ # each is served at its own cadence.
125
+ return (ALLSKY, self.url or self.path)
113
126
 
114
127
  @property
115
128
  def target(self) -> str:
116
129
  """Where the camera is, in one line, safe to print."""
117
130
  if self.kind == USB:
118
131
  return f"device index {self.index}"
119
- if self.kind == NET:
132
+ if self.url:
120
133
  from .sources.network import redact_url
121
134
 
122
- return redact_url(self.url or "")
135
+ return redact_url(self.url)
123
136
  return self.path or ""
124
137
 
125
138
  @property
@@ -141,7 +154,7 @@ class Camera:
141
154
  entry["index"] = self.index
142
155
  if self.specs:
143
156
  entry["specs"] = self.specs
144
- elif self.kind == NET:
157
+ elif self.url:
145
158
  entry["url"] = self.url
146
159
  else:
147
160
  entry["path"] = self.path
@@ -174,6 +187,9 @@ def camera_from_json(cam_id: str, entry: dict) -> Optional[Camera]:
174
187
  if not url:
175
188
  return None
176
189
  return Camera(id=cam_id, kind=NET, url=url, label=label)
190
+ url = entry.get("url")
191
+ if url:
192
+ return Camera(id=cam_id, kind=ALLSKY, url=url, label=label)
177
193
  path = entry.get("path")
178
194
  if not path:
179
195
  return None
@@ -412,14 +428,17 @@ def expanded(cameras: list[Camera], expand) -> list[Camera]:
412
428
  """
413
429
  usable = []
414
430
  for camera in cameras:
415
- if camera.kind != NET:
431
+ # Keyed on the URL rather than on the kind: an all-sky camera reached
432
+ # over HTTP may carry a password in its address just as a network
433
+ # camera does, and it is stored the same way — unexpanded.
434
+ if not camera.url:
416
435
  usable.append(camera)
417
436
  continue
418
437
  try:
419
438
  url = expand(camera.url)
420
439
  except Exception as e:
421
440
  logger.warning(
422
- "Skipping network camera %r: %s",
441
+ "Skipping camera %r: %s",
423
442
  camera.id,
424
443
  getattr(e, "message", None) or e,
425
444
  )
@@ -427,10 +446,9 @@ def expanded(cameras: list[Camera], expand) -> list[Camera]:
427
446
  usable.append(
428
447
  Camera(
429
448
  id=camera.id,
430
- kind=NET,
449
+ kind=camera.kind,
431
450
  url=url,
432
451
  label=camera.label,
433
- path=camera.path,
434
452
  )
435
453
  )
436
454
  return usable
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "3.17.2"
7
+ version = "3.18.0"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -192,12 +192,62 @@ def test_a_path_given_to_webcam_add_points_at_allsky(cli):
192
192
  assert "arcsecond allsky add" in result.output
193
193
 
194
194
 
195
- def test_an_address_given_to_allsky_add_points_at_webcam(cli):
195
+ def test_a_video_stream_given_to_allsky_add_points_at_webcam(cli):
196
196
  result = _run(cli, allsky, ["add", "rtsp://cam.local/s"])
197
197
  assert result.exit_code == 1
198
198
  assert "arcsecond webcam add" in result.output
199
199
 
200
200
 
201
+ def test_allsky_add_takes_the_address_of_a_published_image(cli):
202
+ """The all-sky software runs on another machine and publishes over HTTP."""
203
+ result = _run(cli, allsky, ["add", "http://sky.local/allsky/latest.jpg"])
204
+ assert result.exit_code == 0
205
+ (camera,) = store.all_cameras()
206
+ assert camera.kind == "allsky"
207
+ assert camera.url == "http://sky.local/allsky/latest.jpg"
208
+ assert camera.path is None
209
+
210
+
211
+ def test_an_all_sky_camera_at_an_address_is_listed_by_allsky_not_webcam(cli):
212
+ _run(cli, allsky, ["add", "http://sky.local/latest.jpg"])
213
+ assert "sky.local" in _run(cli, allsky, []).output
214
+ assert "sky.local" not in _run(cli, webcam, []).output
215
+
216
+
217
+ def test_a_windows_path_is_a_path_and_not_an_address(cli):
218
+ """`C:` is a drive letter; urlsplit calls it a scheme, and it is not one."""
219
+ result = _run(cli, allsky, ["add", r"C:\allsky\latest.jpg"])
220
+ assert result.exit_code == 0
221
+ (camera,) = store.all_cameras()
222
+ assert camera.path == r"C:\allsky\latest.jpg"
223
+ assert camera.url is None
224
+
225
+
226
+ def test_an_address_allsky_cannot_read_is_refused(cli):
227
+ result = _run(cli, allsky, ["add", "ftp://sky.local/latest.jpg"])
228
+ assert result.exit_code == 1
229
+ assert "http://" in result.output
230
+ assert store.all_cameras() == []
231
+
232
+
233
+ def test_a_password_in_an_all_sky_address_is_never_written_in_the_clear(
234
+ cli, monkeypatch, tmp_path
235
+ ):
236
+ monkeypatch.setenv("PW", "hunter2")
237
+ _run(cli, allsky, ["add", "http://sky:${PW}@10.0.0.9/latest.jpg"])
238
+ written = (tmp_path / "cameras.json").read_text()
239
+ assert "${PW}" in written
240
+ assert "hunter2" not in written
241
+
242
+
243
+ def test_a_missing_variable_in_an_all_sky_address_registers_nothing(cli, monkeypatch):
244
+ monkeypatch.delenv("PW", raising=False)
245
+ result = cli.invoke(allsky, ["add", "http://sky:${PW}@10.0.0.9/latest.jpg"])
246
+ assert result.exit_code != 0
247
+ assert "PW" in result.output
248
+ assert store.all_cameras() == []
249
+
250
+
201
251
  def test_an_unsupported_scheme_is_refused(cli):
202
252
  result = _run(cli, webcam, ["add", "ftp://cam.local/s"])
203
253
  assert result.exit_code == 1
@@ -231,6 +281,7 @@ def test_a_missing_variable_is_reported_before_anything_is_registered(cli, monke
231
281
  (webcam, ["add", "0"]),
232
282
  (webcam, ["add", "rtsp://c/s"]),
233
283
  (allsky, ["add", "/a.jpg"]),
284
+ (allsky, ["add", "http://sky.local/latest.jpg"]),
234
285
  ],
235
286
  )
236
287
  def test_forget_works_for_every_kind_of_camera(cli, monkeypatch, group, add_args):
@@ -363,6 +414,32 @@ def test_allsky_start_points_at_add_and_proxy_start(cli):
363
414
  assert "arcsecond proxy start" in result.output
364
415
 
365
416
 
417
+ def test_testing_an_all_sky_camera_at_an_address_contacts_it(cli, monkeypatch):
418
+ """It has an address, so `test` can pull an image from it like any other."""
419
+ tested = {}
420
+ monkeypatch.setattr(
421
+ commands, "_test_url", lambda url, timeout, **k: tested.update(url=url)
422
+ )
423
+ printed = (
424
+ _run(cli, allsky, ["add", "http://sky.local/latest.jpg"])
425
+ .output.split("Registered ")[1]
426
+ .split()[0]
427
+ )
428
+ _run(cli, webcam, ["test", printed])
429
+ assert tested["url"] == "http://sky.local/latest.jpg"
430
+
431
+
432
+ def test_testing_an_all_sky_camera_on_this_disk_names_its_path(cli):
433
+ printed = (
434
+ _run(cli, allsky, ["add", "/srv/sky.jpg"])
435
+ .output.split("Registered ")[1]
436
+ .split()[0]
437
+ )
438
+ result = _run(cli, webcam, ["test", printed])
439
+ assert result.exit_code == 1
440
+ assert "/srv/sky.jpg" in result.output
441
+
442
+
366
443
  def test_netcam_points_at_webcam(cli):
367
444
  result = _run(cli, netcam, ["test", "rtsp://cam.local/s"])
368
445
  assert result.exit_code == 1
@@ -198,6 +198,35 @@ def test_a_detected_all_sky_camera_that_is_registered_is_not_reported_twice(tmp_
198
198
  assert report.missing == []
199
199
 
200
200
 
201
+ def test_an_all_sky_camera_at_an_address_is_confirmed_by_contacting_it():
202
+ """It has no path to look at, so it is settled the way any address is."""
203
+ camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
204
+ with patch.object(detection, "_is_reachable", return_value=(True, "answering")):
205
+ with patch.object(detection, "detect_allsky", return_value=[]):
206
+ report = detection.report([camera], (ALLSKY,))
207
+ assert report.present == [camera]
208
+ assert report.new == []
209
+
210
+
211
+ def test_an_all_sky_camera_at_an_address_that_is_down_is_missing():
212
+ camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
213
+ with patch.object(detection, "_is_reachable", return_value=(False, "no answer")):
214
+ with patch.object(detection, "detect_allsky", return_value=[]):
215
+ report = detection.report([camera], (ALLSKY,))
216
+ assert report.missing == [camera]
217
+ assert report.detail["abc"] == "no answer"
218
+
219
+
220
+ def test_an_all_sky_address_is_never_looked_for_on_this_disk():
221
+ """The old check called a URL a missing file — the wrong answer entirely."""
222
+ camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
223
+ with patch.object(detection, "resolve_allsky_path") as looked_at_disk:
224
+ with patch.object(detection, "_is_reachable", return_value=(True, "answering")):
225
+ with patch.object(detection, "detect_allsky", return_value=[]):
226
+ detection.report([camera], (ALLSKY,))
227
+ looked_at_disk.assert_not_called()
228
+
229
+
201
230
  # ---------------------------------------------------------------------------
202
231
  # Probing must never take the report down
203
232
  # ---------------------------------------------------------------------------
@@ -18,11 +18,12 @@ import pytest
18
18
  from arcsecond.imagesources.commands import _expand_env_vars
19
19
  from arcsecond.imagesources.registry import Registry, build_source
20
20
  from arcsecond.imagesources.sources.network import (
21
+ AllSkyHTTPSource,
21
22
  HTTPImageSource,
22
23
  RTSPSource,
23
24
  redact_url,
24
25
  )
25
- from arcsecond.imagesources.store import NET, USB, Camera
26
+ from arcsecond.imagesources.store import ALLSKY, NET, USB, Camera
26
27
 
27
28
  JPEG_A = b"\xff\xd8\xff\xe0 first frame \xff\xd9"
28
29
  JPEG_B = b"\xff\xd8\xff\xe0 second frame \xff\xd9"
@@ -134,6 +135,70 @@ def test_the_registry_rejects_an_unregistered_camera():
134
135
  Registry()._build("abc")
135
136
 
136
137
 
138
+ # ---------------------------------------------------------------------------
139
+ # All-sky cameras reached over the network
140
+ # ---------------------------------------------------------------------------
141
+
142
+ SKY_URL = "http://sky.local/allsky/latest.jpg"
143
+
144
+
145
+ def _sky_over_http(url=SKY_URL, cam_id="sky"):
146
+ return Camera(id=cam_id, kind=ALLSKY, url=url)
147
+
148
+
149
+ def test_an_all_sky_camera_over_http_is_announced_as_an_all_sky_camera():
150
+ """The backend must see the same kind wherever the image is fetched from."""
151
+ from arcsecond.imagesources.sources.filewatch import FileWatchSource
152
+
153
+ over_http = build_source(_sky_over_http()).info()
154
+ on_disk = FileWatchSource("sky", "/srv/allsky/latest.jpg").info()
155
+
156
+ assert over_http.kind == on_disk.kind == "allsky"
157
+ assert over_http.extra["transport"] == "http"
158
+ assert on_disk.extra["transport"] == "file"
159
+
160
+
161
+ def test_the_same_address_is_an_all_sky_camera_or_a_webcam_as_registered():
162
+ assert build_source(_sky_over_http()).info().kind == "allsky"
163
+ assert build_source(_netcam(url=SKY_URL)).info().kind == "webcam"
164
+
165
+
166
+ def test_an_all_sky_camera_over_http_is_polled_at_its_own_cadence():
167
+ """One image every 30-120 seconds: asking every second buys nothing."""
168
+ sky = build_source(_sky_over_http())
169
+ webcam = build_source(_netcam(url=SKY_URL))
170
+ assert isinstance(sky, AllSkyHTTPSource)
171
+ assert sky.poll_interval > webcam.poll_interval
172
+
173
+
174
+ def test_the_registry_builds_an_all_sky_camera_from_its_address():
175
+ registry = Registry(cameras=[_sky_over_http(cam_id="sky")])
176
+ source = registry._build("sky")
177
+ assert isinstance(source, AllSkyHTTPSource)
178
+ assert source.url == SKY_URL
179
+
180
+
181
+ def test_the_registry_builds_an_all_sky_camera_from_its_path(tmp_path):
182
+ from arcsecond.imagesources.sources.filewatch import FileWatchSource
183
+
184
+ image = tmp_path / "latest.jpg"
185
+ registry = Registry(cameras=[Camera(id="sky", kind=ALLSKY, path=str(image))])
186
+ assert isinstance(registry._build("sky"), FileWatchSource)
187
+
188
+
189
+ def test_an_all_sky_address_never_reports_its_password():
190
+ registry = Registry(
191
+ cameras=[_sky_over_http(url="http://sky:hunter2@10.0.0.9/latest.jpg")]
192
+ )
193
+ assert "hunter2" not in str(registry.infos())
194
+
195
+
196
+ def test_a_video_stream_is_not_an_all_sky_camera():
197
+ """`allsky add` turns one away; reaching the registry with one is a bug."""
198
+ with pytest.raises(KeyError, match="http"):
199
+ build_source(_sky_over_http(url="rtsp://sky.local/stream1"))
200
+
201
+
137
202
  def test_a_usb_and_a_network_camera_live_in_one_registry():
138
203
  """One proxy, one id space — whatever the camera is plugged into."""
139
204
  from arcsecond.imagesources.sources.opencv import OpenCVWebcamSource
@@ -104,6 +104,7 @@ def test_every_kind_of_camera_can_be_registered_over_the_endpoint():
104
104
  NETCAM,
105
105
  {"id": "def", "kind": "usb", "index": 0},
106
106
  {"id": "ghi", "kind": "allsky", "path": "/a.jpg"},
107
+ {"id": "jkl", "kind": "allsky", "url": "http://sky.local/latest.jpg"},
107
108
  ]
108
109
  }
109
110
 
@@ -113,8 +114,8 @@ def test_every_kind_of_camera_can_be_registered_over_the_endpoint():
113
114
  return await r.json(), {c.id for c in registry.cameras}
114
115
 
115
116
  answer, ids = _run_against_proxy(body)
116
- assert answer["added"] == ["abc", "def", "ghi"]
117
- assert ids == {"abc", "def", "ghi"}
117
+ assert answer["added"] == ["abc", "def", "ghi", "jkl"]
118
+ assert ids == {"abc", "def", "ghi", "jkl"}
118
119
 
119
120
 
120
121
  @pytest.mark.parametrize(
@@ -14,6 +14,11 @@ from arcsecond.imagesources.store import ALLSKY, NET, USB, Camera
14
14
  RAW_URL = "rtsp://admin:${DOME_CAM_PW}@192.168.1.42:554/stream1"
15
15
  EXPANDED_URL = "rtsp://admin:hunter2@192.168.1.42:554/stream1"
16
16
 
17
+ # An all-sky camera whose software runs on another machine and publishes the
18
+ # image over HTTP. Registered as an all-sky camera, not as a webcam.
19
+ RAW_SKY_URL = "http://sky:${SKY_PW}@10.0.0.9/allsky/latest.jpg"
20
+ EXPANDED_SKY_URL = "http://sky:hunter2@10.0.0.9/allsky/latest.jpg"
21
+
17
22
 
18
23
  @pytest.fixture
19
24
  def store_file(tmp_path):
@@ -32,6 +37,10 @@ def _allsky(path="/srv/sky.jpg", label=None):
32
37
  return Camera(id="", kind=ALLSKY, path=path, label=label)
33
38
 
34
39
 
40
+ def _allsky_url(url=RAW_SKY_URL, label=None):
41
+ return Camera(id="", kind=ALLSKY, url=url, label=label)
42
+
43
+
35
44
  # ---------------------------------------------------------------------------
36
45
  # Identifiers
37
46
  # ---------------------------------------------------------------------------
@@ -87,7 +96,7 @@ def test_load_returns_nothing_when_nothing_was_ever_registered(store_file):
87
96
  assert store.all_cameras(store_file) == []
88
97
 
89
98
 
90
- @pytest.mark.parametrize("camera", [_usb(2), _net(), _allsky()])
99
+ @pytest.mark.parametrize("camera", [_usb(2), _net(), _allsky(), _allsky_url()])
91
100
  def test_every_kind_of_camera_round_trips(store_file, camera):
92
101
  stored, created = store.add(camera, store_file)
93
102
  assert created is True
@@ -169,6 +178,38 @@ def test_a_prefixed_id_is_tolerated(store_file, prefix):
169
178
  assert store.find(f"{prefix}:{stored.id}", store_file) == stored
170
179
 
171
180
 
181
+ def test_an_all_sky_camera_registered_by_address_is_still_an_all_sky_camera(
182
+ store_file,
183
+ ):
184
+ """The transport says how it is fetched, not what kind of camera it is."""
185
+ stored, _ = store.add(_allsky_url("http://sky.local/latest.jpg"), store_file)
186
+ assert stored.kind == ALLSKY
187
+ assert stored.display_kind == "all-sky"
188
+ assert [c.id for c in store.cameras_of_kinds((ALLSKY,), store_file)] == [stored.id]
189
+ assert store.cameras_of_kinds(store.WEBCAM_KINDS, store_file) == []
190
+
191
+
192
+ def test_one_address_registered_as_both_kinds_is_two_cameras(store_file):
193
+ """Asking for an all-sky camera is not the same as asking for a webcam."""
194
+ url = "http://sky.local/latest.jpg"
195
+ sky, _ = store.add(_allsky_url(url), store_file)
196
+ cam, created = store.add(_net(url), store_file)
197
+ assert created is True
198
+ assert sky.id != cam.id
199
+
200
+
201
+ def test_an_all_sky_address_is_stored_as_an_address_not_a_path(store_file):
202
+ stored, _ = store.add(_allsky_url("http://sky.local/latest.jpg"), store_file)
203
+ entry = json.loads(store_file.read_text())["cameras"][stored.id]
204
+ assert entry == {"kind": "allsky", "url": "http://sky.local/latest.jpg"}
205
+
206
+
207
+ def test_an_all_sky_address_never_shows_its_password(store_file):
208
+ stored, _ = store.add(_allsky_url(EXPANDED_SKY_URL), store_file)
209
+ assert "hunter2" not in stored.target
210
+ assert "***" in stored.target
211
+
212
+
172
213
  def test_cameras_of_kinds_keeps_the_two_groups_apart(store_file):
173
214
  usb, _ = store.add(_usb(0), store_file)
174
215
  net, _ = store.add(_net(), store_file)
@@ -191,6 +232,16 @@ def test_a_registered_camera_is_expanded_again_on_the_way_back(store_file):
191
232
  assert usable.id == stored.id
192
233
 
193
234
 
235
+ def test_an_all_sky_address_is_expanded_like_any_other(store_file):
236
+ """A password in an all-sky address is kept out of the file the same way."""
237
+ stored, _ = store.add(_allsky_url(RAW_SKY_URL), store_file)
238
+ assert "${SKY_PW}" in store_file.read_text()
239
+
240
+ (usable,) = store.expanded([stored], lambda u: EXPANDED_SKY_URL)
241
+ assert usable.url == EXPANDED_SKY_URL
242
+ assert usable.kind == ALLSKY
243
+
244
+
194
245
  def test_a_camera_whose_variable_is_unset_is_skipped_not_fatal(store_file):
195
246
  def explode(url):
196
247
  raise KeyError("DOME_CAM_PW")
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "3.17.2"
158
+ version = "3.18.0"
159
159
  source = { editable = "." }
160
160
  dependencies = [
161
161
  { name = "alpyca" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes