Decouple hardware components into standalone services (#1413)#1413
Decouple hardware components into standalone services (#1413)#1413wtgee wants to merge 28 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors PANOPTES POCS hardware control to support running Mount, Camera, and Scheduler as standalone FastAPI services, with the main process talking to them via new HTTP-based proxy classes.
Changes:
- Added FastAPI microservice wrappers for mount, cameras, and scheduler under
panoptes.pocs.services. - Introduced remote proxy implementations for mount/camera/scheduler that translate method calls into HTTP requests.
- Updated factories and supervisord configuration to enable endpoint-based remote connectivity, and documented the feature in the changelog.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
src/panoptes/pocs/services/scheduler_api.py |
New FastAPI scheduler service endpoints. |
src/panoptes/pocs/services/mount_api.py |
New FastAPI mount service endpoints (incl. slew/park ops). |
src/panoptes/pocs/services/camera_api.py |
New FastAPI camera service endpoints (status, exposure, cooling). |
src/panoptes/pocs/scheduler/remote.py |
New scheduler proxy that delegates to a remote scheduler service. |
src/panoptes/pocs/scheduler/__init__.py |
Factory updated to pass endpoint_url when configured. |
src/panoptes/pocs/mount/remote.py |
New mount proxy that delegates to a remote mount service. |
src/panoptes/pocs/mount/__init__.py |
Factory updated to pass endpoint_url when configured. |
src/panoptes/pocs/camera/remote.py |
New camera proxy that delegates to a remote camera service. |
src/panoptes/pocs/camera/camera.py |
Formatting-only adjustments in threading kwargs and arithmetic. |
conftest.py |
Whitespace-only change. |
conf_files/pocs-supervisord.conf |
Added supervisord programs for the new hardware services (ports 8001–8003). |
CHANGELOG.md |
Documented the new decoupled hardware services and remote proxies. |
Comments suppressed due to low confidence (1)
src/panoptes/pocs/scheduler/init.py:81
- This adds support for
scheduler.endpoint_urlbut the existing scheduler factory tests don't exercise the remote scheduler path. Consider adding a test that setsscheduler.typetopanoptes.pocs.scheduler.remotewith anendpoint_urland verifies the scheduler is constructed correctly (and that the URL is stored/used), using httpx mocking for the remote calls.
# Pass endpoint_url if this is a remote scheduler
if "endpoint_url" in scheduler_config:
kwargs["endpoint_url"] = scheduler_config["endpoint_url"]
# Create the Scheduler instance
pocs_scheduler = module.Scheduler(
observer, fields_file=str(fields_path), constraints=constraints, *args, **kwargs
)
| from fastapi import BackgroundTasks, FastAPI, HTTPException | ||
| from pydantic import BaseModel | ||
|
|
||
| from panoptes.pocs.mount import create_mount_from_config | ||
|
|
||
| app = FastAPI() | ||
|
|
||
| # Global mount instance | ||
| mount = None | ||
|
|
||
|
|
||
| @app.on_event("startup") | ||
| async def startup_event(): | ||
| global mount | ||
| try: | ||
| mount = create_mount_from_config() | ||
| except Exception as e: | ||
| print(f"Failed to create mount: {e}") | ||
| mount = None | ||
|
|
||
|
|
||
| @app.get("/status") | ||
| def status(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return mount.status() | ||
|
|
||
|
|
||
| @app.post("/connect") | ||
| def connect(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return {"result": mount.connect()} | ||
|
|
||
|
|
||
| @app.post("/initialize") | ||
| def initialize(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return {"result": mount.initialize()} | ||
|
|
||
|
|
||
| @app.post("/disconnect") | ||
| def disconnect(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return {"result": mount.disconnect()} | ||
|
|
||
|
|
||
| @app.get("/is_connected") | ||
| def is_connected(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return {"result": mount.is_connected} | ||
|
|
||
|
|
||
| @app.get("/is_initialized") | ||
| def is_initialized(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return {"result": mount.is_initialized} | ||
|
|
||
|
|
||
| @app.get("/is_parked") | ||
| def is_parked(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") |
There was a problem hiding this comment.
These services use module-level globals (mount) and @app.on_event("startup") plus print(...) for failures. Elsewhere in the repo (e.g. src/panoptes/pocs/utils/service/weather.py) FastAPI apps use a lifespan context manager and store instances on app.state with proper logging. Aligning with that pattern avoids issues with testability and multi-worker deployments and ensures errors go through the standard logger.
| from fastapi import BackgroundTasks, FastAPI, HTTPException | |
| from pydantic import BaseModel | |
| from panoptes.pocs.mount import create_mount_from_config | |
| app = FastAPI() | |
| # Global mount instance | |
| mount = None | |
| @app.on_event("startup") | |
| async def startup_event(): | |
| global mount | |
| try: | |
| mount = create_mount_from_config() | |
| except Exception as e: | |
| print(f"Failed to create mount: {e}") | |
| mount = None | |
| @app.get("/status") | |
| def status(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return mount.status() | |
| @app.post("/connect") | |
| def connect(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return {"result": mount.connect()} | |
| @app.post("/initialize") | |
| def initialize(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return {"result": mount.initialize()} | |
| @app.post("/disconnect") | |
| def disconnect(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return {"result": mount.disconnect()} | |
| @app.get("/is_connected") | |
| def is_connected(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return {"result": mount.is_connected} | |
| @app.get("/is_initialized") | |
| def is_initialized(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return {"result": mount.is_initialized} | |
| @app.get("/is_parked") | |
| def is_parked(): | |
| if not mount: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| from contextlib import asynccontextmanager | |
| import logging | |
| from fastapi import BackgroundTasks, FastAPI, HTTPException, Request | |
| from pydantic import BaseModel | |
| from panoptes.pocs.mount import create_mount_from_config | |
| logger = logging.getLogger(__name__) | |
| @asynccontextmanager | |
| async def lifespan(app: FastAPI): | |
| """FastAPI lifespan context manager that manages the mount instance.""" | |
| try: | |
| app.state.mount = create_mount_from_config() | |
| except Exception: | |
| logger.exception("Failed to create mount from config") | |
| app.state.mount = None | |
| try: | |
| yield | |
| finally: | |
| mount = getattr(app.state, "mount", None) | |
| if mount is not None: | |
| try: | |
| # Attempt a clean disconnect on shutdown. | |
| mount.disconnect() | |
| except Exception: | |
| logger.exception("Error while disconnecting mount during shutdown") | |
| app = FastAPI(lifespan=lifespan) | |
| def get_mount(request: Request): | |
| """Retrieve the mount instance from the FastAPI application state.""" | |
| mount = getattr(request.app.state, "mount", None) | |
| if mount is None: | |
| raise HTTPException(status_code=503, detail="Mount not initialized") | |
| return mount | |
| @app.get("/status") | |
| def status(request: Request): | |
| mount = get_mount(request) | |
| return mount.status() | |
| @app.post("/connect") | |
| def connect(request: Request): | |
| mount = get_mount(request) | |
| return {"result": mount.connect()} | |
| @app.post("/initialize") | |
| def initialize(request: Request): | |
| mount = get_mount(request) | |
| return {"result": mount.initialize()} | |
| @app.post("/disconnect") | |
| def disconnect(request: Request): | |
| mount = get_mount(request) | |
| return {"result": mount.disconnect()} | |
| @app.get("/is_connected") | |
| def is_connected(request: Request): | |
| mount = get_mount(request) | |
| return {"result": mount.is_connected} | |
| @app.get("/is_initialized") | |
| def is_initialized(request: Request): | |
| mount = get_mount(request) | |
| return {"result": mount.is_initialized} | |
| @app.get("/is_parked") | |
| def is_parked(request: Request): | |
| mount = get_mount(request) |
| @app.get("/status") | ||
| def status(): | ||
| if not scheduler: | ||
| raise HTTPException(status_code=503, detail="Scheduler not initialized") | ||
| return {"result": scheduler.status} | ||
|
|
There was a problem hiding this comment.
/status returns scheduler.status, but BaseScheduler.status contains live Python objects (constraints instances and a current_observation object) that are not JSON-serializable, so this endpoint will 500 under FastAPI's JSON encoding. Consider returning a JSON-safe schema (e.g., constraint class names/params and current observation name) or a dedicated pydantic response model.
| app = FastAPI() | ||
|
|
||
| # Global scheduler instance | ||
| scheduler = None | ||
|
|
||
|
|
||
| @app.on_event("startup") | ||
| async def startup_event(): | ||
| global scheduler | ||
| try: | ||
| scheduler = create_scheduler_from_config() | ||
| except Exception as e: | ||
| print(f"Failed to create scheduler: {e}") | ||
| scheduler = None | ||
|
|
There was a problem hiding this comment.
This module uses a module-level global (scheduler) and @app.on_event("startup") with print(...) on failure. The existing FastAPI services in this repo (e.g. src/panoptes/pocs/utils/service/power.py, weather.py) use a lifespan context manager and store resources on app.state with standard logging. Aligning with that pattern will reduce concurrency surprises and make initialization/shutdown behavior consistent.
| @app.get("/{name}/status") | ||
| def status(name: str): | ||
| cam = get_camera(name) | ||
| return { | ||
| "uid": cam.uid, | ||
| "is_connected": cam.is_connected, | ||
| "is_exposing": cam.is_exposing, | ||
| "is_ready": cam.is_ready, | ||
| "temperature": cam.temperature, | ||
| "target_temperature": cam.target_temperature, | ||
| "cooling_enabled": cam.cooling_enabled, | ||
| "cooling_power": cam.cooling_power, | ||
| } |
There was a problem hiding this comment.
Several fields returned by this endpoint (e.g. temperature, target_temperature, cooling_power) are often astropy.units.Quantity objects (e.g. simulator/FLI/ZWO cameras) and are not JSON-serializable. This will break the API for common camera implementations. Convert quantities to plain values (and optionally include units) before returning.
| @app.get("/{name}/properties") | ||
| def properties(name: str): | ||
| cam = get_camera(name) | ||
| return { | ||
| "uid": cam.uid, | ||
| "is_connected": cam.is_connected, | ||
| "readout_time": cam.readout_time.value if hasattr(cam.readout_time, "value") else cam.readout_time, | ||
| "file_extension": cam.file_extension, | ||
| "egain": cam.egain, | ||
| "bit_depth": cam.bit_depth, | ||
| "temperature": cam.temperature, | ||
| "target_temperature": cam.target_temperature, | ||
| "temperature_tolerance": cam.temperature_tolerance, | ||
| "cooling_enabled": cam.cooling_enabled, | ||
| "cooling_power": cam.cooling_power, | ||
| "filter_type": cam.filter_type, | ||
| "is_cooled_camera": cam.is_cooled_camera, | ||
| "is_temperature_stable": cam.is_temperature_stable, | ||
| "is_exposing": cam.is_exposing, | ||
| "is_ready": cam.is_ready, | ||
| "can_take_internal_darks": cam.can_take_internal_darks, | ||
| } |
There was a problem hiding this comment.
Similar to /{name}/status, this endpoint returns values that are commonly astropy.units.Quantity (e.g. temperature, target_temperature, temperature_tolerance, cooling_power, egain, bit_depth) without normalization, which will fail JSON encoding. It would be safer to normalize all Quantity-like values consistently (as you already do for readout_time).
| super().clear_available_observations() | ||
| self._post("clear_available_observations") | ||
|
|
||
| def reset_observed_list(self): | ||
| super().reset_observed_list() | ||
| self._post("reset_observed_list") | ||
|
|
||
| def add_observation(self, observation_config: dict, **kwargs): | ||
| super().add_observation(observation_config, **kwargs) | ||
| self._post("add_observation", json={"observation_config": observation_config}) | ||
|
|
||
| def remove_observation(self, field_name): | ||
| super().remove_observation(field_name) | ||
| self._post("remove_observation", params={"field_name": field_name}) |
There was a problem hiding this comment.
The local scheduler state is mutated via super().add_observation(...) before the remote call. If the remote POST fails (your _post returns None on error), the local and remote scheduler states will diverge, and subsequent remote scheduling decisions won't match local bookkeeping. Consider making the remote call first and only updating local state on success, or raising on remote failure so callers can handle it.
| super().clear_available_observations() | |
| self._post("clear_available_observations") | |
| def reset_observed_list(self): | |
| super().reset_observed_list() | |
| self._post("reset_observed_list") | |
| def add_observation(self, observation_config: dict, **kwargs): | |
| super().add_observation(observation_config, **kwargs) | |
| self._post("add_observation", json={"observation_config": observation_config}) | |
| def remove_observation(self, field_name): | |
| super().remove_observation(field_name) | |
| self._post("remove_observation", params={"field_name": field_name}) | |
| """Clear available observations on both remote and local schedulers. | |
| The remote scheduler is updated first; the local state is only cleared if the | |
| remote call succeeds, to avoid divergence between local and remote state. | |
| """ | |
| result = self._post("clear_available_observations") | |
| if not result: | |
| self.logger.error("Failed to clear available observations on remote scheduler") | |
| raise RuntimeError("Remote scheduler clear_available_observations failed") | |
| super().clear_available_observations() | |
| def reset_observed_list(self): | |
| """Reset the observed list on both remote and local schedulers. | |
| The remote scheduler is updated first; the local state is only reset if the | |
| remote call succeeds, to avoid divergence between local and remote state. | |
| """ | |
| result = self._post("reset_observed_list") | |
| if not result: | |
| self.logger.error("Failed to reset observed list on remote scheduler") | |
| raise RuntimeError("Remote scheduler reset_observed_list failed") | |
| super().reset_observed_list() | |
| def add_observation(self, observation_config: dict, **kwargs): | |
| """Add an observation to both remote and local schedulers. | |
| The observation is first sent to the remote scheduler; the local scheduler | |
| is only updated if the remote call succeeds, to keep their states in sync. | |
| """ | |
| result = self._post("add_observation", json={"observation_config": observation_config}) | |
| if not result: | |
| self.logger.error("Failed to add observation on remote scheduler") | |
| raise RuntimeError("Remote scheduler add_observation failed") | |
| super().add_observation(observation_config, **kwargs) | |
| def remove_observation(self, field_name): | |
| """Remove an observation from both remote and local schedulers. | |
| The observation is first removed from the remote scheduler; the local | |
| scheduler is only updated if the remote call succeeds, to keep their | |
| states in sync. | |
| """ | |
| result = self._post("remove_observation", params={"field_name": field_name}) | |
| if not result: | |
| self.logger.error("Failed to remove observation on remote scheduler") | |
| raise RuntimeError("Remote scheduler remove_observation failed") | |
| super().remove_observation(field_name) |
| def _get(self, path): | ||
| try: | ||
| response = self.client.get(f"{self.endpoint_url}/{self.name}/{path}") | ||
| response.raise_for_status() | ||
| if "result" in response.json(): |
There was a problem hiding this comment.
The request path is built using self.name (.../{self.name}/{path}), but camera names can contain spaces (the default here is "Remote Camera"). That will lead to invalid/unmatched routes (e.g. /Remote Camera/status) unless callers URL-encode exactly the same way as the server expects. Prefer using a URL-safe identifier (e.g. uid) and/or URL-encode the path segment consistently on both client and server.
| import httpx | ||
| from astropy.coordinates import SkyCoord | ||
|
|
There was a problem hiding this comment.
httpx is imported and used directly here. It doesn't appear to be declared as a direct dependency in pyproject.toml, so installations that don't include it transitively may break at import time. Add httpx as an explicit project dependency (or move the import behind an optional extra with a clear error).
| def status(): | ||
| if not mount: | ||
| raise HTTPException(status_code=503, detail="Mount not initialized") | ||
| return mount.status() |
There was a problem hiding this comment.
AbstractMount.status is a @property that returns a dict. Calling mount.status() will raise TypeError: 'dict' object is not callable for standard mounts. Return mount.status (and optionally wrap it in a {"result": ...} envelope for consistency with other endpoints).
| return mount.status() | |
| return {"result": mount.status} |
| # If it's a remote mount, pass the endpoint_url | ||
| if "endpoint_url" in mount_info: | ||
| kwargs["endpoint_url"] = mount_info["endpoint_url"] | ||
|
|
||
| # Make the mount include site information | ||
| mount = module.Mount(location=earth_location, *args, **kwargs) | ||
|
|
There was a problem hiding this comment.
This change introduces a new config-driven code path (endpoint_url) that alters mount construction, but there are existing tests for create_mount_from_config that don't cover the remote case. Please add a focused test that sets mount.endpoint_url in config and asserts the factory passes it through to the driver (e.g. using the new panoptes.pocs.mount.remote driver with mocked HTTP).
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1413 +/- ##
==========================================
+ Coverage 66.17% 66.28% +0.10%
==========================================
Files 106 110 +4
Lines 9639 10019 +380
Branches 847 872 +25
==========================================
+ Hits 6379 6641 +262
- Misses 3114 3225 +111
- Partials 146 153 +7 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
5803287 to
d88a3f2
Compare
f0a9f94 to
273b1d7
Compare
| def __init__(self, location, endpoint_url="http://localhost:8001", *args, **kwargs): | ||
| # We don't necessarily need the commands dict here since the real mount | ||
| # on the remote end has it, but AbstractMount requires commands in its | ||
| # _setup_commands. Let's let the parent initialize. | ||
| # We might need to mock commands or just pass an empty dict. | ||
| kwargs.setdefault("commands", {}) | ||
| super().__init__(location, *args, **kwargs) | ||
| self.endpoint_url = endpoint_url | ||
| self.client = httpx.Client(timeout=300.0) # Long timeout for blocking slews | ||
| self.logger.info(f"Initialized RemoteMount pointing to {self.endpoint_url}") | ||
|
|
||
| def _get(self, path): | ||
| try: | ||
| response = self.client.get(f"{self.endpoint_url}/{path}") | ||
| response.raise_for_status() | ||
| data = from_json(response.text) | ||
| if isinstance(data, dict) and "result" in data: | ||
| return data["result"] |
There was a problem hiding this comment.
httpx.Client is created and stored on the instance but never closed. Since these proxy objects are long-lived, this can leak connections/sockets over time and can produce ResourceWarnings in tests. Add a close() method (and/or __enter__/__exit__) that calls self.client.close(), and ensure callers/service shutdown paths invoke it.
| super().__init__(name=name, model=model, port=port, primary=primary, *args, **kwargs) | ||
| self.endpoint_url = endpoint_url | ||
| self.client = httpx.Client(timeout=300.0) | ||
| # URL-safe camera name for routing | ||
| self.safe_name = quote(self.name) | ||
| self.logger.info(f"Initialized RemoteCamera {name} pointing to {self.endpoint_url}") | ||
|
|
There was a problem hiding this comment.
httpx.Client is created but never closed. Add a close()/context-manager and ensure it’s called when the camera is released/shutdown to prevent connection leakage.
| from contextlib import asynccontextmanager | ||
|
|
||
| from astropy.coordinates import SkyCoord | ||
| from fastapi import BackgroundTasks, FastAPI, HTTPException, Request | ||
| from pydantic import BaseModel | ||
|
|
There was a problem hiding this comment.
panoptes.pocs.services.* won’t be packaged/installed because src/panoptes/pocs/services/ has no __init__.py and pyproject.toml uses setuptools.packages.find with namespaces = false. This will make uvicorn panoptes.pocs.services.mount_api:app fail at runtime. Add src/panoptes/pocs/services/__init__.py (or switch package discovery to include namespace packages).
| @app.post("/disconnect") | ||
| def disconnect(request: Request): | ||
| mount = get_mount(request) | ||
| return {"result": mount.disconnect()} |
There was a problem hiding this comment.
mount.disconnect() returns None for some mount implementations (e.g., the base AbstractMount.disconnect), so this endpoint can respond with {"result": null}. For consistency with the other endpoints (and to make proxy behavior predictable), return an explicit boolean (e.g., True on successful call, or False/HTTP error on failure).
| return {"result": mount.disconnect()} | |
| disconnect_result = mount.disconnect() | |
| if disconnect_result is None: | |
| # Some mount implementations return None; infer success from connection state. | |
| result = not mount.is_connected | |
| else: | |
| result = bool(disconnect_result) | |
| return {"result": result} |
| logger = get_logger() | ||
|
|
||
|
|
||
| import asyncio | ||
|
|
||
| @asynccontextmanager | ||
| async def lifespan(app: FastAPI): |
There was a problem hiding this comment.
import asyncio appears after logger = ..., which will trigger Ruff E402/I001. Move asyncio up with the other standard-library imports at the top of the file.
| logger = get_logger() | ||
|
|
||
|
|
||
| import asyncio | ||
|
|
||
| @asynccontextmanager |
There was a problem hiding this comment.
import asyncio is placed after non-import statements (logger = ...), which violates Ruff’s import rules (E402/I001) and will fail lint. Move all standard-library imports (including asyncio) to the top of the module in the correct section order.
| logger = get_logger() | ||
|
|
||
|
|
||
| import asyncio | ||
|
|
||
| @asynccontextmanager | ||
| async def lifespan(app: FastAPI): |
There was a problem hiding this comment.
import asyncio is not at the top of the module (comes after logger = ...), which will fail Ruff E402/I001. Move it into the standard-library import section at the top.
| from astroplan import Observer | ||
| import astropy.units as u |
There was a problem hiding this comment.
Import order doesn’t follow the project’s Ruff/isort configuration (standard library → third-party → first-party). In particular import astropy.units as u is out of order relative to the other third-party imports, which will fail lint (I001). Run ruff format / ruff check --fix or reorder the imports manually.
| from astroplan import Observer | |
| import astropy.units as u | |
| import astropy.units as u | |
| from astroplan import Observer |
| device_config.update(cfg) | ||
|
|
||
| cam_name = device_config.setdefault("name", f"Cam{cam_num:02d}") | ||
|
|
There was a problem hiding this comment.
There is trailing whitespace on the blank line after cam_name = ... (Ruff W291). This will fail lint with the current Ruff configuration; remove the extra spaces.
| [program:pocs-mount-service] | ||
| priority=10 | ||
| user=panoptes | ||
| directory=/home/panoptes | ||
| command=uvicorn --host 0.0.0.0 --port 8001 panoptes.pocs.services.mount_api:app | ||
| redirect_stderr=true | ||
| stdout_logfile=/home/panoptes/logs/mount-service.log | ||
| autostart=true | ||
| startsecs=5 | ||
| stopasgroup=true | ||
| killasgroup=true | ||
|
|
||
| [program:pocs-camera-service] | ||
| priority=11 | ||
| user=panoptes | ||
| directory=/home/panoptes | ||
| command=uvicorn --host 0.0.0.0 --port 8002 panoptes.pocs.services.camera_api:app | ||
| redirect_stderr=true | ||
| stdout_logfile=/home/panoptes/logs/camera-service.log | ||
| autostart=true | ||
| startsecs=5 | ||
| stopasgroup=true | ||
| killasgroup=true | ||
|
|
||
| [program:pocs-scheduler-service] | ||
| priority=12 | ||
| user=panoptes | ||
| directory=/home/panoptes | ||
| command=uvicorn --host 0.0.0.0 --port 8003 panoptes.pocs.services.scheduler_api:app | ||
| redirect_stderr=true |
There was a problem hiding this comment.
The new hardware service processes are configured to bind uvicorn to 0.0.0.0 on ports 8001–8003, but the APIs expose privileged hardware controls (slew/park/take_exposure) without any authentication. Unless these hosts are always firewalled, this is a security risk. Consider binding to 127.0.0.1 by default and/or adding an auth mechanism (e.g., shared token) and documenting required network restrictions.
# Conflicts: # .gitignore # uv.lock
Build the docs from `develop` for now.
Can now type `pocs version` to show which versions of the software are running.
* Remove docker
Description
This PR implements an architectural refactor to decouple the tightly-coupled hardware components (
Mount,Cameras,Scheduler) from the centralObservatory/POCSmonolithic process. Each component can now run as a standalone microservice using FastAPI, communicating with the main POCS instance via the Proxy Pattern.Key Changes
src/panoptes/pocs/services/containing FastAPI wrappers for:mount_api.py: Wraps any standard POCS mount (simulator, iOptron, Bisque).camera_api.py: Wraps a collection of cameras (ZWO, Canon, etc.) using existing factory methods.scheduler_api.py: Wraps the configured scheduler (Dispatch/Base).RemoteMount,RemoteCamera, andRemoteSchedulerinheriting from the base abstract classes. These classes translate standard method calls into HTTP requests to the respective services.conf_files/pocs-supervisord.confto include sections for the new hardware services.create_mount_from_config,create_cameras_from_config, andcreate_scheduler_from_configto support anendpoint_urlparameter, enabling remote connectivity when configured.Motivation
This decoupling allows for:
Compatibility
endpoint_urlis provided in the configuration for a specific component.camera_apiwrapper.Tests
test_pocs.pysuite.ruff.Closes #1413