arcsecond 3.9.0__tar.gz → 3.10.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.9.0 → arcsecond-3.10.2}/.github/workflows/pythonpublish.yml +30 -17
- {arcsecond-3.9.0 → arcsecond-3.10.2}/PKG-INFO +1 -1
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cli.py +5 -3
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/docker-compose.yml +9 -10
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/local.py +25 -6
- arcsecond-3.10.2/arcsecond/hosting/utils.py +24 -0
- arcsecond-3.10.2/arcsecond/imagesources/commands.py +175 -0
- arcsecond-3.10.2/arcsecond/imagesources/proxy.py +145 -0
- arcsecond-3.10.2/arcsecond/imagesources/registry.py +159 -0
- arcsecond-3.10.2/arcsecond/imagesources/sources/base.py +51 -0
- arcsecond-3.10.2/arcsecond/imagesources/sources/filewatch.py +113 -0
- arcsecond-3.10.2/arcsecond/imagesources/sources/opencv.py +93 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/config.js +1 -1
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/api-basics.md +6 -0
- arcsecond-3.10.2/docs/rotate-postgres-password.md +78 -0
- arcsecond-3.10.2/docs/webcam.md +178 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/pyproject.toml +5 -4
- arcsecond-3.10.2/tests/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_hosting_local.py +33 -1
- arcsecond-3.9.0/arcsecond/hosting/utils.py +0 -13
- arcsecond-3.9.0/arcsecond/webcam/commands.py +0 -101
- arcsecond-3.9.0/arcsecond/webcam/proxy.py +0 -145
- arcsecond-3.9.0/docs/webcam.md +0 -116
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/dependabot.yml +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/workflows/docsdeploy.yml +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/.gitignore +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/LICENSE +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/Makefile +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/README.md +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/__version__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/config.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/endpoint.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/main.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/resources.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/errors.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.9.0/arcsecond/webcam → arcsecond-3.10.2/arcsecond/imagesources}/__init__.py +0 -0
- {arcsecond-3.9.0/tests → arcsecond-3.10.2/arcsecond/imagesources/sources}/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/options.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/targets.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/deploy.sh +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/theme/custom.css +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/theme/index.js +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/img/icon-logo.png +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/index.md +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/install.md +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/public/img/icon-logo.png +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/resources.md +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/upload.md +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/examples/example_upload_files.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/examples/example_upload_images.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/package-lock.json +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/package.json +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/poetry.lock +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/requirements.txt +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/setup.cfg +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/setup.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/sonar-project.properties +0 -0
- {arcsecond-3.9.0/tests/api → arcsecond-3.10.2/tests}/__init__.py +0 -0
- {arcsecond-3.9.0/tests/cloud → arcsecond-3.10.2/tests/api}/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_api.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_api_endpoint.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_config.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_targets.py +0 -0
- {arcsecond-3.9.0/tests/cloud/uploader → arcsecond-3.10.2/tests/cloud}/__init__.py +0 -0
- {arcsecond-3.9.0/tests/cloud/uploader/allskycameraimages → arcsecond-3.10.2/tests/cloud/uploader}/__init__.py +0 -0
- {arcsecond-3.9.0/tests/cloud/uploader/datafiles → arcsecond-3.10.2/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/conftest.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_cli.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_targets_planning.py +0 -0
- {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/utils.py +0 -0
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
name: Upload Python Package to Pypi
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- '[0-9]+.[0-9]+.[0-9]+'
|
|
6
7
|
|
|
7
8
|
jobs:
|
|
8
9
|
deploy:
|
|
9
10
|
runs-on: ubuntu-latest
|
|
10
11
|
permissions:
|
|
11
|
-
contents:
|
|
12
|
-
id-token: write
|
|
12
|
+
contents: write # needed to create the GitHub release for the tag
|
|
13
|
+
id-token: write # needed for PyPI trusted publishing (OIDC)
|
|
13
14
|
|
|
14
15
|
steps:
|
|
15
|
-
- name: Validate
|
|
16
|
+
- name: Validate tag format (X.Y.Z)
|
|
16
17
|
shell: bash
|
|
17
18
|
run: |
|
|
18
|
-
TAG="${
|
|
19
|
-
echo "
|
|
19
|
+
TAG="${GITHUB_REF_NAME}"
|
|
20
|
+
echo "Pushed tag: $TAG"
|
|
20
21
|
if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
21
22
|
echo "Tag must be X.Y.Z (e.g. 1.2.3). Got: $TAG"
|
|
22
23
|
exit 1
|
|
@@ -24,27 +25,25 @@ jobs:
|
|
|
24
25
|
|
|
25
26
|
- uses: actions/checkout@v5
|
|
26
27
|
|
|
28
|
+
- name: Set up Python
|
|
29
|
+
uses: actions/setup-python@v6
|
|
30
|
+
with:
|
|
31
|
+
python-version: '3.12'
|
|
32
|
+
|
|
27
33
|
- name: Validate pyproject.toml version matches tag
|
|
28
34
|
run: |
|
|
29
35
|
python - <<'PY'
|
|
30
|
-
import pathlib
|
|
31
|
-
import tomllib
|
|
32
|
-
|
|
33
|
-
tag = "${{ github.event.release.tag_name }}"
|
|
36
|
+
import os, pathlib, tomllib
|
|
34
37
|
|
|
38
|
+
tag = os.environ["GITHUB_REF_NAME"]
|
|
35
39
|
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
|
|
36
40
|
py_ver = data["project"]["version"]
|
|
37
41
|
|
|
38
42
|
print(f"pyproject.toml version: {py_ver}")
|
|
39
43
|
if py_ver != tag:
|
|
40
|
-
|
|
44
|
+
raise SystemExit(f"Version mismatch: tag={tag} but pyproject.toml={py_ver}")
|
|
41
45
|
PY
|
|
42
46
|
|
|
43
|
-
- name: Set up Python
|
|
44
|
-
uses: actions/setup-python@v6
|
|
45
|
-
with:
|
|
46
|
-
python-version: '3.12'
|
|
47
|
-
|
|
48
47
|
- name: Build distributions
|
|
49
48
|
run: |
|
|
50
49
|
python -m pip install --upgrade pip
|
|
@@ -55,3 +54,17 @@ jobs:
|
|
|
55
54
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
56
55
|
with:
|
|
57
56
|
packages-dir: dist
|
|
57
|
+
|
|
58
|
+
- name: Create GitHub release for tag
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
61
|
+
run: |
|
|
62
|
+
TAG="${GITHUB_REF_NAME}"
|
|
63
|
+
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
64
|
+
echo "Release $TAG already exists, skipping."
|
|
65
|
+
else
|
|
66
|
+
gh release create "$TAG" \
|
|
67
|
+
--title "$TAG" \
|
|
68
|
+
--generate-notes \
|
|
69
|
+
dist/*
|
|
70
|
+
fi
|
|
@@ -10,7 +10,7 @@ from arcsecond.cloud import (
|
|
|
10
10
|
upload_data,
|
|
11
11
|
)
|
|
12
12
|
from arcsecond.hosting import setup
|
|
13
|
-
from arcsecond.
|
|
13
|
+
from arcsecond.imagesources import commands as imagesources
|
|
14
14
|
|
|
15
15
|
from . import __version__
|
|
16
16
|
from .options import State
|
|
@@ -61,5 +61,7 @@ main.add_command(upload_data)
|
|
|
61
61
|
# Allow to try arcsecond by installing a local version
|
|
62
62
|
main.add_command(setup)
|
|
63
63
|
|
|
64
|
-
# Native
|
|
65
|
-
|
|
64
|
+
# Native live-image proxy — exposes USB webcams and all-sky cameras to
|
|
65
|
+
# Arcsecond.local Docker containers via host.docker.internal.
|
|
66
|
+
main.add_command(imagesources.webcam)
|
|
67
|
+
main.add_command(imagesources.allsky)
|
|
@@ -8,8 +8,12 @@ services:
|
|
|
8
8
|
image: postgres:16
|
|
9
9
|
container_name: arcsecond-db
|
|
10
10
|
restart: unless-stopped
|
|
11
|
+
# Bind to localhost only — the backend container reaches the DB over the
|
|
12
|
+
# internal Docker network (hostname `arcsecond-db`), and operators can
|
|
13
|
+
# still run `psql -h localhost` / `pg_dump` from the host. Anything on the
|
|
14
|
+
# LAN is firewalled out.
|
|
11
15
|
ports:
|
|
12
|
-
- "5432:5432"
|
|
16
|
+
- "127.0.0.1:5432:5432"
|
|
13
17
|
volumes:
|
|
14
18
|
- arcsecond_postgres_data:/var/lib/postgresql/data
|
|
15
19
|
env_file:
|
|
@@ -21,8 +25,10 @@ services:
|
|
|
21
25
|
image: redis:7.4
|
|
22
26
|
container_name: arcsecond-broker
|
|
23
27
|
restart: unless-stopped
|
|
28
|
+
# Localhost-only as well — Redis with no auth on a LAN-exposed port is a
|
|
29
|
+
# well-known foot-gun. Backend reaches it over the Docker network.
|
|
24
30
|
ports:
|
|
25
|
-
- "6379:6379"
|
|
31
|
+
- "127.0.0.1:6379:6379"
|
|
26
32
|
|
|
27
33
|
# Arcsecond backend (REST APIs). Can be used for API calls and external pipelines, scripts etc.
|
|
28
34
|
backend:
|
|
@@ -96,15 +102,8 @@ services:
|
|
|
96
102
|
platesolver:
|
|
97
103
|
image: ghcr.io/arcsecond-io/arcsecond-service-platesolver-astrometry:latest
|
|
98
104
|
container_name: arcsecond-platesolver
|
|
105
|
+
restart: unless-stopped
|
|
99
106
|
ports: [ "8900:8900" ]
|
|
100
|
-
volumes:
|
|
101
|
-
# Leave /data as is, it's a path inside the container, not in the host machine.
|
|
102
|
-
# SHARED_DATA_PATH must be a path of your host machine, specified in the .env file.
|
|
103
|
-
- type: bind
|
|
104
|
-
source: ${SHARED_DATA_PATH}
|
|
105
|
-
target: /data
|
|
106
|
-
bind:
|
|
107
|
-
create_host_path: true
|
|
108
107
|
|
|
109
108
|
# Arcsecond webapp.
|
|
110
109
|
web:
|
|
@@ -5,12 +5,14 @@ from pathlib import Path
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
7
|
from arcsecond.options import basic_options
|
|
8
|
-
from .utils import _get_encryption_key, _get_random_secret_key
|
|
8
|
+
from .utils import _get_encryption_key, _get_random_postgres_password, _get_random_secret_key
|
|
9
9
|
|
|
10
10
|
ENV_FILENAME = ".env"
|
|
11
11
|
|
|
12
|
+
# Stable across installs — operators connect with this username when running
|
|
13
|
+
# manual psql / pg_dump commands. The actual security boundary is the password
|
|
14
|
+
# (generated per-install) and the network exposure (localhost-only).
|
|
12
15
|
POSTGRES_USER = "arcsecond_docker"
|
|
13
|
-
POSTGRES_PASSWORD = "arcsecond_docker"
|
|
14
16
|
POSTGRES_DB = "arcsecond_docker"
|
|
15
17
|
|
|
16
18
|
|
|
@@ -39,7 +41,11 @@ def _required_env_values():
|
|
|
39
41
|
"FIELD_ENCRYPTION_KEY": _get_encryption_key(),
|
|
40
42
|
"SHARED_DATA_PATH": prompt_shared_data_path(),
|
|
41
43
|
"POSTGRES_USER": POSTGRES_USER,
|
|
42
|
-
|
|
44
|
+
# Per-install random; never overwritten on repeat runs (see write_env_file).
|
|
45
|
+
# Postgres only reads this on first container boot to bootstrap the role,
|
|
46
|
+
# so the .env value and the live DB password must stay in sync — that's
|
|
47
|
+
# why we never regenerate it after the .env exists.
|
|
48
|
+
"POSTGRES_PASSWORD": _get_random_postgres_password(),
|
|
43
49
|
"POSTGRES_DB": POSTGRES_DB,
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -104,7 +110,16 @@ def write_env_file():
|
|
|
104
110
|
|
|
105
111
|
def write_docker_compose_file() -> Path:
|
|
106
112
|
"""
|
|
107
|
-
|
|
113
|
+
Materialise the packaged docker-compose.yml in the current directory.
|
|
114
|
+
|
|
115
|
+
Three cases:
|
|
116
|
+
1. No file present → write the packaged version as docker-compose.yml.
|
|
117
|
+
2. File present and identical to the packaged version → no-op.
|
|
118
|
+
3. File present but different → leave the operator's file untouched
|
|
119
|
+
(it may carry local customisations) and drop the packaged
|
|
120
|
+
version next to it as docker-compose.latest.yml so they can
|
|
121
|
+
diff and merge intentionally.
|
|
122
|
+
|
|
108
123
|
Works from any CWD and when installed from a wheel/sdist.
|
|
109
124
|
"""
|
|
110
125
|
dest = Path.cwd() / "docker-compose.yml"
|
|
@@ -125,8 +140,12 @@ def write_docker_compose_file() -> Path:
|
|
|
125
140
|
print("docker-compose.yml is already up to date.")
|
|
126
141
|
return dest
|
|
127
142
|
|
|
128
|
-
dest.
|
|
129
|
-
|
|
143
|
+
latest = dest.with_name("docker-compose.latest.yml")
|
|
144
|
+
latest.write_bytes(expected_content)
|
|
145
|
+
print(
|
|
146
|
+
"docker-compose.yml differs from the packaged version; "
|
|
147
|
+
f"leaving it untouched and writing the latest packaged copy to: {latest}"
|
|
148
|
+
)
|
|
130
149
|
return dest
|
|
131
150
|
|
|
132
151
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _get_random_secret_key():
|
|
7
|
+
# No '%' to avoid interpolation surprises
|
|
8
|
+
chars = "abcdefghijklmnopqrstuvwxyz0123456789!@#$^&*(-_=+)"
|
|
9
|
+
return "".join(secrets.choice(chars) for _ in range(50))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_encryption_key():
|
|
13
|
+
return base64.urlsafe_b64encode(os.urandom(32)).decode('UTF8')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_random_postgres_password():
|
|
17
|
+
"""Generate a strong, shell- and URL-safe Postgres password.
|
|
18
|
+
|
|
19
|
+
Uses URL-safe base64 (``[A-Za-z0-9_-]``) so the value never needs
|
|
20
|
+
quoting in the .env file or any future connection string. 32 random
|
|
21
|
+
bytes → 43 chars, ~256 bits of entropy. Trailing '=' padding is
|
|
22
|
+
stripped because it triggers shell parsing weirdness in some setups.
|
|
23
|
+
"""
|
|
24
|
+
return base64.urlsafe_b64encode(os.urandom(32)).decode('UTF8').rstrip('=')
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Click command groups: ``arcsecond webcam`` and ``arcsecond allsky``.
|
|
3
|
+
|
|
4
|
+
Both groups talk to the same proxy. ``webcam`` is kept for backward
|
|
5
|
+
compatibility; ``allsky`` is the new entry point for fisheye / all-sky
|
|
6
|
+
cameras whose driver software writes JPEGs to disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .registry import AllskyOverride
|
|
14
|
+
from .sources.filewatch import detect_allsky
|
|
15
|
+
from .sources.opencv import detect_webcams
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_aiohttp():
|
|
21
|
+
try:
|
|
22
|
+
import aiohttp # noqa: F401
|
|
23
|
+
except ImportError:
|
|
24
|
+
click.echo(
|
|
25
|
+
click.style("Error: ", fg='red') +
|
|
26
|
+
"aiohttp is not installed.\n"
|
|
27
|
+
"Run: pip install 'arcsecond[webcam]'"
|
|
28
|
+
)
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_cv2():
|
|
33
|
+
try:
|
|
34
|
+
import cv2 # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
click.echo(
|
|
37
|
+
click.style("Error: ", fg='red') +
|
|
38
|
+
"opencv-python-headless is not installed.\n"
|
|
39
|
+
"Run: pip install 'arcsecond[webcam]'"
|
|
40
|
+
)
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_allsky_overrides(values: tuple[str, ...]) -> list[AllskyOverride]:
|
|
45
|
+
overrides: list[AllskyOverride] = []
|
|
46
|
+
for v in values:
|
|
47
|
+
if '=' not in v:
|
|
48
|
+
raise click.BadParameter(f"--allsky must be id=path, got {v!r}")
|
|
49
|
+
sid, _, path = v.partition('=')
|
|
50
|
+
sid, path = sid.strip(), path.strip()
|
|
51
|
+
if not sid or not path:
|
|
52
|
+
raise click.BadParameter(f"--allsky must be id=path, got {v!r}")
|
|
53
|
+
overrides.append(AllskyOverride(id=sid, path=path))
|
|
54
|
+
return overrides
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_proxy(host: str, port: int, log_level: str, allsky_overrides):
|
|
58
|
+
_check_aiohttp()
|
|
59
|
+
|
|
60
|
+
logging.basicConfig(
|
|
61
|
+
level=getattr(logging, log_level.upper()),
|
|
62
|
+
format='%(asctime)s %(levelname)-8s %(name)s %(message)s',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
click.echo(
|
|
66
|
+
click.style("Arcsecond live-image proxy", bold=True) +
|
|
67
|
+
f" listening on {host}:{port}\n"
|
|
68
|
+
f" Detection → http://{host}:{port}/detect\n"
|
|
69
|
+
f" Streaming → ws://{host}:{port}/stream/{{id}}\n\n"
|
|
70
|
+
"Set in your .env file:\n"
|
|
71
|
+
f" LIVE_IMAGE_PROXY_URL=http://host.docker.internal:{port}\n\n"
|
|
72
|
+
"Press Ctrl-C to stop."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from .proxy import run
|
|
76
|
+
run(host=host, port=port, allsky_overrides=allsky_overrides)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Shared options
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_start_options = [
|
|
84
|
+
click.option('--port', default=8765, show_default=True, help="TCP port to listen on."),
|
|
85
|
+
click.option('--host', default='0.0.0.0', show_default=True, help="Interface to bind."),
|
|
86
|
+
click.option('--log-level', default='INFO', show_default=True,
|
|
87
|
+
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR'], case_sensitive=False),
|
|
88
|
+
help="Logging verbosity."),
|
|
89
|
+
click.option('--allsky', 'allsky', multiple=True, metavar='ID=PATH',
|
|
90
|
+
help="Register an all-sky source. PATH may be a file or a glob. "
|
|
91
|
+
"Repeat for multiple cameras. If omitted, well-known paths "
|
|
92
|
+
"are auto-discovered."),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _add_options(options):
|
|
97
|
+
def _wrap(fn):
|
|
98
|
+
for opt in reversed(options):
|
|
99
|
+
fn = opt(fn)
|
|
100
|
+
return fn
|
|
101
|
+
return _wrap
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# `arcsecond webcam` group (kept for backward compatibility)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@click.group(help="Manage the native live-image proxy (USB webcams + all-sky).")
|
|
109
|
+
def webcam():
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@webcam.command(name='detect', help="Scan for locally attached webcams and print their details.")
|
|
114
|
+
def webcam_detect_cmd():
|
|
115
|
+
_check_cv2()
|
|
116
|
+
cams = detect_webcams()
|
|
117
|
+
if not cams:
|
|
118
|
+
click.echo("No webcams detected.")
|
|
119
|
+
return
|
|
120
|
+
click.echo(f"Found {len(cams)} webcam(s):\n")
|
|
121
|
+
for c in cams:
|
|
122
|
+
e = c.extra or {}
|
|
123
|
+
click.echo(f" {c.id} {e.get('width')}×{e.get('height')} {e.get('fps', 0):.1f} fps")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@webcam.command(name='start', help=(
|
|
127
|
+
"Start the live-image proxy server.\n\n"
|
|
128
|
+
"Exposes:\n\n"
|
|
129
|
+
" GET /detect — list available sources (JSON)\n\n"
|
|
130
|
+
" WS /stream/{id} — JPEG frame stream\n\n"
|
|
131
|
+
"Source ids are e.g. webcam:0, allsky:roof. Bare numeric ids "
|
|
132
|
+
"(/stream/0) are accepted for backward compatibility.\n\n"
|
|
133
|
+
"Set LIVE_IMAGE_PROXY_URL=http://host.docker.internal:<PORT> in your "
|
|
134
|
+
".env so the backend can reach the proxy."
|
|
135
|
+
))
|
|
136
|
+
@_add_options(_start_options)
|
|
137
|
+
def webcam_start_cmd(port, host, log_level, allsky):
|
|
138
|
+
overrides = _parse_allsky_overrides(allsky)
|
|
139
|
+
_run_proxy(host, port, log_level, overrides)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# `arcsecond allsky` group (new)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
@click.group(help="Manage all-sky camera image sources.")
|
|
147
|
+
def allsky():
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@allsky.command(name='detect', help=(
|
|
152
|
+
"Auto-discover all-sky sources at well-known paths "
|
|
153
|
+
"(Thomas Jacquin's allsky, indi-allsky)."
|
|
154
|
+
))
|
|
155
|
+
def allsky_detect_cmd():
|
|
156
|
+
found = detect_allsky()
|
|
157
|
+
if not found:
|
|
158
|
+
click.echo("No all-sky sources discovered at well-known paths.")
|
|
159
|
+
click.echo("\nIf your software writes images elsewhere, register a custom path with:\n")
|
|
160
|
+
click.echo(" arcsecond allsky start --allsky <id>=<path-to-jpeg-or-glob>")
|
|
161
|
+
return
|
|
162
|
+
click.echo(f"Found {len(found)} all-sky source(s):\n")
|
|
163
|
+
for s in found:
|
|
164
|
+
click.echo(f" {s.id} → {(s.extra or {}).get('path')}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@allsky.command(name='start', help=(
|
|
168
|
+
"Start the live-image proxy server (same proxy as `arcsecond webcam start`).\n\n"
|
|
169
|
+
"Use --allsky id=path to register custom all-sky paths in addition to "
|
|
170
|
+
"auto-discovered ones."
|
|
171
|
+
))
|
|
172
|
+
@_add_options(_start_options)
|
|
173
|
+
def allsky_start_cmd(port, host, log_level, allsky):
|
|
174
|
+
overrides = _parse_allsky_overrides(allsky)
|
|
175
|
+
_run_proxy(host, port, log_level, overrides)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live-image proxy server for the Arcsecond CLI.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
The Arcsecond backend runs inside Docker Desktop (Windows / macOS), which does
|
|
7
|
+
not forward USB devices — and may not have access to host filesystem paths
|
|
8
|
+
where all-sky software writes images. This small aiohttp server runs
|
|
9
|
+
**natively** on the host and exposes:
|
|
10
|
+
|
|
11
|
+
GET /detect → JSON list of available sources
|
|
12
|
+
WS /stream/{id} → continuous JPEG-frame stream (base64-in-JSON)
|
|
13
|
+
|
|
14
|
+
Source ids look like ``webcam:0`` or ``allsky:roof``. For backward
|
|
15
|
+
compatibility, a bare numeric id (``/stream/0``) is treated as ``webcam:0``.
|
|
16
|
+
|
|
17
|
+
The backend reads the ``LIVE_IMAGE_PROXY_URL`` environment variable
|
|
18
|
+
(``WEBCAM_PROXY_URL`` is accepted as a deprecated fallback). When set, it
|
|
19
|
+
delegates detection and streaming to this proxy.
|
|
20
|
+
|
|
21
|
+
Start with:
|
|
22
|
+
arcsecond webcam start # webcams only (legacy)
|
|
23
|
+
arcsecond imagesources start # webcams + all-sky
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from .registry import AllskyOverride, Registry
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# aiohttp request handlers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
async def handle_health(request):
|
|
43
|
+
from aiohttp import web
|
|
44
|
+
return web.json_response({'status': 'ok'})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def handle_detect(request):
|
|
48
|
+
from aiohttp import web
|
|
49
|
+
registry: Registry = request.app['registry']
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
infos = await loop.run_in_executor(None, registry.detect)
|
|
52
|
+
return web.json_response([asdict(i) for i in infos])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def handle_stream(request):
|
|
56
|
+
from aiohttp import web
|
|
57
|
+
|
|
58
|
+
registry: Registry = request.app['registry']
|
|
59
|
+
source_id = request.match_info['id']
|
|
60
|
+
|
|
61
|
+
ws = web.WebSocketResponse()
|
|
62
|
+
await ws.prepare(request)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
acquired = await registry.acquire(source_id)
|
|
66
|
+
except KeyError as e:
|
|
67
|
+
logger.error("Live-image proxy: %s", e)
|
|
68
|
+
await ws.send_str(json.dumps({'type': 'error', 'message': str(e)}))
|
|
69
|
+
await ws.close()
|
|
70
|
+
return ws
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error("Live-image proxy: cannot open %s: %s", source_id, e)
|
|
73
|
+
await ws.send_str(json.dumps({'type': 'error', 'message': str(e)}))
|
|
74
|
+
await ws.close()
|
|
75
|
+
return ws
|
|
76
|
+
|
|
77
|
+
logger.info(
|
|
78
|
+
"Live-image proxy: client connected to stream %s (refcount=%d)",
|
|
79
|
+
source_id, acquired.refcount,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Tolerate a few transient read failures before giving up. ~2 s at the
|
|
83
|
+
# source's poll rate covers most one-off DirectShow hiccups.
|
|
84
|
+
max_consecutive_failures = max(1, int(2.0 / max(acquired.poll_interval, 0.01)))
|
|
85
|
+
consecutive_failures = 0
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
while not ws.closed:
|
|
89
|
+
try:
|
|
90
|
+
jpeg: Optional[bytes] = await acquired.read()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
consecutive_failures += 1
|
|
93
|
+
if consecutive_failures >= max_consecutive_failures:
|
|
94
|
+
msg = f"frame read failed for {source_id}: {e}"
|
|
95
|
+
logger.warning(
|
|
96
|
+
"Live-image proxy: %s (after %d attempts)",
|
|
97
|
+
msg, consecutive_failures,
|
|
98
|
+
)
|
|
99
|
+
await ws.send_str(json.dumps({'type': 'error', 'message': msg}))
|
|
100
|
+
break
|
|
101
|
+
await asyncio.sleep(acquired.poll_interval)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
consecutive_failures = 0
|
|
105
|
+
if jpeg is not None:
|
|
106
|
+
b64 = base64.b64encode(jpeg).decode('ascii')
|
|
107
|
+
await ws.send_str(json.dumps({
|
|
108
|
+
'type': 'frame',
|
|
109
|
+
'format': 'jpeg/base64',
|
|
110
|
+
'data': b64,
|
|
111
|
+
}))
|
|
112
|
+
await asyncio.sleep(acquired.poll_interval)
|
|
113
|
+
except (ConnectionResetError, ConnectionError):
|
|
114
|
+
pass
|
|
115
|
+
finally:
|
|
116
|
+
remaining = await acquired.release()
|
|
117
|
+
logger.info(
|
|
118
|
+
"Live-image proxy: client disconnected from %s (refcount=%d).",
|
|
119
|
+
source_id, remaining,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return ws
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Server entry-point
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def run(
|
|
130
|
+
host: str = '0.0.0.0',
|
|
131
|
+
port: int = 8765,
|
|
132
|
+
allsky_overrides: Optional[list[AllskyOverride]] = None,
|
|
133
|
+
):
|
|
134
|
+
"""Build and run the aiohttp application (blocking)."""
|
|
135
|
+
from aiohttp import web
|
|
136
|
+
|
|
137
|
+
app = web.Application()
|
|
138
|
+
app['registry'] = Registry(allsky_overrides=allsky_overrides)
|
|
139
|
+
|
|
140
|
+
app.router.add_get('/health', handle_health)
|
|
141
|
+
app.router.add_get('/detect', handle_detect)
|
|
142
|
+
app.router.add_get('/stream/{id}', handle_stream)
|
|
143
|
+
|
|
144
|
+
logger.info("Live-image proxy starting on %s:%d", host, port)
|
|
145
|
+
web.run_app(app, host=host, port=port, print=lambda msg: logger.info(msg))
|