-
Notifications
You must be signed in to change notification settings - Fork 51
Decouple hardware components into standalone services (#1413) #1413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 13 commits
d88a3f2
6a183d7
1ae170f
273b1d7
79721b9
648673a
f7ad908
fe2829e
5e00888
891e713
201584f
45701fc
4fbca26
db7fa97
4b293f2
a2f694d
21f089d
afd6bb3
d9d08d9
87a8226
4711942
4e17711
3ec7e7e
1d92baf
d17a898
b776c88
ebb30de
c8029a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,3 +57,6 @@ temp* | |
| src/panoptes/pocs/_version.py | ||
| json_store | ||
| .junie | ||
|
|
||
| # AI Agent Plans | ||
| plans/ | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -63,7 +63,7 @@ def list_connected_gphoto2_cameras(endpoint: AnyHttpUrl | None = None): | |||
|
|
||||
|
|
||||
| def create_cameras_from_config( | ||||
| config=None, cameras=None, auto_primary=True, recreate_existing=False, *args, **kwargs | ||||
| config=None, cameras=None, auto_primary=True, recreate_existing=False, *args, client_mode=True, **kwargs | ||||
| ): | ||||
| """Create camera object(s) based on the config. | ||||
|
|
||||
|
|
@@ -81,6 +81,9 @@ def create_cameras_from_config( | |||
| existing camera with the same `uid` is already assigned. Should currently | ||||
| only affect cameras that use the `sdk` (i.g. not DSLRs). Default False | ||||
| raises an exception if camera is already assigned. | ||||
| client_mode: If True (default), intercept configs with an `endpoint_url` and | ||||
| return a proxy object (`RemoteCamera`) instead of the physical driver. | ||||
| Set to False in the hardware service API to instantiate the physical device. | ||||
| *args (list): Passed to `get_config`. | ||||
| **kwargs (dict): Can pass a `cameras` object that overrides the info in | ||||
| the configuration file. Can also pass `auto_detect`(bool) to try and | ||||
|
|
@@ -134,6 +137,11 @@ def create_cameras_from_config( | |||
| device_config.update(cfg) | ||||
|
|
||||
| cam_name = device_config.setdefault("name", f"Cam{cam_num:02d}") | ||||
|
|
||||
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import threading | ||
| from typing import Any | ||
| from urllib.parse import quote | ||
|
|
||
| import httpx | ||
| from panoptes.utils import error | ||
| from panoptes.utils.serializers import from_json | ||
|
|
||
| from panoptes.pocs.camera.camera import AbstractCamera | ||
|
|
||
|
|
||
| class Camera(AbstractCamera): | ||
| """A proxy Camera class that delegates commands to a remote FastAPI service.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| name="Remote Camera", | ||
| model="remote", | ||
| port=None, | ||
| primary=False, | ||
| endpoint_url="http://localhost:8002", | ||
| *args, | ||
| **kwargs, | ||
| ): | ||
| 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}") | ||
|
|
||
|
Comment on lines
+25
to
+31
|
||
| def _get(self, path): | ||
| try: | ||
| response = self.client.get(f"{self.endpoint_url}/{self.safe_name}/{path}") | ||
| response.raise_for_status() | ||
| data = from_json(response.text) | ||
| if isinstance(data, dict) and "result" in data: | ||
| return data["result"] | ||
| return data | ||
| except Exception as e: | ||
| self.logger.error(f"Remote camera GET {path} failed: {e}") | ||
| return None | ||
|
|
||
| def _post(self, path, json=None, params=None): | ||
| try: | ||
| response = self.client.post( | ||
| f"{self.endpoint_url}/{self.safe_name}/{path}", json=json, params=params | ||
| ) | ||
| response.raise_for_status() | ||
| data = from_json(response.text) | ||
| if isinstance(data, dict) and "result" in data: | ||
| return data["result"] | ||
| return data.get("result", True) if isinstance(data, dict) else data | ||
| except Exception as e: | ||
| self.logger.error(f"Remote camera POST {path} failed: {e}") | ||
| return None | ||
|
|
||
| def connect(self): | ||
| self._connected = self._post("connect") | ||
| return self._connected | ||
|
|
||
| @property | ||
| def is_connected(self): | ||
| status = self._get("status") | ||
| return status.get("is_connected", False) if status else False | ||
|
|
||
| @property | ||
| def temperature(self): | ||
| status = self._get("status") | ||
| return status.get("temperature") if status else None | ||
|
|
||
| @property | ||
| def target_temperature(self): | ||
| status = self._get("status") | ||
| return status.get("target_temperature") if status else None | ||
|
|
||
| @target_temperature.setter | ||
| def target_temperature(self, target): | ||
| self._post("set_target_temperature", params={"target": target}) | ||
|
|
||
| @property | ||
| def cooling_enabled(self): | ||
| status = self._get("status") | ||
| return status.get("cooling_enabled", False) if status else False | ||
|
|
||
| @cooling_enabled.setter | ||
| def cooling_enabled(self, enabled): | ||
| self._post("set_cooling_enabled", params={"enabled": enabled}) | ||
|
|
||
| @property | ||
| def cooling_power(self): | ||
| status = self._get("status") | ||
| return status.get("cooling_power", 0.0) if status else 0.0 | ||
|
|
||
| @property | ||
| def is_exposing(self): | ||
| status = self._get("status") | ||
| return status.get("is_exposing", False) if status else False | ||
|
|
||
| @property | ||
| def is_ready(self): | ||
| status = self._get("status") | ||
| return status.get("is_ready", False) if status else False | ||
|
|
||
| def take_exposure( | ||
| self, | ||
| seconds=1.0, | ||
| filename=None, | ||
| metadata=None, | ||
| dark=False, | ||
| blocking=False, | ||
| timeout=10.0, | ||
| *args, | ||
| **kwargs, | ||
| ): | ||
| """Proxy exposure logic to remote node.""" | ||
| if not filename: | ||
| raise error.PanError("Must pass filename for take_exposure") | ||
|
|
||
| seconds_val = seconds.value if hasattr(seconds, "value") else seconds | ||
| timeout_val = timeout.value if hasattr(timeout, "value") else timeout | ||
|
|
||
| params = { | ||
| "seconds": seconds_val, | ||
| "filename": str(filename), | ||
| "metadata": metadata or {}, | ||
| "dark": dark, | ||
| "blocking": blocking, | ||
| "timeout": timeout_val, | ||
| } | ||
|
|
||
| self._post("take_exposure", json=params) | ||
|
|
||
| # The remote handles its own threading. We can just return a dummy thread | ||
| # if blocking is false, since AbstractCamera expects a thread. | ||
| dummy_thread = threading.Thread(target=lambda: None) | ||
| dummy_thread.start() | ||
| return dummy_thread | ||
|
Comment on lines
+132
to
+138
|
||
|
|
||
| def process_exposure(self, metadata, **kwargs): | ||
| """Proxy processing logic to remote node.""" | ||
| self._post("process_exposure", json={"metadata": metadata}) | ||
|
|
||
| def _set_target_temperature(self, target): | ||
| pass | ||
|
|
||
| def _set_cooling_enabled(self, enable): | ||
| pass | ||
|
|
||
| def _start_exposure(self, seconds=None, filename=None, dark=False, header=None, *args, **kwargs): | ||
| pass | ||
|
|
||
| def _readout(self, *args, **kwargs): | ||
| pass | ||
|
|
||
| def _process_fits(self, file_path, metadata): | ||
| return file_path | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new hardware service processes are configured to bind
uvicornto0.0.0.0on 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 to127.0.0.1by default and/or adding an auth mechanism (e.g., shared token) and documenting required network restrictions.