Skip to content

Commit 302a136

Browse files
onekiloparsecclaude
andcommitted
Generalize webcam proxy to a live-image proxy supporting all-sky cameras
Refactors the native CLI proxy from webcam-only to a source-agnostic live-image proxy. USB webcams keep working unchanged; all-sky cameras that write JPEGs to disk (Thomas Jacquin's allsky, indi-allsky, ...) are now supported through the same WebSocket transport, addressed by source ids like webcam:0 or allsky:roof. - New arcsecond/imagesources/ package replaces arcsecond/webcam/, with a FrameSource ABC and two implementations: OpenCV (webcams) and a file- watcher (all-sky JPEGs, mtime-based, file or glob). - All-sky paths auto-discovered at well-known locations; custom paths can be registered with --allsky id=path on `webcam start` or `allsky start`. - New `arcsecond allsky` CLI group (detect, start). `arcsecond webcam` remains as backward-compatible alias. - Wire protocol unchanged: {type, format: "jpeg/base64", data}. Numeric /stream/{N} ids still resolve to webcam:N. - Backend env var renamed WEBCAM_PROXY_URL → LIVE_IMAGE_PROXY_URL. - pip install arcsecond[webcam] still works (extra name preserved). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1eaec6c commit 302a136

14 files changed

Lines changed: 708 additions & 292 deletions

File tree

arcsecond/cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
upload_data,
1111
)
1212
from arcsecond.hosting import setup
13-
from arcsecond.webcam import commands as webcam
13+
from arcsecond.imagesources import commands as imagesources
1414

1515
from . import __version__
1616
from .options import State
@@ -61,5 +61,7 @@ def version():
6161
# Allow to try arcsecond by installing a local version
6262
main.add_command(setup)
6363

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)

arcsecond/imagesources/commands.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

arcsecond/imagesources/proxy.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
logger.info("Live-image proxy: client connected to stream %s", source_id)
64+
65+
try:
66+
source = registry.open_source(source_id)
67+
except KeyError as e:
68+
logger.error("Live-image proxy: %s", e)
69+
await ws.send_str(json.dumps({'type': 'error', 'message': str(e)}))
70+
await ws.close()
71+
return ws
72+
73+
try:
74+
await source.open()
75+
except Exception as e:
76+
logger.error("Live-image proxy: cannot open %s: %s", source_id, e)
77+
await ws.send_str(json.dumps({'type': 'error', 'message': str(e)}))
78+
await ws.close()
79+
return ws
80+
81+
try:
82+
while not ws.closed:
83+
jpeg: Optional[bytes] = await source.read()
84+
if jpeg is not None:
85+
b64 = base64.b64encode(jpeg).decode('ascii')
86+
await ws.send_str(json.dumps({
87+
'type': 'frame',
88+
'format': 'jpeg/base64',
89+
'data': b64,
90+
}))
91+
await asyncio.sleep(source.poll_interval)
92+
except (ConnectionResetError, ConnectionError):
93+
logger.info("Live-image proxy: client disconnected from %s.", source_id)
94+
finally:
95+
await source.close()
96+
logger.info("Live-image proxy: %s released.", source_id)
97+
98+
return ws
99+
100+
101+
# ---------------------------------------------------------------------------
102+
# Server entry-point
103+
# ---------------------------------------------------------------------------
104+
105+
def run(
106+
host: str = '0.0.0.0',
107+
port: int = 8765,
108+
allsky_overrides: Optional[list[AllskyOverride]] = None,
109+
):
110+
"""Build and run the aiohttp application (blocking)."""
111+
from aiohttp import web
112+
113+
app = web.Application()
114+
app['registry'] = Registry(allsky_overrides=allsky_overrides)
115+
116+
app.router.add_get('/health', handle_health)
117+
app.router.add_get('/detect', handle_detect)
118+
app.router.add_get('/stream/{id}', handle_stream)
119+
120+
logger.info("Live-image proxy starting on %s:%d", host, port)
121+
web.run_app(app, host=host, port=port, print=lambda msg: logger.info(msg))

arcsecond/imagesources/registry.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Source resolution and discovery for the proxy.
3+
4+
Webcams are auto-detected by probing OpenCV device indices.
5+
All-sky sources are either auto-discovered at well-known paths or registered
6+
explicitly by the user (``arcsecond allsky add``-style overrides passed at
7+
``run()`` time).
8+
"""
9+
10+
import logging
11+
from dataclasses import dataclass
12+
from typing import Optional
13+
14+
from .sources.base import FrameSource, SourceInfo
15+
from .sources.filewatch import FileWatchSource, detect_allsky
16+
from .sources.opencv import OpenCVWebcamSource, detect_webcams
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@dataclass
22+
class AllskyOverride:
23+
id: str # bare id, will be prefixed with "allsky:"
24+
path: str # file path or glob
25+
label: Optional[str] = None
26+
27+
28+
class Registry:
29+
"""Discover and instantiate sources on demand."""
30+
31+
def __init__(self, allsky_overrides: Optional[list[AllskyOverride]] = None):
32+
self.allsky_overrides = allsky_overrides or []
33+
34+
def detect(self) -> list[SourceInfo]:
35+
infos: list[SourceInfo] = []
36+
try:
37+
infos.extend(detect_webcams())
38+
except Exception as e:
39+
logger.warning("Webcam detection failed: %s", e)
40+
41+
if self.allsky_overrides:
42+
for o in self.allsky_overrides:
43+
src = FileWatchSource(f"allsky:{o.id}", o.path, o.label)
44+
infos.append(src.info())
45+
else:
46+
infos.extend(detect_allsky())
47+
48+
return infos
49+
50+
def open_source(self, source_id: str) -> FrameSource:
51+
# Backward-compat: bare numeric ids → webcam.
52+
if source_id.isdigit():
53+
return OpenCVWebcamSource(int(source_id))
54+
55+
kind, _, name = source_id.partition(":")
56+
if not name:
57+
raise KeyError(f"Unknown source id: {source_id!r}")
58+
59+
if kind == "webcam":
60+
return OpenCVWebcamSource(int(name))
61+
62+
if kind == "allsky":
63+
for o in self.allsky_overrides:
64+
if o.id == name:
65+
return FileWatchSource(source_id, o.path, o.label)
66+
for info in detect_allsky():
67+
if info.id == source_id:
68+
return FileWatchSource(source_id, info.extra["path"], info.label)
69+
raise KeyError(f"No all-sky source registered as {source_id!r}")
70+
71+
raise KeyError(f"Unknown source kind: {kind!r}")

arcsecond/imagesources/sources/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)