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.
Files changed (121) hide show
  1. {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/workflows/pythonpublish.yml +30 -17
  2. {arcsecond-3.9.0 → arcsecond-3.10.2}/PKG-INFO +1 -1
  3. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cli.py +5 -3
  4. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/docker-compose.yml +9 -10
  5. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/local.py +25 -6
  6. arcsecond-3.10.2/arcsecond/hosting/utils.py +24 -0
  7. arcsecond-3.10.2/arcsecond/imagesources/commands.py +175 -0
  8. arcsecond-3.10.2/arcsecond/imagesources/proxy.py +145 -0
  9. arcsecond-3.10.2/arcsecond/imagesources/registry.py +159 -0
  10. arcsecond-3.10.2/arcsecond/imagesources/sources/base.py +51 -0
  11. arcsecond-3.10.2/arcsecond/imagesources/sources/filewatch.py +113 -0
  12. arcsecond-3.10.2/arcsecond/imagesources/sources/opencv.py +93 -0
  13. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/config.js +1 -1
  14. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/api-basics.md +6 -0
  15. arcsecond-3.10.2/docs/rotate-postgres-password.md +78 -0
  16. arcsecond-3.10.2/docs/webcam.md +178 -0
  17. {arcsecond-3.9.0 → arcsecond-3.10.2}/pyproject.toml +5 -4
  18. arcsecond-3.10.2/tests/cloud/uploader/datafiles/__init__.py +0 -0
  19. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_hosting_local.py +33 -1
  20. arcsecond-3.9.0/arcsecond/hosting/utils.py +0 -13
  21. arcsecond-3.9.0/arcsecond/webcam/commands.py +0 -101
  22. arcsecond-3.9.0/arcsecond/webcam/proxy.py +0 -145
  23. arcsecond-3.9.0/docs/webcam.md +0 -116
  24. {arcsecond-3.9.0 → arcsecond-3.10.2}/.docker/Dockerfile_postgres +0 -0
  25. {arcsecond-3.9.0 → arcsecond-3.10.2}/.docker/Dockerfile_redis +0 -0
  26. {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/dependabot.yml +0 -0
  27. {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/workflows/docsdeploy.yml +0 -0
  28. {arcsecond-3.9.0 → arcsecond-3.10.2}/.github/workflows/tests.yml +0 -0
  29. {arcsecond-3.9.0 → arcsecond-3.10.2}/.gitignore +0 -0
  30. {arcsecond-3.9.0 → arcsecond-3.10.2}/LICENSE +0 -0
  31. {arcsecond-3.9.0 → arcsecond-3.10.2}/Makefile +0 -0
  32. {arcsecond-3.9.0 → arcsecond-3.10.2}/README.md +0 -0
  33. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/__init__.py +0 -0
  34. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/__version__.py +0 -0
  35. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/__init__.py +0 -0
  36. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/config.py +0 -0
  37. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/constants.py +0 -0
  38. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/endpoint.py +0 -0
  39. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/main.py +0 -0
  40. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/api/resources.py +0 -0
  41. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/__init__.py +0 -0
  42. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/auth.py +0 -0
  43. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/resources.py +0 -0
  44. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/__init__.py +0 -0
  45. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/__init__.py +0 -0
  46. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
  47. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
  48. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
  49. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
  50. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/constants.py +0 -0
  51. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/context.py +0 -0
  52. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  53. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  54. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  55. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  56. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  57. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/errors.py +0 -0
  58. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/logger.py +0 -0
  59. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/uploader.py +0 -0
  60. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/utils.py +0 -0
  61. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploader/walker.py +0 -0
  62. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/cloud/uploads.py +0 -0
  63. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/errors.py +0 -0
  64. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/__init__.py +0 -0
  65. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/checks.py +0 -0
  66. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/constants.py +0 -0
  67. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/__init__.py +0 -0
  68. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/constants.py +0 -0
  69. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/containers.py +0 -0
  70. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/images.py +0 -0
  71. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/docker/utils.py +0 -0
  72. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/__init__.py +0 -0
  73. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/client.py +0 -0
  74. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/keygen/utils.py +0 -0
  75. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/main.py +0 -0
  76. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
  77. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/setup.py +0 -0
  78. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/hosting/validation.py +0 -0
  79. {arcsecond-3.9.0/arcsecond/webcam → arcsecond-3.10.2/arcsecond/imagesources}/__init__.py +0 -0
  80. {arcsecond-3.9.0/tests → arcsecond-3.10.2/arcsecond/imagesources/sources}/__init__.py +0 -0
  81. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/options.py +0 -0
  82. {arcsecond-3.9.0 → arcsecond-3.10.2}/arcsecond/targets.py +0 -0
  83. {arcsecond-3.9.0 → arcsecond-3.10.2}/deploy.sh +0 -0
  84. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/theme/custom.css +0 -0
  85. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/.vitepress/theme/index.js +0 -0
  86. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/img/icon-logo.png +0 -0
  87. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/index.md +0 -0
  88. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/install.md +0 -0
  89. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/public/img/icon-logo.png +0 -0
  90. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/resources.md +0 -0
  91. {arcsecond-3.9.0 → arcsecond-3.10.2}/docs/upload.md +0 -0
  92. {arcsecond-3.9.0 → arcsecond-3.10.2}/examples/example_upload_files.py +0 -0
  93. {arcsecond-3.9.0 → arcsecond-3.10.2}/examples/example_upload_images.py +0 -0
  94. {arcsecond-3.9.0 → arcsecond-3.10.2}/package-lock.json +0 -0
  95. {arcsecond-3.9.0 → arcsecond-3.10.2}/package.json +0 -0
  96. {arcsecond-3.9.0 → arcsecond-3.10.2}/poetry.lock +0 -0
  97. {arcsecond-3.9.0 → arcsecond-3.10.2}/requirements.txt +0 -0
  98. {arcsecond-3.9.0 → arcsecond-3.10.2}/setup.cfg +0 -0
  99. {arcsecond-3.9.0 → arcsecond-3.10.2}/setup.py +0 -0
  100. {arcsecond-3.9.0 → arcsecond-3.10.2}/sonar-project.properties +0 -0
  101. {arcsecond-3.9.0/tests/api → arcsecond-3.10.2/tests}/__init__.py +0 -0
  102. {arcsecond-3.9.0/tests/cloud → arcsecond-3.10.2/tests/api}/__init__.py +0 -0
  103. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_api.py +0 -0
  104. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_api_endpoint.py +0 -0
  105. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_config.py +0 -0
  106. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/api/test_targets.py +0 -0
  107. {arcsecond-3.9.0/tests/cloud/uploader → arcsecond-3.10.2/tests/cloud}/__init__.py +0 -0
  108. {arcsecond-3.9.0/tests/cloud/uploader/allskycameraimages → arcsecond-3.10.2/tests/cloud/uploader}/__init__.py +0 -0
  109. {arcsecond-3.9.0/tests/cloud/uploader/datafiles → arcsecond-3.10.2/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
  110. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
  111. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
  112. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  113. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  114. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  115. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  116. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  117. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/conftest.py +0 -0
  118. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/fixtures/file1.fits +0 -0
  119. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_cli.py +0 -0
  120. {arcsecond-3.9.0 → arcsecond-3.10.2}/tests/test_targets_planning.py +0 -0
  121. {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
- release:
5
- types: [ published ]
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: read
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 release tag and version (X.Y.Z and matches pyproject)
16
+ - name: Validate tag format (X.Y.Z)
16
17
  shell: bash
17
18
  run: |
18
- TAG="${{ github.event.release.tag_name }}"
19
- echo "Release tag: $TAG"
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
- raise SystemExit(f"Version mismatch: tag={tag} but pyproject.toml={py_ver}")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcsecond
3
- Version: 3.9.0
3
+ Version: 3.10.2
4
4
  Summary: CLI for arcsecond.io
5
5
  Project-URL: Homepage, https://github.com/arcsecond-io/cli
6
6
  Project-URL: Issues, https://github.com/arcsecond-io/cli/issues
@@ -10,7 +10,7 @@ from arcsecond.cloud import (
10
10
  upload_data,
11
11
  )
12
12
  from arcsecond.hosting import setup
13
- from arcsecond.webcam import commands as webcam
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 webcam proxy — lets Docker containers reach USB webcams on the host.
65
- main.add_command(webcam.webcam)
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
- "POSTGRES_PASSWORD": POSTGRES_PASSWORD,
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
- Copy the packaged docker-compose.yml to the current directory.
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.write_bytes(expected_content)
129
- print("docker-compose.yml has been updated to the latest version.")
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))