citrascope 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,42 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ from citrascope.logging import CITRASCOPE_LOGGER
4
+
5
+ UNDEFINED_STRING = "undefined"
6
+
7
+
8
+ class CitraScopeSettings(BaseSettings):
9
+ model_config = SettingsConfigDict(
10
+ env_prefix="CITRASCOPE_",
11
+ env_nested_delimiter="__",
12
+ )
13
+
14
+ # Default to production API
15
+ host: str = "api.citra.space"
16
+ port: int = 443
17
+
18
+ personal_access_token: str = UNDEFINED_STRING
19
+ use_ssl: bool = True
20
+ telescope_id: str = UNDEFINED_STRING
21
+
22
+ indi_server_url: str = "localhost"
23
+ indi_server_port: int = 7624
24
+ indi_telescope_name: str = UNDEFINED_STRING
25
+ indi_camera_name: str = UNDEFINED_STRING
26
+
27
+ log_level: str = "INFO"
28
+
29
+ def __init__(self, dev: bool = False, log_level: str = "INFO", **kwargs):
30
+ super().__init__(**kwargs)
31
+ self.log_level = log_level
32
+ if dev:
33
+ self.host = "dev.api.citra.space"
34
+ CITRASCOPE_LOGGER.info("Using development API endpoint.")
35
+
36
+ def model_post_init(self, __context) -> None:
37
+ if self.personal_access_token == UNDEFINED_STRING:
38
+ CITRASCOPE_LOGGER.warning(f"{self.__class__.__name__} personal_access_token has not been set")
39
+ exit(1)
40
+ if self.telescope_id == UNDEFINED_STRING:
41
+ CITRASCOPE_LOGGER.warning(f"{self.__class__.__name__} telescope_id has not been set")
42
+ exit(1)
@@ -0,0 +1,156 @@
1
+ import heapq
2
+ import os
3
+ import threading
4
+ import time
5
+ from datetime import datetime
6
+
7
+ from dateutil import parser as dtparser
8
+
9
+ from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
10
+ from citrascope.tasks.scope.static_telescope_task import StaticTelescopeTask
11
+ from citrascope.tasks.scope.tracking_telescope_task import TrackingTelescopeTask
12
+ from citrascope.tasks.task import Task
13
+
14
+
15
+ class TaskManager:
16
+ def __init__(
17
+ self,
18
+ api_client,
19
+ telescope_record,
20
+ ground_station_record,
21
+ logger,
22
+ hardware_adapter: AbstractAstroHardwareAdapter,
23
+ ):
24
+ self.api_client = api_client
25
+ self.telescope_record = telescope_record
26
+ self.ground_station_record = ground_station_record
27
+ self.logger = logger
28
+ self.task_heap = [] # min-heap by start time
29
+ self.task_ids = set()
30
+ self.hardware_adapter = hardware_adapter
31
+ self.heap_lock = threading.RLock()
32
+ self._stop_event = threading.Event()
33
+ self.current_task_id = None # Track currently executing task
34
+
35
+ def poll_tasks(self):
36
+ while not self._stop_event.is_set():
37
+ try:
38
+ tasks = self.api_client.get_telescope_tasks(self.telescope_record["id"])
39
+ added = 0
40
+ now = int(time.time())
41
+ with self.heap_lock:
42
+ for task_dict in tasks:
43
+ try:
44
+ task = Task.from_dict(task_dict)
45
+ tid = task.id
46
+ task_start = task.taskStart
47
+ task_stop = task.taskStop
48
+ # Skip if task is in heap or is currently being executed
49
+ if tid and task_start and tid not in self.task_ids and tid != self.current_task_id:
50
+ try:
51
+ start_epoch = int(dtparser.isoparse(task_start).timestamp())
52
+ stop_epoch = int(dtparser.isoparse(task_stop).timestamp()) if task_stop else 0
53
+ except Exception:
54
+ self.logger.error(f"Could not parse taskStart/taskStop for task {tid}")
55
+ continue
56
+ if stop_epoch and stop_epoch < now:
57
+ self.logger.debug(f"Skipping past task {tid} that ended at {task_stop}")
58
+ continue # Skip tasks whose end date has passed
59
+ if task.status not in ["Pending", "Scheduled"]:
60
+ self.logger.debug(f"Skipping task {tid} with status {task.status}")
61
+ continue # Only schedule pending/scheduled tasks
62
+ heapq.heappush(self.task_heap, (start_epoch, stop_epoch, tid, task))
63
+ self.task_ids.add(tid)
64
+ added += 1
65
+ except Exception as e:
66
+ self.logger.error(f"Error adding task {tid} to heap: {e}", exc_info=True)
67
+ if added > 0:
68
+ self.logger.info(self._heap_summary("Added tasks"))
69
+ self.logger.info(self._heap_summary("Polled tasks"))
70
+ except Exception as e:
71
+ self.logger.error(f"Exception in poll_tasks loop: {e}", exc_info=True)
72
+ time.sleep(5) # avoid tight error loop
73
+ self._stop_event.wait(15)
74
+
75
+ def task_runner(self):
76
+ while not self._stop_event.is_set():
77
+ try:
78
+ now = int(time.time())
79
+ completed = 0
80
+ while True:
81
+ with self.heap_lock:
82
+ if not (self.task_heap and self.task_heap[0][0] <= now):
83
+ break
84
+ _, _, tid, task = self.task_heap[0]
85
+ self.logger.info(f"Starting task {tid} at {datetime.now().isoformat()}: {task}")
86
+ self.current_task_id = tid # Mark as in-flight
87
+
88
+ # Observation is now outside the lock!
89
+ try:
90
+ observation_succeeded = self._observe_satellite(task)
91
+ except Exception as e:
92
+ self.logger.error(f"Exception during observation for task {tid}: {e}", exc_info=True)
93
+ observation_succeeded = False
94
+
95
+ with self.heap_lock:
96
+ self.current_task_id = None # Clear after done (success or fail)
97
+ if observation_succeeded:
98
+ self.logger.info(f"Completed observation task {tid} successfully.")
99
+ heapq.heappop(self.task_heap)
100
+ self.task_ids.discard(tid)
101
+ completed += 1
102
+ else:
103
+ self.logger.error(f"Observation task {tid} failed.")
104
+
105
+ if completed > 0:
106
+ self.logger.info(self._heap_summary("Completed tasks"))
107
+ except Exception as e:
108
+ self.logger.error(f"Exception in task_runner loop: {e}", exc_info=True)
109
+ time.sleep(5) # avoid tight error loop
110
+ self._stop_event.wait(1)
111
+
112
+ def _observe_satellite(self, task: Task):
113
+
114
+ # stake a still
115
+ static_task = StaticTelescopeTask(
116
+ self.api_client, self.hardware_adapter, self.logger, self.telescope_record, self.ground_station_record, task
117
+ )
118
+ return static_task.execute()
119
+
120
+ # track the sat for a while with longer exposure
121
+ # tracking_task = TrackingTelescopeTask(
122
+ # self.api_client, self.hardware_adapter, self.logger, self.telescope_record, self.ground_station_record, task
123
+ # )
124
+ # return tracking_task.execute()
125
+
126
+ def _heap_summary(self, event):
127
+ with self.heap_lock:
128
+ summary = f"{event}: {len(self.task_heap)} tasks in queue. "
129
+ next_tasks = []
130
+ if self.task_heap:
131
+ next_tasks = [
132
+ f"{tid} at {datetime.fromtimestamp(start).isoformat()}"
133
+ for start, stop, tid, _ in self.task_heap[:3]
134
+ ]
135
+ if len(self.task_heap) > 3:
136
+ next_tasks.append(f"... ({len(self.task_heap)-3} more)")
137
+ if self.current_task_id is not None:
138
+ # Show the current in-flight task at the front
139
+ summary += f"Current: {self.current_task_id}. "
140
+ if next_tasks and len(next_tasks) > 1 and self.current_task_id != next_tasks[0].split()[0]:
141
+ summary += "Next: " + ", ".join(next_tasks)
142
+ else:
143
+ summary += "No tasks scheduled."
144
+ return summary
145
+
146
+ def start(self):
147
+ self._stop_event.clear()
148
+ self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
149
+ self.runner_thread = threading.Thread(target=self.task_runner, daemon=True)
150
+ self.poll_thread.start()
151
+ self.runner_thread.start()
152
+
153
+ def stop(self):
154
+ self._stop_event.set()
155
+ self.poll_thread.join()
156
+ self.runner_thread.join()
@@ -0,0 +1,205 @@
1
+ import os
2
+ import time
3
+ from abc import ABC, abstractmethod
4
+
5
+ from dateutil import parser as dtparser
6
+ from skyfield.api import Angle, EarthSatellite, load, wgs84
7
+
8
+ from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
9
+
10
+
11
+ class AbstractBaseTelescopeTask(ABC):
12
+ def __init__(
13
+ self,
14
+ api_client,
15
+ hardware_adapter: AbstractAstroHardwareAdapter,
16
+ logger,
17
+ telescope_record,
18
+ ground_station_record,
19
+ task,
20
+ ):
21
+ self.api_client = api_client
22
+ self.hardware_adapter: AbstractAstroHardwareAdapter = hardware_adapter
23
+ self.logger = logger
24
+ self.telescope_record = telescope_record
25
+ self.ground_station_record = ground_station_record
26
+ self.task = task
27
+
28
+ def fetch_satellite(self) -> dict | None:
29
+ satellite_data = self.api_client.get_satellite(self.task.satelliteId)
30
+ if not satellite_data:
31
+ self.logger.error(f"Could not fetch satellite data for {self.task.satelliteId}")
32
+ return None
33
+ elsets = satellite_data.get("elsets", [])
34
+ if not elsets:
35
+ self.logger.error(f"No elsets found for satellite {self.task.satelliteId}")
36
+ return None
37
+ satellite_data["most_recent_elset"] = self._get_most_recent_elset(satellite_data)
38
+ return satellite_data
39
+
40
+ def _get_most_recent_elset(self, satellite_data) -> dict | None:
41
+ if "most_recent_elset" in satellite_data:
42
+ return satellite_data["most_recent_elset"]
43
+
44
+ elsets = satellite_data.get("elsets", [])
45
+ if not elsets:
46
+ self.logger.error(f"No elsets found for satellite {self.task.satelliteId}")
47
+ return None
48
+ most_recent_elset = max(
49
+ elsets,
50
+ key=lambda e: (
51
+ dtparser.isoparse(e["creationEpoch"])
52
+ if e.get("creationEpoch")
53
+ else dtparser.isoparse("1970-01-01T00:00:00Z")
54
+ ),
55
+ )
56
+ return most_recent_elset
57
+
58
+ def upload_image_and_mark_complete(self, filepath):
59
+ upload_result = self.api_client.upload_image(self.task.id, self.telescope_record["id"], filepath)
60
+ if upload_result:
61
+ self.logger.info(f"Successfully uploaded image for task {self.task.id}")
62
+ else:
63
+ self.logger.error(f"Failed to upload image for task {self.task.id}")
64
+ try:
65
+ os.remove(filepath)
66
+ self.logger.info(f"Deleted local image file {filepath} after upload.")
67
+ except Exception as e:
68
+ self.logger.error(f"Failed to delete local image file {filepath}: {e}")
69
+ marked_complete = self.api_client.mark_task_complete(self.task.id)
70
+ if not marked_complete:
71
+ task_check = self.api_client.get_telescope_tasks(self.telescope_record["id"])
72
+ for t in task_check:
73
+ if t["id"] == self.task.id and t.get("status") == "Succeeded":
74
+ self.logger.info(f"Task {self.task.id} is already marked complete.")
75
+ return True
76
+ self.logger.error(f"Failed to mark task {self.task.id} as complete.")
77
+ return False
78
+ self.logger.info(f"Marked task {self.task.id} as complete.")
79
+ return True
80
+
81
+ @abstractmethod
82
+ def execute(self):
83
+ pass
84
+
85
+ def _get_skyfield_ground_station_and_satellite(self, satellite_data):
86
+ """
87
+ Returns (ground_station, satellite, ts) Skyfield objects for the given satellite and elset.
88
+ """
89
+ ts = load.timescale()
90
+ most_recent_elset = self._get_most_recent_elset(satellite_data)
91
+ if most_recent_elset is None:
92
+ raise ValueError("No valid elset available for satellite.")
93
+
94
+ ground_station = wgs84.latlon(
95
+ self.ground_station_record["latitude"],
96
+ self.ground_station_record["longitude"],
97
+ elevation_m=self.ground_station_record["altitude"],
98
+ )
99
+ satellite = EarthSatellite(most_recent_elset["tle"][0], most_recent_elset["tle"][1], satellite_data["name"], ts)
100
+ return ground_station, satellite, ts
101
+
102
+ def get_target_radec_and_rates(self, satellite_data, seconds_from_now: float = 0.0):
103
+ ground_station, satellite, ts = self._get_skyfield_ground_station_and_satellite(satellite_data)
104
+ difference = satellite - ground_station
105
+ days_to_add = seconds_from_now / (24 * 60 * 60) # Skyfield uses days
106
+ topocentric = difference.at(ts.now() + days_to_add)
107
+ target_ra, target_dec, _ = topocentric.radec()
108
+
109
+ # determine ra/dec travel rates
110
+ rates = topocentric.frame_latlon_and_rates(
111
+ ground_station
112
+ ) # TODO can this be collapsed with .radec() call above?
113
+ target_dec_rate = rates[4]
114
+ target_ra_rate = rates[3]
115
+
116
+ return target_ra, target_dec, target_ra_rate, target_dec_rate
117
+
118
+ def predict_slew_time_seconds(self, satellite_data, seconds_from_now: float = 0.0) -> float:
119
+ current_scope_ra, current_scope_dec = self.hardware_adapter.get_telescope_direction()
120
+ current_target_ra, current_target_dec, _, _ = self.get_target_radec_and_rates(satellite_data, seconds_from_now)
121
+
122
+ ra_diff_deg = abs((current_target_ra.degrees - current_scope_ra)) # type: ignore
123
+ dec_diff_deg = abs(current_target_dec.degrees - current_scope_dec) # type: ignore
124
+
125
+ if ra_diff_deg > dec_diff_deg:
126
+ return ra_diff_deg / self.hardware_adapter.scope_slew_rate_degrees_per_second
127
+ else:
128
+ return dec_diff_deg / self.hardware_adapter.scope_slew_rate_degrees_per_second
129
+
130
+ def point_to_lead_position(self, satellite_data):
131
+
132
+ self.logger.debug(f"Using TLE {satellite_data['most_recent_elset']['tle']}")
133
+
134
+ max_angular_distance_deg = 0.3
135
+ attempts = 0
136
+ max_attempts = 10
137
+ while attempts < max_attempts:
138
+ attempts += 1
139
+ # Estimate lead position and slew time
140
+ lead_ra, lead_dec, est_slew_time = self.estimate_lead_position(satellite_data)
141
+ self.logger.info(
142
+ f"Pointing ahead to RA: {lead_ra.hours:.4f}h, DEC: {lead_dec.degrees:.4f}°, estimated slew time: {est_slew_time:.1f}s"
143
+ )
144
+
145
+ # Move the scope
146
+ slew_start_time = time.time()
147
+ self.hardware_adapter.point_telescope(lead_ra.hours, lead_dec.degrees) # type: ignore
148
+ while self.hardware_adapter.telescope_is_moving():
149
+ self.logger.debug(f"Slewing to lead position for {satellite_data['name']}...")
150
+ time.sleep(0.1)
151
+
152
+ slew_duration = time.time() - slew_start_time
153
+ self.logger.info(
154
+ f"Telescope slew done, took {slew_duration:.1f} sec, off by {abs(slew_duration - est_slew_time):.1f} sec."
155
+ )
156
+
157
+ # check our alignment against the starfield
158
+ # is_aligned = self.hardware_adapter.perform_alignment(lead_ra.degrees, lead_dec.degrees) # type: ignore
159
+ # if not is_aligned:
160
+ # continue # try again with the new alignment offsets
161
+
162
+ # Check angular distance to satellite's current position
163
+ current_scope_ra, current_scope_dec = self.hardware_adapter.get_telescope_direction()
164
+ current_satellite_position = self.get_target_radec_and_rates(satellite_data)
165
+ current_angular_distance_deg = self.hardware_adapter.angular_distance(
166
+ current_scope_ra,
167
+ current_scope_dec,
168
+ current_satellite_position[0].degrees, # type: ignore
169
+ current_satellite_position[1].degrees, # type: ignore
170
+ )
171
+ self.logger.info(f"Current angular distance to satellite is {current_angular_distance_deg:.3f} degrees.")
172
+ if current_angular_distance_deg <= max_angular_distance_deg:
173
+ self.logger.info("Telescope is within acceptable range of target.")
174
+ break
175
+
176
+ def estimate_lead_position(
177
+ self,
178
+ satellite_data: dict,
179
+ max_iterations: int = 5,
180
+ tolerance: float = 0.1,
181
+ ):
182
+ """
183
+ Iteratively estimate the future RA/Dec where the satellite will be when the telescope finishes slewing.
184
+
185
+ Args:
186
+ satellite_data: Satellite data dict.
187
+ max_iterations: Maximum number of iterations.
188
+ tolerance: Convergence threshold in seconds.
189
+
190
+ Returns:
191
+ Tuple of (target_ra, target_dec, estimated_slew_time)
192
+ """
193
+ # Get initial estimate
194
+ est_slew_time = self.predict_slew_time_seconds(satellite_data)
195
+ for _ in range(max_iterations):
196
+ future_radec = self.get_target_radec_and_rates(satellite_data, est_slew_time)
197
+ try:
198
+ new_slew_time = self.predict_slew_time_seconds(satellite_data, est_slew_time)
199
+ except TypeError:
200
+ # Fallback for legacy predict_slew_time_seconds signature
201
+ new_slew_time = self.predict_slew_time_seconds(satellite_data)
202
+ if abs(new_slew_time - est_slew_time) < tolerance:
203
+ break
204
+ est_slew_time = new_slew_time
205
+ return future_radec[0], future_radec[1], est_slew_time
@@ -0,0 +1,16 @@
1
+ import time
2
+
3
+ from citrascope.tasks.scope.base_telescope_task import AbstractBaseTelescopeTask
4
+
5
+
6
+ class StaticTelescopeTask(AbstractBaseTelescopeTask):
7
+ def execute(self):
8
+
9
+ satellite_data = self.fetch_satellite()
10
+ if not satellite_data or satellite_data.get("most_recent_elset") is None:
11
+ raise ValueError("Could not fetch valid satellite data or TLE.")
12
+ self.point_to_lead_position(satellite_data)
13
+
14
+ # Take the image
15
+ filepath = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
16
+ return self.upload_image_and_mark_complete(filepath)
@@ -0,0 +1,28 @@
1
+ import time
2
+
3
+ from citrascope.tasks.scope.base_telescope_task import AbstractBaseTelescopeTask
4
+
5
+
6
+ class TrackingTelescopeTask(AbstractBaseTelescopeTask):
7
+ def execute(self):
8
+
9
+ satellite_data = self.fetch_satellite()
10
+ if not satellite_data or satellite_data.get("most_recent_elset") is None:
11
+ raise ValueError("Could not fetch valid satellite data or TLE.")
12
+
13
+ self.point_to_lead_position(satellite_data)
14
+
15
+ # determine appropriate tracking rates based on satellite motion
16
+ _, _, target_ra_rate, target_dec_rate = self.get_target_radec_and_rates(satellite_data)
17
+
18
+ # Set the telescope tracking rates
19
+ tracking_set = self.hardware_adapter.set_custom_tracking_rate(
20
+ target_ra_rate.arcseconds.per_second, target_dec_rate.arcseconds.per_second
21
+ )
22
+ if not tracking_set:
23
+ self.logger.error("Failed to set tracking rates on telescope.")
24
+ return False
25
+
26
+ # Take the image
27
+ filepath = self.hardware_adapter.take_image(self.task.id, 20.0) # 20 second exposure
28
+ return self.upload_image_and_mark_complete(filepath)
@@ -0,0 +1,43 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Task:
6
+ id: str
7
+ type: str
8
+ status: str
9
+ creationEpoch: str
10
+ updateEpoch: str
11
+ taskStart: str
12
+ taskStop: str
13
+ userId: str
14
+ username: str
15
+ satelliteId: str
16
+ satelliteName: str
17
+ telescopeId: str
18
+ telescopeName: str
19
+ groundStationId: str
20
+ groundStationName: str
21
+
22
+ @classmethod
23
+ def from_dict(cls, data: dict) -> "Task":
24
+ return cls(
25
+ id=data.get("id"),
26
+ type=data.get("type", ""),
27
+ status=data.get("status"),
28
+ creationEpoch=data.get("creationEpoch", ""),
29
+ updateEpoch=data.get("updateEpoch", ""),
30
+ taskStart=data.get("taskStart", ""),
31
+ taskStop=data.get("taskStop", ""),
32
+ userId=data.get("userId", ""),
33
+ username=data.get("username", ""),
34
+ satelliteId=data.get("satelliteId", ""),
35
+ satelliteName=data.get("satelliteName", ""),
36
+ telescopeId=data.get("telescopeId", ""),
37
+ telescopeName=data.get("telescopeName", ""),
38
+ groundStationId=data.get("groundStationId", ""),
39
+ groundStationName=data.get("groundStationName", ""),
40
+ )
41
+
42
+ def __repr__(self):
43
+ return f"<Task {self.id} {self.type} {self.status}>"
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: citrascope
3
+ Version: 0.1.0
4
+ Summary: Remotely control a telescope while it polls for tasks, collects and edge processes data, and delivers results and data for further processing.
5
+ Author-email: Christopher Stevens <chris@citra.space>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: click
9
+ Requires-Dist: httpx
10
+ Requires-Dist: pixelemon
11
+ Requires-Dist: pydantic-settings
12
+ Requires-Dist: pyindi-client
13
+ Requires-Dist: pytest-cov
14
+ Requires-Dist: python-dateutil
15
+ Requires-Dist: python-json-logger
16
+ Requires-Dist: requests
17
+ Requires-Dist: skyfield
18
+ Requires-Dist: types-python-dateutil
19
+ Provides-Extra: build
20
+ Requires-Dist: build; extra == 'build'
21
+ Provides-Extra: deploy
22
+ Requires-Dist: twine; extra == 'deploy'
23
+ Provides-Extra: dev
24
+ Requires-Dist: black; extra == 'dev'
25
+ Requires-Dist: flake8; extra == 'dev'
26
+ Requires-Dist: flake8-pytest-style; extra == 'dev'
27
+ Requires-Dist: isort; extra == 'dev'
28
+ Requires-Dist: mockito; extra == 'dev'
29
+ Requires-Dist: mypy; extra == 'dev'
30
+ Requires-Dist: pre-commit; extra == 'dev'
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: pytest-cov; extra == 'dev'
33
+ Provides-Extra: docs
34
+ Requires-Dist: sphinx; extra == 'docs'
35
+ Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
36
+ Requires-Dist: sphinx-markdown-builder; extra == 'docs'
37
+ Provides-Extra: test
38
+ Requires-Dist: mockito; extra == 'test'
39
+ Requires-Dist: pytest; extra == 'test'
40
+ Requires-Dist: pytest-cov; extra == 'test'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # CitraScope
44
+ [![Pytest](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml) [![Build and Push Docker Image](https://github.com/citra-space/citrascope/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/docker-publish.yml)
45
+
46
+ Remotely control a telescope while it polls for tasks, collects observations, and delivers data for further processing.
47
+
48
+ ## Features
49
+
50
+ - Connects to Citra.space's API and identifies itself as an online telescope
51
+ - Connects to configured INDI telescope and camera hardware
52
+ - Acts as a task daemon carrying out and remitting photography tasks
53
+
54
+ ## Installation
55
+
56
+ Install CitraScope from PyPI:
57
+
58
+ ```sh
59
+ pip install citrascope
60
+ ```
61
+
62
+ This provides the `citrascope` command-line tool. To see available commands:
63
+
64
+ ```sh
65
+ citrascope --help
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ Run the CLI tool:
71
+
72
+ ```sh
73
+ citrascope start
74
+ ```
75
+
76
+ To connect to the Citra Dev server:
77
+
78
+ ```sh
79
+ citrascope start --dev
80
+ ```
81
+
82
+ ## Configuration
83
+
84
+ Settings are managed via environment variables with the prefix `CITRASCOPE_`. You must configure your personal access token and telescope ID, as well as INDI server details. You can set these variables in your shell or in a `.env` file at the project root.
85
+
86
+ Example `.env` file:
87
+
88
+ ```env
89
+ CITRASCOPE_PERSONAL_ACCESS_TOKEN=citra_pat_xxx
90
+ CITRASCOPE_TELESCOPE_ID=xxx
91
+ # CITRASCOPE_INDI_SERVER_URL=127.0.0.1
92
+ CITRASCOPE_INDI_SERVER_URL=host.docker.internal # use with devcontainer for accessing a localhost indi server
93
+ CITRASCOPE_INDI_SERVER_PORT=7624
94
+ CITRASCOPE_INDI_TELESCOPE_NAME=Telescope Simulator
95
+ ```
96
+
97
+ **Variable descriptions:**
98
+
99
+ - `CITRASCOPE_PERSONAL_ACCESS_TOKEN`: Your CitraScope personal access token (required)
100
+ - `CITRASCOPE_TELESCOPE_ID`: Your telescope ID (required)
101
+ - `CITRASCOPE_INDI_SERVER_URL`: Hostname or IP address of the INDI server (default: `host.docker.internal` for devcontainers, or `127.0.0.1` for local)
102
+ - `CITRASCOPE_INDI_SERVER_PORT`: Port for the INDI server (default: `7624`)
103
+ - `CITRASCOPE_INDI_TELESCOPE_NAME`: Name of the INDI telescope device (default: `Telescope Simulator`)
104
+
105
+ You can copy `.env.example` to `.env` and tweak your values.
106
+
107
+ ## Developer Setup
108
+
109
+ If you are developing on macOS or Windows, use the provided [VS Code Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) setup. The devcontainer provides a full Linux environment, which is required for the `pyindi-client` dependency to work. This is necessary because `pyindi-client` only works on Linux, and will not function natively on Mac or Windows.
110
+
111
+ By opening this project in VS Code and choosing "Reopen in Container" (or using the Dev Containers extension), you can develop and run the project seamlessly, regardless of your host OS.
112
+
113
+ The devcontainer also ensures all required system dependencies (like `cmake`) are installed automatically.
114
+
115
+ ### Installing Development Dependencies
116
+
117
+ To install development dependencies (for code style, linting, and pre-commit hooks):
118
+
119
+ ```sh
120
+ pip install '.[dev]'
121
+ ```
122
+
123
+ ### Setting up Pre-commit Hooks
124
+
125
+ This project uses [pre-commit](https://pre-commit.com/) to run code quality checks (like Flake8, Black, isort, etc.) automatically before each commit.
126
+
127
+ After installing the dev dependencies, enable the hooks with:
128
+
129
+ ```sh
130
+ pre-commit install
131
+ ```
132
+
133
+ You can manually run all pre-commit checks on all files with:
134
+
135
+ ```sh
136
+ pre-commit run --all-files
137
+ ```
138
+
139
+ This ensures code style and quality checks are enforced for all contributors.
140
+
141
+ ### Running and Debugging with VS Code
142
+
143
+ If you are using Visual Studio Code, you can run or debug the project directly using the pre-configured launch options in `.vscode/launch.json`:
144
+
145
+ - **Python: citrascope dev start** — Runs the main entry point with development options.
146
+ - **Python: citrascope dev start DEBUG logging** — Runs with development options and sets log level to DEBUG for more detailed output.
147
+
148
+ To use these, open the Run and Debug panel in VS Code, select the desired configuration, and click the Run or Debug button. This is a convenient way to start or debug the app without manually entering commands.
149
+
150
+ ## Running Tests
151
+
152
+ This project uses [pytest](https://pytest.org/) for unit testing. All tests are located in the `tests/` directory.
153
+
154
+ To run tests manually:
155
+
156
+ ```bash
157
+ pytest
158
+ ```
@@ -0,0 +1,21 @@
1
+ citrascope/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ citrascope/__main__.py,sha256=MlIzMVRL_tnvd8vJcZXENIglepirST3KDPfVpN7V9bE,673
3
+ citrascope/citra_scope_daemon.py,sha256=rQ4U7r2q5n9FSxM7E7xTdhpJjYCT7Mv_3PhUPkWwMTc,4073
4
+ citrascope/api/abstract_api_client.py,sha256=FG386RwHZtsCNaGGx0bhwxajyj_0B6SxU3p2w0R4tYU,472
5
+ citrascope/api/citra_api_client.py,sha256=7T7woGN-cjTYF8CNkGSjWXCKO-q0ufnBLGrdT2pdY3w,4173
6
+ citrascope/hardware/abstract_astro_hardware_adapter.py,sha256=JOw0PDoFrgicB5qZrurlLrVcDsjQNjHcxyFJ6674KJ8,3748
7
+ citrascope/hardware/indi_adapter.py,sha256=57AvH-AzthqXyefUgMq21GfcFfNh9WcGoara03NiJkg,12564
8
+ citrascope/logging/__init__.py,sha256=bXX2PX6MZelEX3fi_-lmT51uZCfN9WnLPZfx5_RswAA,101
9
+ citrascope/logging/_citrascope_logger.py,sha256=-KZ3ufQc3VOSlP1I3KC1YFDU7o880-O0iu3URlzRGlM,1056
10
+ citrascope/settings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ citrascope/settings/_citrascope_settings.py,sha256=JpuVFroZERPjbx58ZCw9BM9xr41qW6MNxm3sYGjYS14,1386
12
+ citrascope/tasks/runner.py,sha256=pUyQlrQkSNbM_SKl6AUFY8QSV2NzZfGvybGlzV7Q-BU,7320
13
+ citrascope/tasks/task.py,sha256=0u0oN56E6KaNz19ba_7WuY43Sk4CTXc8UPT7sdUpRXo,1287
14
+ citrascope/tasks/scope/base_telescope_task.py,sha256=He8W9OSHDL2tp56sY5dPdtYDswzp5HDMMeWpSNYNcgY,9198
15
+ citrascope/tasks/scope/static_telescope_task.py,sha256=DTrKZiOJ3ZPSDPvmMbyWkW50kS4I6Li6qC6SyrpWbaI,612
16
+ citrascope/tasks/scope/tracking_telescope_task.py,sha256=k5LEmEi_xnFHNjqPNYb8_tqDdCFD3YGe25Wh_brJXHk,1130
17
+ docs/index.md,sha256=YQDeVrN9AcbRzo88Jc4iRCO70gAh_4GSgImrJMwcSCo,1402
18
+ citrascope-0.1.0.dist-info/METADATA,sha256=u7Uc7lN6PZz78TUNFqmYe3IWBgxPdfudIpg31kuuVX0,5817
19
+ citrascope-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
+ citrascope-0.1.0.dist-info/entry_points.txt,sha256=fP22Lt8bNZ_whBowDnOWSADf_FUrgAWnIhqqPf5Xo2g,55
21
+ citrascope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any