arcsecond 3.16.0__tar.gz → 3.16.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arcsecond-3.16.0 → arcsecond-3.16.2}/PKG-INFO +2 -2
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/backups.py +22 -3
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/database.py +20 -4
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/docker-compose.yml +23 -3
- arcsecond-3.16.2/arcsecond/hosting/local.py +468 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/opencv.py +30 -3
- {arcsecond-3.16.0 → arcsecond-3.16.2}/pyproject.toml +1 -1
- arcsecond-3.16.2/tests/test_hosting_local.py +412 -0
- arcsecond-3.16.2/tests/test_imagesources_webcam.py +140 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/uv.lock +1 -1
- arcsecond-3.16.0/arcsecond/hosting/local.py +0 -170
- arcsecond-3.16.0/tests/test_hosting_local.py +0 -115
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/dependabot.yml +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/.gitignore +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/LICENSE +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/Makefile +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/README.md +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/__version__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/commands.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/dome_probe.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/config.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/endpoint.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/main.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/resources.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cli.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/errors.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/utils.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/commands.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/proxy.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/registry.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/base.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/filewatch.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/network.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/store.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/options.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/targets.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/examples/example_upload_files.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/requirements.txt +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/setup.cfg +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/setup.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/sonar-project.properties +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_api.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_api_endpoint.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_config.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_targets.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/conftest.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_alpaca_probe.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_cli.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_hosting_database.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_imagesources_netcam.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_imagesources_store.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_targets_planning.py +0 -0
- {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/utils.py +0 -0
|
@@ -20,9 +20,18 @@ API_CONTAINER = "arcsecond-api"
|
|
|
20
20
|
WORKER_CONTAINER = "arcsecond-worker"
|
|
21
21
|
BEAT_CONTAINER = "arcsecond-beat"
|
|
22
22
|
WEB_CONTAINER = "arcsecond-web"
|
|
23
|
+
# Optional service: absent on most installs. The stop loop skips it when it
|
|
24
|
+
# is not running, and the docker-start fallback tolerates the missing name.
|
|
25
|
+
ALERTS_CONTAINER = "arcsecond-alerts"
|
|
23
26
|
|
|
24
27
|
# Services to stop before wiping the DB. db is kept up so we can talk to it.
|
|
25
|
-
SERVICES_TO_STOP = [
|
|
28
|
+
SERVICES_TO_STOP = [
|
|
29
|
+
API_CONTAINER,
|
|
30
|
+
WORKER_CONTAINER,
|
|
31
|
+
ALERTS_CONTAINER,
|
|
32
|
+
BEAT_CONTAINER,
|
|
33
|
+
WEB_CONTAINER,
|
|
34
|
+
]
|
|
26
35
|
|
|
27
36
|
BACKUP_PATTERN = re.compile(r"^backup-(\d{8}-\d{6})\.sql\.gz$")
|
|
28
37
|
PRE_RESTORE_PREFIX = "pre-restore-"
|
|
@@ -322,7 +331,15 @@ def _run_destination_backups_command(*args, timeout=60):
|
|
|
322
331
|
return None
|
|
323
332
|
try:
|
|
324
333
|
out = subprocess.run(
|
|
325
|
-
[
|
|
334
|
+
[
|
|
335
|
+
"docker",
|
|
336
|
+
"exec",
|
|
337
|
+
API_CONTAINER,
|
|
338
|
+
"python",
|
|
339
|
+
"manage.py",
|
|
340
|
+
"destination_backups",
|
|
341
|
+
*args,
|
|
342
|
+
],
|
|
326
343
|
capture_output=True,
|
|
327
344
|
text=True,
|
|
328
345
|
check=False,
|
|
@@ -358,7 +375,9 @@ def _remote_only_items(items, remote_names):
|
|
|
358
375
|
if not m or name in local_names:
|
|
359
376
|
continue
|
|
360
377
|
try:
|
|
361
|
-
ts = datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(
|
|
378
|
+
ts = datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(
|
|
379
|
+
tzinfo=timezone.utc
|
|
380
|
+
)
|
|
362
381
|
except ValueError:
|
|
363
382
|
continue
|
|
364
383
|
result.append((name, ts))
|
|
@@ -37,6 +37,21 @@ DB_CONTAINER = "arcsecond-db"
|
|
|
37
37
|
# directory, so recreating it would be downtime for nothing.
|
|
38
38
|
SERVICES_TO_RECREATE = ["backend", "worker", "beat"]
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
def _services_to_recreate():
|
|
42
|
+
"""The optional alerts consumer joins the recreate list only when the
|
|
43
|
+
operator's compose file actually carries it — naming an unknown service
|
|
44
|
+
makes `docker compose up` fail outright on installs without the block."""
|
|
45
|
+
services = list(SERVICES_TO_RECREATE)
|
|
46
|
+
compose_path = Path.cwd() / "docker-compose.yml"
|
|
47
|
+
try:
|
|
48
|
+
if "# >>> arcsecond:alerts" in compose_path.read_text(encoding="utf-8"):
|
|
49
|
+
services.append("alerts")
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
return services
|
|
53
|
+
|
|
54
|
+
|
|
40
55
|
ENV_KEY = "POSTGRES_PASSWORD"
|
|
41
56
|
|
|
42
57
|
# The .env in the install directory does double duty: compose also reads it to
|
|
@@ -248,7 +263,7 @@ def set_password_cmd(password, show, no_restart, dry_run):
|
|
|
248
263
|
click.echo(f" would run ALTER ROLE \"{db_user}\" WITH PASSWORD '***'")
|
|
249
264
|
if not no_restart:
|
|
250
265
|
click.echo(
|
|
251
|
-
f" would recreate {', '.join(
|
|
266
|
+
f" would recreate {', '.join(_services_to_recreate())} "
|
|
252
267
|
"(docker compose up -d --force-recreate)"
|
|
253
268
|
)
|
|
254
269
|
sys.exit(0)
|
|
@@ -324,21 +339,22 @@ def set_password_cmd(password, show, no_restart, dry_run):
|
|
|
324
339
|
|
|
325
340
|
click.echo(click.style("Password changed and verified.", fg="green"))
|
|
326
341
|
|
|
342
|
+
services_to_recreate = _services_to_recreate()
|
|
327
343
|
if no_restart:
|
|
328
344
|
click.echo(
|
|
329
345
|
"\nSkipping the restart, as asked. The running containers still hold the\n"
|
|
330
346
|
"old password and will fail on their next reconnect. Apply it with:\n"
|
|
331
|
-
f" docker compose up -d --force-recreate {' '.join(
|
|
347
|
+
f" docker compose up -d --force-recreate {' '.join(services_to_recreate)}"
|
|
332
348
|
)
|
|
333
349
|
else:
|
|
334
|
-
click.echo(f"Recreating {', '.join(
|
|
350
|
+
click.echo(f"Recreating {', '.join(services_to_recreate)}...")
|
|
335
351
|
cmd = [
|
|
336
352
|
"docker",
|
|
337
353
|
"compose",
|
|
338
354
|
"up",
|
|
339
355
|
"-d",
|
|
340
356
|
"--force-recreate",
|
|
341
|
-
*
|
|
357
|
+
*services_to_recreate,
|
|
342
358
|
]
|
|
343
359
|
click.echo(click.style(f"$ {' '.join(cmd)}", fg="cyan"))
|
|
344
360
|
recreate = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# === Arcsecond.local config. ===
|
|
2
2
|
# *** DO NOT change any name or port number. ***
|
|
3
|
-
# Version 6.
|
|
3
|
+
# Version 6.3
|
|
4
4
|
|
|
5
5
|
services:
|
|
6
6
|
# Database (PostgresQL)
|
|
@@ -105,7 +105,9 @@ services:
|
|
|
105
105
|
restart: unless-stopped
|
|
106
106
|
ports: [ "8900:8900" ]
|
|
107
107
|
|
|
108
|
-
# Arcsecond webapp.
|
|
108
|
+
# Arcsecond webapp. Served on 5555 only: a self-hosted install is
|
|
109
|
+
# organisation-based and therefore always a portal, so the second port that
|
|
110
|
+
# used to be here (5577) served the very same app.
|
|
109
111
|
web:
|
|
110
112
|
image: ghcr.io/arcsecond-io/arcsecond-web:latest
|
|
111
113
|
container_name: arcsecond-web
|
|
@@ -115,7 +117,25 @@ services:
|
|
|
115
117
|
condition: service_started
|
|
116
118
|
ports:
|
|
117
119
|
- "5555:5555"
|
|
118
|
-
|
|
120
|
+
|
|
121
|
+
# >>> arcsecond:alerts
|
|
122
|
+
# Arcsecond transient-alerts consumer (optional).
|
|
123
|
+
# Long-lived Kafka client for NASA GCN. Outbound TLS to kafka.gcn.nasa.gov
|
|
124
|
+
# and auth.gcn.nasa.gov only — nothing listens. Requires GCN_CONSUMER_CLIENT_ID /
|
|
125
|
+
# GCN_CONSUMER_CLIENT_SECRET in .env; idles harmlessly when they are absent.
|
|
126
|
+
# Single instance only — do not scale it.
|
|
127
|
+
alerts:
|
|
128
|
+
image: ghcr.io/arcsecond-io/arcsecond-api:latest
|
|
129
|
+
container_name: arcsecond-alerts
|
|
130
|
+
restart: unless-stopped
|
|
131
|
+
entrypoint: /home/app/entrypoint-alerts.sh
|
|
132
|
+
depends_on:
|
|
133
|
+
backend:
|
|
134
|
+
condition: service_healthy
|
|
135
|
+
env_file:
|
|
136
|
+
- .env
|
|
137
|
+
stop_grace_period: 30s
|
|
138
|
+
# <<< arcsecond:alerts
|
|
119
139
|
|
|
120
140
|
volumes:
|
|
121
141
|
arcsecond_postgres_data:
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from arcsecond.options import basic_options
|
|
10
|
+
|
|
11
|
+
from .utils import (
|
|
12
|
+
_get_encryption_key,
|
|
13
|
+
_get_random_postgres_password,
|
|
14
|
+
_get_random_secret_key,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ENV_FILENAME = ".env"
|
|
18
|
+
|
|
19
|
+
# Stable across installs — operators connect with this username when running
|
|
20
|
+
# manual psql / pg_dump commands. The actual security boundary is the password
|
|
21
|
+
# (generated per-install) and the network exposure (localhost-only).
|
|
22
|
+
POSTGRES_USER = "arcsecond_docker"
|
|
23
|
+
POSTGRES_DB = "arcsecond_docker"
|
|
24
|
+
|
|
25
|
+
# Services an installation can opt out of. Each one lives in the packaged
|
|
26
|
+
# docker-compose.yml between "# >>> arcsecond:<name>" / "# <<< arcsecond:<name>"
|
|
27
|
+
# marker lines, and is added or removed by splicing those blocks as text —
|
|
28
|
+
# never by parsing and re-emitting YAML, which would destroy the compose
|
|
29
|
+
# file's comments (they are operator documentation).
|
|
30
|
+
OPTIONAL_SERVICES = {
|
|
31
|
+
"alerts": "transient-alerts (ToO)",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# One .env key records every answered yes/no, e.g. "alerts:yes". A service
|
|
35
|
+
# absent from the value has never been decided, so setup may still ask —
|
|
36
|
+
# that distinction is what lets a future CLI version introduce a new
|
|
37
|
+
# optional service without re-asking about the old ones.
|
|
38
|
+
OPTIONAL_SERVICES_ENV_KEY = "ARCSECOND_OPTIONAL_SERVICES"
|
|
39
|
+
|
|
40
|
+
GCN_ENV_COMMENT = (
|
|
41
|
+
"# NASA GCN credentials (optional — used by the transient-alerts service):"
|
|
42
|
+
" see https://docs.arcsecond.io/local/transient-alerts"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Compose reads .env itself, and a backslash in a value is an escape sequence
|
|
47
|
+
# there — a Windows default like C:\Users\Obs\Data reaches the daemon mangled
|
|
48
|
+
# (\U, \O, \D swallowed), and the bind mount then points somewhere that does
|
|
49
|
+
# not exist. Windows accepts forward slashes in every path API and Docker
|
|
50
|
+
# accepts C:/Users/Obs/Data, so we write the path posix-style on every
|
|
51
|
+
# platform. Picked at import time rather than branching inside expand_path so
|
|
52
|
+
# tests can exercise the Windows flavour from any host.
|
|
53
|
+
_PATH_FLAVOUR = PureWindowsPath if os.name == "nt" else PurePosixPath
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def expand_path(value: str) -> str:
|
|
57
|
+
expanded = os.path.expandvars(value)
|
|
58
|
+
expanded = os.path.expanduser(expanded)
|
|
59
|
+
# A backslash is a legal filename character on POSIX, so the conversion
|
|
60
|
+
# must only ever happen with the Windows flavour.
|
|
61
|
+
return _PATH_FLAVOUR(expanded).as_posix()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def prompt_shared_data_path() -> str:
|
|
65
|
+
default_path = str(Path.cwd())
|
|
66
|
+
|
|
67
|
+
print("SHARED_DATA_PATH configuration")
|
|
68
|
+
print(f"Default (current folder): {default_path}")
|
|
69
|
+
user_input = input(
|
|
70
|
+
"Press Enter to accept, or type a different path (supports ~ and $VARS): "
|
|
71
|
+
).strip()
|
|
72
|
+
|
|
73
|
+
chosen = user_input if user_input else default_path
|
|
74
|
+
return expand_path(chosen)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Values are callables so nothing is computed — and no prompt fires — for a
|
|
78
|
+
# key that is already present in an existing .env.
|
|
79
|
+
REQUIRED_ENV_PROVIDERS = {
|
|
80
|
+
"SECRET_KEY": lambda: _get_random_secret_key(),
|
|
81
|
+
"AUTH_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
|
|
82
|
+
"AGENT_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
|
|
83
|
+
"FIELD_ENCRYPTION_KEY": lambda: _get_encryption_key(),
|
|
84
|
+
"SHARED_DATA_PATH": lambda: prompt_shared_data_path(),
|
|
85
|
+
"POSTGRES_USER": lambda: POSTGRES_USER,
|
|
86
|
+
# Per-install random; never overwritten on repeat runs (see write_env_file).
|
|
87
|
+
# Postgres only reads this on first container boot to bootstrap the role,
|
|
88
|
+
# so the .env value and the live DB password must stay in sync — that's
|
|
89
|
+
# why we never regenerate it after the .env exists.
|
|
90
|
+
"POSTGRES_PASSWORD": lambda: _get_random_postgres_password(),
|
|
91
|
+
"POSTGRES_DB": lambda: POSTGRES_DB,
|
|
92
|
+
# Empty placeholders: the operator pastes their own GCN credentials here.
|
|
93
|
+
"GCN_CONSUMER_CLIENT_ID": lambda: "",
|
|
94
|
+
"GCN_CONSUMER_CLIENT_SECRET": lambda: "",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_env_keys(lines):
|
|
99
|
+
keys = set()
|
|
100
|
+
for line in lines:
|
|
101
|
+
stripped = line.strip()
|
|
102
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
103
|
+
continue
|
|
104
|
+
key, _ = stripped.split("=", 1)
|
|
105
|
+
keys.add(key.strip())
|
|
106
|
+
return keys
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_env_line(key, value):
|
|
110
|
+
if key == "SHARED_DATA_PATH":
|
|
111
|
+
return f'{key}="{value}"'
|
|
112
|
+
return f"{key}={value}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_env_file():
|
|
116
|
+
env_path = Path.cwd() / ENV_FILENAME
|
|
117
|
+
ordered_required_keys = [
|
|
118
|
+
"SECRET_KEY",
|
|
119
|
+
"AUTH_JWT_SIGNING_KEY",
|
|
120
|
+
"AGENT_JWT_SIGNING_KEY",
|
|
121
|
+
"FIELD_ENCRYPTION_KEY",
|
|
122
|
+
"SHARED_DATA_PATH",
|
|
123
|
+
"POSTGRES_USER",
|
|
124
|
+
"POSTGRES_PASSWORD",
|
|
125
|
+
"POSTGRES_DB",
|
|
126
|
+
"GCN_CONSUMER_CLIENT_ID",
|
|
127
|
+
"GCN_CONSUMER_CLIENT_SECRET",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def env_lines_for(keys):
|
|
131
|
+
lines = []
|
|
132
|
+
gcn_comment_pending = any(key.startswith("GCN_CONSUMER_") for key in keys)
|
|
133
|
+
for key in keys:
|
|
134
|
+
if key.startswith("GCN_CONSUMER_") and gcn_comment_pending:
|
|
135
|
+
# Once, before whichever GCN key lands first — an operator may
|
|
136
|
+
# have hand-added one of the two already.
|
|
137
|
+
lines.append(GCN_ENV_COMMENT)
|
|
138
|
+
gcn_comment_pending = False
|
|
139
|
+
lines.append(_format_env_line(key, REQUIRED_ENV_PROVIDERS[key]()))
|
|
140
|
+
return lines
|
|
141
|
+
|
|
142
|
+
if env_path.exists():
|
|
143
|
+
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
144
|
+
existing_keys = _parse_env_keys(existing_lines)
|
|
145
|
+
missing_keys = [
|
|
146
|
+
key for key in ordered_required_keys if key not in existing_keys
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
if not missing_keys:
|
|
150
|
+
print(f"{ENV_FILENAME} already contains all required keys.")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if existing_lines and existing_lines[-1].strip():
|
|
154
|
+
existing_lines.append("")
|
|
155
|
+
existing_lines.extend(env_lines_for(missing_keys))
|
|
156
|
+
|
|
157
|
+
env_path.write_text("\n".join(existing_lines) + "\n", encoding="utf-8")
|
|
158
|
+
print(
|
|
159
|
+
f"Updated {ENV_FILENAME} at: {env_path} (added keys: {', '.join(missing_keys)})"
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
env_contents = "\n".join(env_lines_for(ordered_required_keys))
|
|
164
|
+
env_path.write_text(env_contents + "\n", encoding="utf-8")
|
|
165
|
+
print(f"Wrote {ENV_FILENAME} to: {env_path}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_optional_service_decisions(env_path):
|
|
169
|
+
"""The recorded yes/no answers, as {name: bool}. Unknown names are kept
|
|
170
|
+
out of the dict but preserved in the file (see the recorder)."""
|
|
171
|
+
decisions = {}
|
|
172
|
+
if not env_path.exists():
|
|
173
|
+
return decisions
|
|
174
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
175
|
+
stripped = line.strip()
|
|
176
|
+
if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
|
|
177
|
+
continue
|
|
178
|
+
_, _, value = stripped.partition("=")
|
|
179
|
+
for token in value.split(","):
|
|
180
|
+
token = token.strip()
|
|
181
|
+
if not token:
|
|
182
|
+
continue
|
|
183
|
+
name, sep, verdict = token.partition(":")
|
|
184
|
+
if not sep or verdict not in ("yes", "no"):
|
|
185
|
+
print(
|
|
186
|
+
f"Ignoring malformed token '{token}' in {OPTIONAL_SERVICES_ENV_KEY}."
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
if name in OPTIONAL_SERVICES:
|
|
190
|
+
decisions[name] = verdict == "yes"
|
|
191
|
+
return decisions
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _record_optional_service_decision(env_path, name, enabled):
|
|
195
|
+
"""Rewrite only this service's token; tokens for services this CLI
|
|
196
|
+
version does not know about survive verbatim (downgrades happen)."""
|
|
197
|
+
verdict = f"{name}:{'yes' if enabled else 'no'}"
|
|
198
|
+
lines = (
|
|
199
|
+
env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
|
|
200
|
+
)
|
|
201
|
+
for index, line in enumerate(lines):
|
|
202
|
+
stripped = line.strip()
|
|
203
|
+
if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
|
|
204
|
+
continue
|
|
205
|
+
_, _, value = stripped.partition("=")
|
|
206
|
+
tokens = [t.strip() for t in value.split(",") if t.strip()]
|
|
207
|
+
tokens = [t for t in tokens if t.partition(":")[0] != name]
|
|
208
|
+
tokens.append(verdict)
|
|
209
|
+
lines[index] = f"{OPTIONAL_SERVICES_ENV_KEY}={','.join(sorted(tokens))}"
|
|
210
|
+
break
|
|
211
|
+
else:
|
|
212
|
+
if lines and lines[-1].strip():
|
|
213
|
+
lines.append("")
|
|
214
|
+
lines.append(f"{OPTIONAL_SERVICES_ENV_KEY}={verdict}")
|
|
215
|
+
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _optional_service_markers(name):
|
|
219
|
+
return f"# >>> arcsecond:{name}", f"# <<< arcsecond:{name}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _extract_optional_service_block(packaged_text, name):
|
|
223
|
+
begin, end = _optional_service_markers(name)
|
|
224
|
+
lines = packaged_text.splitlines()
|
|
225
|
+
begin_index = next(
|
|
226
|
+
(i for i, line in enumerate(lines) if line.strip() == begin), None
|
|
227
|
+
)
|
|
228
|
+
end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
|
|
229
|
+
if begin_index is None or end_index is None or end_index < begin_index:
|
|
230
|
+
return None
|
|
231
|
+
return lines[begin_index : end_index + 1]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _with_trailing_newline_like(lines, original_text):
|
|
235
|
+
return "\n".join(lines) + ("\n" if original_text.endswith("\n") else "")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _strip_optional_service_block(text, name):
|
|
239
|
+
begin, end = _optional_service_markers(name)
|
|
240
|
+
lines = text.splitlines()
|
|
241
|
+
begin_index = next(
|
|
242
|
+
(i for i, line in enumerate(lines) if line.strip() == begin), None
|
|
243
|
+
)
|
|
244
|
+
end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
|
|
245
|
+
if begin_index is None or end_index is None or end_index < begin_index:
|
|
246
|
+
return text
|
|
247
|
+
del lines[begin_index : end_index + 1]
|
|
248
|
+
# Drop the blank separator the block carried, so strip(splice(x)) == x.
|
|
249
|
+
if begin_index < len(lines) and not lines[begin_index].strip():
|
|
250
|
+
del lines[begin_index]
|
|
251
|
+
return _with_trailing_newline_like(lines, text)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _splice_optional_service_block(current_text, packaged_text, name):
|
|
255
|
+
"""Insert the packaged block before the top-level "volumes:" line.
|
|
256
|
+
Returns the new text, current_text if the block is already there,
|
|
257
|
+
or None when there is no anchor to splice against."""
|
|
258
|
+
begin, _ = _optional_service_markers(name)
|
|
259
|
+
lines = current_text.splitlines()
|
|
260
|
+
if any(line.strip() == begin for line in lines):
|
|
261
|
+
return current_text
|
|
262
|
+
block = _extract_optional_service_block(packaged_text, name)
|
|
263
|
+
if block is None:
|
|
264
|
+
return None
|
|
265
|
+
anchor = next(
|
|
266
|
+
(
|
|
267
|
+
i
|
|
268
|
+
for i, line in enumerate(lines)
|
|
269
|
+
if line.rstrip() == "volumes:" and not line[:1].isspace()
|
|
270
|
+
),
|
|
271
|
+
None,
|
|
272
|
+
)
|
|
273
|
+
if anchor is None:
|
|
274
|
+
return None
|
|
275
|
+
new_lines = lines[:anchor] + block + [""] + lines[anchor:]
|
|
276
|
+
return _with_trailing_newline_like(new_lines, current_text)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
VERSION_HEADER_RE = re.compile(r"^# Version .+$", flags=re.MULTILINE)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _compose_version(text):
|
|
283
|
+
match = re.search(r"^# Version (.+)$", text, flags=re.MULTILINE)
|
|
284
|
+
return match.group(1).strip() if match else None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _reconcile_version_header(current_text, expected_text):
|
|
288
|
+
"""When the *only* remaining difference is the '# Version X.Y' comment
|
|
289
|
+
line, adopt the packaged one — the header is ours, not operator content.
|
|
290
|
+
Without this, every pre-existing install would trail one version behind
|
|
291
|
+
forever and collect a spurious docker-compose.latest.yml on every run.
|
|
292
|
+
Returns the updated text, or None when the files differ beyond it."""
|
|
293
|
+
current_match = VERSION_HEADER_RE.search(current_text)
|
|
294
|
+
expected_match = VERSION_HEADER_RE.search(expected_text)
|
|
295
|
+
if current_match is None or expected_match is None:
|
|
296
|
+
return None
|
|
297
|
+
updated = (
|
|
298
|
+
current_text[: current_match.start()]
|
|
299
|
+
+ expected_match.group(0)
|
|
300
|
+
+ current_text[current_match.end() :]
|
|
301
|
+
)
|
|
302
|
+
return updated if updated == expected_text else None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def write_docker_compose_file(
|
|
306
|
+
enabled_services=frozenset(), removed_services=frozenset()
|
|
307
|
+
) -> Path:
|
|
308
|
+
"""
|
|
309
|
+
Materialise the packaged docker-compose.yml in the current directory.
|
|
310
|
+
|
|
311
|
+
The expected content is the packaged file minus the marker-delimited
|
|
312
|
+
blocks of optional services that are not enabled. Three cases:
|
|
313
|
+
1. No file present → write the expected content.
|
|
314
|
+
2. File present and identical to the expected content → no-op.
|
|
315
|
+
3. File present but different → splice enabled optional services in
|
|
316
|
+
(idempotently, before the top-level "volumes:" line) and remove
|
|
317
|
+
explicitly-disabled ones; if the file still differs — it carries
|
|
318
|
+
local customisations — leave it untouched and drop the expected
|
|
319
|
+
version next to it as docker-compose.latest.yml so the operator
|
|
320
|
+
can diff and merge intentionally.
|
|
321
|
+
|
|
322
|
+
Works from any CWD and when installed from a wheel/sdist.
|
|
323
|
+
"""
|
|
324
|
+
dest = Path.cwd() / "docker-compose.yml"
|
|
325
|
+
|
|
326
|
+
# arcsecond/hosting/docker/docker-compose.yml
|
|
327
|
+
compose = resources.files("arcsecond.hosting.docker").joinpath("docker-compose.yml")
|
|
328
|
+
|
|
329
|
+
with compose.open("rb") as src:
|
|
330
|
+
packaged_text = src.read().decode("utf-8")
|
|
331
|
+
|
|
332
|
+
expected_text = packaged_text
|
|
333
|
+
for name in OPTIONAL_SERVICES:
|
|
334
|
+
if name not in enabled_services:
|
|
335
|
+
expected_text = _strip_optional_service_block(expected_text, name)
|
|
336
|
+
expected_content = expected_text.encode("utf-8")
|
|
337
|
+
|
|
338
|
+
if not dest.exists():
|
|
339
|
+
dest.write_bytes(expected_content)
|
|
340
|
+
print(f"Wrote docker-compose.yml to: {dest}")
|
|
341
|
+
return dest
|
|
342
|
+
|
|
343
|
+
if dest.read_bytes() == expected_content:
|
|
344
|
+
print("docker-compose.yml is already up to date.")
|
|
345
|
+
return dest
|
|
346
|
+
|
|
347
|
+
current_text = dest.read_text(encoding="utf-8")
|
|
348
|
+
changes = []
|
|
349
|
+
unspliceable = []
|
|
350
|
+
|
|
351
|
+
for name in sorted(enabled_services):
|
|
352
|
+
spliced = _splice_optional_service_block(current_text, packaged_text, name)
|
|
353
|
+
if spliced is None:
|
|
354
|
+
unspliceable.append(name)
|
|
355
|
+
elif spliced != current_text:
|
|
356
|
+
current_text = spliced
|
|
357
|
+
changes.append(f"added the optional '{name}' service")
|
|
358
|
+
|
|
359
|
+
for name in sorted(removed_services):
|
|
360
|
+
stripped = _strip_optional_service_block(current_text, name)
|
|
361
|
+
if stripped != current_text:
|
|
362
|
+
current_text = stripped
|
|
363
|
+
changes.append(f"removed the optional '{name}' service")
|
|
364
|
+
|
|
365
|
+
if current_text.encode("utf-8") != expected_content:
|
|
366
|
+
reconciled = _reconcile_version_header(current_text, expected_text)
|
|
367
|
+
if reconciled is not None:
|
|
368
|
+
current_text = reconciled
|
|
369
|
+
changes.append(
|
|
370
|
+
f"updated the version header to {_compose_version(expected_text)}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if changes:
|
|
374
|
+
dest.write_text(current_text, encoding="utf-8")
|
|
375
|
+
print(f"Updated docker-compose.yml: {', '.join(changes)}.")
|
|
376
|
+
|
|
377
|
+
if current_text.encode("utf-8") == expected_content:
|
|
378
|
+
return dest
|
|
379
|
+
|
|
380
|
+
current_version = _compose_version(current_text)
|
|
381
|
+
yours = f"Version {current_version}" if current_version else "no Version header"
|
|
382
|
+
latest = dest.with_name("docker-compose.latest.yml")
|
|
383
|
+
latest.write_bytes(expected_content)
|
|
384
|
+
messages = [
|
|
385
|
+
"docker-compose.yml differs from the packaged version "
|
|
386
|
+
f"(yours: {yours}, packaged: Version {_compose_version(expected_text)}); "
|
|
387
|
+
f"leaving it untouched and writing the latest packaged copy to: {latest}"
|
|
388
|
+
]
|
|
389
|
+
for name in unspliceable:
|
|
390
|
+
messages.append(
|
|
391
|
+
f"Could not find a top-level 'volumes:' line to splice the "
|
|
392
|
+
f"'{name}' service into — merge it from {latest.name} by hand."
|
|
393
|
+
)
|
|
394
|
+
print("\n".join(messages))
|
|
395
|
+
return dest
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _stdin_is_interactive():
|
|
399
|
+
return sys.stdin.isatty()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _resolve_optional_services(env_path, flags):
|
|
403
|
+
"""Fold the explicit flags, the recorded decisions and (on a TTY) the
|
|
404
|
+
operator's answers into (enabled, removed, decisions_to_record)."""
|
|
405
|
+
decisions = _read_optional_service_decisions(env_path)
|
|
406
|
+
enabled, removed, to_record = set(), set(), []
|
|
407
|
+
prompts_skipped = False
|
|
408
|
+
|
|
409
|
+
for name, label in OPTIONAL_SERVICES.items():
|
|
410
|
+
flag = flags.get(name)
|
|
411
|
+
if flag is not None:
|
|
412
|
+
# An explicit flag always wins and rewrites the recorded answer.
|
|
413
|
+
(enabled if flag else removed).add(name)
|
|
414
|
+
to_record.append((name, flag))
|
|
415
|
+
elif name in decisions:
|
|
416
|
+
if decisions[name]:
|
|
417
|
+
enabled.add(name)
|
|
418
|
+
elif _stdin_is_interactive():
|
|
419
|
+
answer = click.confirm(
|
|
420
|
+
f"Include the optional {label} service in docker-compose.yml?",
|
|
421
|
+
default=False,
|
|
422
|
+
)
|
|
423
|
+
if answer:
|
|
424
|
+
enabled.add(name)
|
|
425
|
+
to_record.append((name, answer))
|
|
426
|
+
else:
|
|
427
|
+
prompts_skipped = True
|
|
428
|
+
|
|
429
|
+
if prompts_skipped:
|
|
430
|
+
print(
|
|
431
|
+
"Skipping optional-service prompts (non-interactive run); "
|
|
432
|
+
"use --with-alerts/--without-alerts to decide."
|
|
433
|
+
)
|
|
434
|
+
return enabled, removed, to_record
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@click.command(help="Prepare the installation of Arcsecond.local.")
|
|
438
|
+
@click.option(
|
|
439
|
+
"--with-alerts/--without-alerts",
|
|
440
|
+
"with_alerts",
|
|
441
|
+
default=None,
|
|
442
|
+
help="Include (or remove) the optional transient-alerts (ToO) service in "
|
|
443
|
+
"docker-compose.yml without prompting.",
|
|
444
|
+
)
|
|
445
|
+
@basic_options
|
|
446
|
+
def setup(with_alerts):
|
|
447
|
+
click.echo("\nWelcome to Arcsecond.local setup.")
|
|
448
|
+
click.echo(
|
|
449
|
+
"\nThis will write or update two files in this folder (.env and docker-compose.yml)."
|
|
450
|
+
)
|
|
451
|
+
click.echo("")
|
|
452
|
+
|
|
453
|
+
env_path = Path.cwd() / ENV_FILENAME
|
|
454
|
+
enabled, removed, to_record = _resolve_optional_services(
|
|
455
|
+
env_path, {"alerts": with_alerts}
|
|
456
|
+
)
|
|
457
|
+
write_env_file()
|
|
458
|
+
for name, answer in to_record:
|
|
459
|
+
_record_optional_service_decision(env_path, name, answer)
|
|
460
|
+
write_docker_compose_file(enabled_services=enabled, removed_services=removed)
|
|
461
|
+
|
|
462
|
+
if "alerts" in enabled:
|
|
463
|
+
click.echo(
|
|
464
|
+
"\nTransient alerts next steps: create GCN credentials (see "
|
|
465
|
+
"https://docs.arcsecond.io/local/transient-alerts), paste them into "
|
|
466
|
+
".env as GCN_CONSUMER_CLIENT_ID / GCN_CONSUMER_CLIENT_SECRET, then "
|
|
467
|
+
"run: docker compose up -d"
|
|
468
|
+
)
|
|
@@ -7,6 +7,7 @@ Designed for USB webcams attached to the host running the proxy.
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
|
+
import sys
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
12
13
|
from .base import FrameSource, SourceInfo
|
|
@@ -18,6 +19,28 @@ _JPEG_QUALITY = 60 # 0-100
|
|
|
18
19
|
_MAX_PROBE = 10 # device indices to probe during detection
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def _capture_backend():
|
|
23
|
+
"""The OpenCV backend to open device *indices* with, for this platform.
|
|
24
|
+
|
|
25
|
+
Never ``CAP_ANY``. Left to choose for itself, OpenCV walks its backend list
|
|
26
|
+
and — on a machine with no camera at that index — ends up at FFMPEG, which
|
|
27
|
+
interprets an integer index by asking libavdevice to enumerate DirectShow
|
|
28
|
+
devices. That path is slow, cannot work, and prints a wall of
|
|
29
|
+
``Could not enumerate audio only devices`` to stderr for every index probed.
|
|
30
|
+
|
|
31
|
+
Pinning the platform's native backend also keeps index numbering stable:
|
|
32
|
+
detection and ``open()`` must agree on what device index 1 means, and each
|
|
33
|
+
backend enumerates in its own order.
|
|
34
|
+
"""
|
|
35
|
+
import cv2
|
|
36
|
+
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
return cv2.CAP_DSHOW
|
|
39
|
+
if sys.platform == "darwin":
|
|
40
|
+
return cv2.CAP_AVFOUNDATION
|
|
41
|
+
return cv2.CAP_V4L2
|
|
42
|
+
|
|
43
|
+
|
|
21
44
|
class OpenCVWebcamSource(FrameSource):
|
|
22
45
|
kind = "webcam"
|
|
23
46
|
poll_interval = _FRAME_INTERVAL
|
|
@@ -29,10 +52,13 @@ class OpenCVWebcamSource(FrameSource):
|
|
|
29
52
|
self._cap = None
|
|
30
53
|
|
|
31
54
|
async def open(self) -> None:
|
|
32
|
-
import cv2
|
|
55
|
+
import cv2 # noqa: F401
|
|
33
56
|
|
|
34
57
|
loop = asyncio.get_running_loop()
|
|
35
|
-
|
|
58
|
+
backend = _capture_backend()
|
|
59
|
+
self._cap = await loop.run_in_executor(
|
|
60
|
+
None, cv2.VideoCapture, self.index, backend
|
|
61
|
+
)
|
|
36
62
|
if not await loop.run_in_executor(None, self._cap.isOpened):
|
|
37
63
|
raise RuntimeError(f"Cannot open webcam at device index {self.index}.")
|
|
38
64
|
|
|
@@ -75,9 +101,10 @@ def detect_webcams(max_index: int = _MAX_PROBE) -> list[SourceInfo]:
|
|
|
75
101
|
"""Blocking probe of device indices 0..max_index-1."""
|
|
76
102
|
import cv2
|
|
77
103
|
|
|
104
|
+
backend = _capture_backend()
|
|
78
105
|
found: list[SourceInfo] = []
|
|
79
106
|
for i in range(max_index):
|
|
80
|
-
cap = cv2.VideoCapture(i)
|
|
107
|
+
cap = cv2.VideoCapture(i, backend)
|
|
81
108
|
if not cap.isOpened():
|
|
82
109
|
cap.release()
|
|
83
110
|
continue
|