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.
- {arcsecond-3.17.2 → arcsecond-3.18.0}/PKG-INFO +1 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/commands.py +45 -13
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/detection.py +15 -11
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/proxy.py +6 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/registry.py +7 -2
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/network.py +42 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/store.py +30 -12
- {arcsecond-3.17.2 → arcsecond-3.18.0}/pyproject.toml +1 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_commands.py +78 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_detection.py +29 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_network.py +66 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_proxy.py +3 -2
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_store.py +52 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/uv.lock +1 -1
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.flake8 +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/dependabot.yml +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/.gitignore +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/LICENSE +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/Makefile +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/README.md +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/__version__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/commands.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/alpaca/dome_probe.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/config.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/endpoint.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/main.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/api/resources.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cli.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/errors.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/backups.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/database.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/local.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/utils.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/runtime.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/base.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/imagesources/sources/opencv.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/options.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/arcsecond/targets.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/examples/example_upload_files.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/requirements.txt +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/setup.cfg +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/setup.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/sonar-project.properties +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_api.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_api_endpoint.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_config.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/api/test_targets.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/conftest.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_alpaca_probe.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_cli.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_backups_restore.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_database.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_local.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_hosting_utils_secret_key.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_imagesources_webcam.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/test_targets_planning.py +0 -0
- {arcsecond-3.17.2 → arcsecond-3.18.0}/tests/utils.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
"
|
|
802
|
-
"
|
|
803
|
-
"file wins
|
|
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("
|
|
816
|
+
@click.argument("target")
|
|
809
817
|
@click.option("--label", default=None, help="A name for yourself, e.g. 'Roof'.")
|
|
810
|
-
def allsky_add_cmd(
|
|
811
|
-
target =
|
|
818
|
+
def allsky_add_cmd(target, label):
|
|
819
|
+
target = target.strip()
|
|
812
820
|
if not target:
|
|
813
|
-
_fail("give the path
|
|
821
|
+
_fail("give the path or address of the JPEG your all-sky software writes.")
|
|
814
822
|
|
|
815
|
-
|
|
823
|
+
scheme = urlsplit(target).scheme.lower()
|
|
824
|
+
|
|
825
|
+
if scheme in RTSP_SCHEMES:
|
|
816
826
|
_fail(
|
|
817
|
-
"an all-sky camera is registered by
|
|
818
|
-
"
|
|
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
|
-
|
|
18
|
-
which of it is a camera, so a network camera
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
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
|
|
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
|
|
173
|
-
camera writing to a path outside the well-known list was not
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
132
|
+
if self.url:
|
|
120
133
|
from .sources.network import redact_url
|
|
121
134
|
|
|
122
|
-
return redact_url(self.url
|
|
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.
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
@@ -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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py
RENAMED
|
File without changes
|
{arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py
RENAMED
|
File without changes
|
|
File without changes
|
{arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py
RENAMED
|
File without changes
|
{arcsecond-3.17.2 → arcsecond-3.18.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py
RENAMED
|
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
|