citrascope 0.3.0__py3-none-any.whl → 0.5.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.
citrascope/__main__.py CHANGED
@@ -1,24 +1,21 @@
1
1
  import click
2
2
 
3
3
  from citrascope.citra_scope_daemon import CitraScopeDaemon
4
+ from citrascope.constants import DEFAULT_WEB_PORT
4
5
  from citrascope.settings.citrascope_settings import CitraScopeSettings
5
6
 
6
7
 
7
- @click.group()
8
- @click.option("--dev", is_flag=True, default=False, help="Use the development API (dev.app.citra.space)")
9
- @click.option("--log-level", default="INFO", help="Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
10
- @click.option("--keep-images", is_flag=True, default=False, help="Keep image files after upload (do not delete)")
11
- @click.pass_context
12
- def cli(ctx, dev, log_level, keep_images):
13
- ctx.obj = {"settings": CitraScopeSettings(dev=dev, log_level=log_level, keep_images=keep_images)}
14
-
15
-
16
- @cli.command("start")
17
- @click.option("--web-host", default="0.0.0.0", help="Web server host address (default: 0.0.0.0)")
18
- @click.option("--web-port", default=24872, type=int, help="Web server port (default: 24872)")
19
- @click.pass_context
20
- def start(ctx, web_host, web_port):
21
- daemon = CitraScopeDaemon(ctx.obj["settings"], enable_web=True, web_host=web_host, web_port=web_port)
8
+ @click.command()
9
+ @click.option(
10
+ "--web-port",
11
+ default=DEFAULT_WEB_PORT,
12
+ type=int,
13
+ help=f"Web server port (default: {DEFAULT_WEB_PORT})",
14
+ )
15
+ def cli(web_port):
16
+ """CitraScope daemon - configure via web UI at http://localhost:24872"""
17
+ settings = CitraScopeSettings(web_port=web_port)
18
+ daemon = CitraScopeDaemon(settings)
22
19
  daemon.run()
23
20
 
24
21
 
@@ -41,7 +41,19 @@ class CitraApiClient(AbstractCitraApiClient):
41
41
  return resp.json()
42
42
  except httpx.HTTPStatusError as e:
43
43
  if self.logger:
44
- self.logger.error(f"HTTP error: {e.response.status_code} {e.response.text}")
44
+ # Check if response is HTML (e.g., Cloudflare error pages)
45
+ content_type = e.response.headers.get("content-type", "")
46
+ response_text = e.response.text
47
+
48
+ if "text/html" in content_type or response_text.strip().startswith("<"):
49
+ # Log only status and a brief message for HTML responses, sometimes we get Cloudflare error pages
50
+ self.logger.error(
51
+ f"HTTP error: {e.response.status_code} - "
52
+ f"Received HTML error page (likely Cloudflare or server error) for {method} {endpoint}"
53
+ )
54
+ else:
55
+ # Log full response for non-HTML errors (JSON, plain text, etc.)
56
+ self.logger.error(f"HTTP error: {e.response.status_code} {response_text}")
45
57
  return None
46
58
  except Exception as e:
47
59
  if self.logger:
@@ -17,9 +17,6 @@ class CitraScopeDaemon:
17
17
  settings: CitraScopeSettings,
18
18
  api_client: Optional[AbstractCitraApiClient] = None,
19
19
  hardware_adapter: Optional[AbstractAstroHardwareAdapter] = None,
20
- enable_web: bool = True,
21
- web_host: str = "0.0.0.0",
22
- web_port: int = 24872,
23
20
  ):
24
21
  self.settings = settings
25
22
  CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
@@ -33,16 +30,15 @@ class CitraScopeDaemon:
33
30
 
34
31
  self.api_client = api_client
35
32
  self.hardware_adapter = hardware_adapter
36
- self.enable_web = enable_web
37
33
  self.web_server = None
38
34
  self.task_manager = None
39
35
  self.ground_station = None
40
36
  self.telescope_record = None
41
37
  self.configuration_error: Optional[str] = None
38
+ self._autofocus_in_progress = False
42
39
 
43
- # Create web server instance if enabled (always start web server)
44
- if self.enable_web:
45
- self.web_server = CitraScopeWebServer(daemon=self, host=web_host, port=web_port)
40
+ # Create web server instance (always enabled)
41
+ self.web_server = CitraScopeWebServer(daemon=self, host="0.0.0.0", port=self.settings.web_port)
46
42
 
47
43
  def _create_hardware_adapter(self) -> AbstractAstroHardwareAdapter:
48
44
  """Factory method to create the appropriate hardware adapter based on settings."""
@@ -73,12 +69,8 @@ class CitraScopeDaemon:
73
69
  try:
74
70
  if reload_settings:
75
71
  CITRASCOPE_LOGGER.info("Reloading configuration...")
76
- # Reload settings from file
77
- new_settings = CitraScopeSettings(
78
- dev=("dev.api" in self.settings.host),
79
- log_level=self.settings.log_level,
80
- keep_images=self.settings.keep_images,
81
- )
72
+ # Reload settings from file (preserving web_port)
73
+ new_settings = CitraScopeSettings(web_port=self.settings.web_port)
82
74
  self.settings = new_settings
83
75
  CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
84
76
 
@@ -186,6 +178,9 @@ class CitraScopeDaemon:
186
178
  f"Hardware connected. Slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec"
187
179
  )
188
180
 
181
+ # Save filter configuration if adapter supports it
182
+ self._save_filter_config()
183
+
189
184
  self.task_manager = TaskManager(
190
185
  self.api_client,
191
186
  citra_telescope_record,
@@ -205,12 +200,88 @@ class CitraScopeDaemon:
205
200
  CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
206
201
  return False, error_msg
207
202
 
203
+ def is_autofocus_in_progress(self) -> bool:
204
+ """Check if autofocus routine is currently running.
205
+
206
+ Returns:
207
+ bool: True if autofocus is in progress, False otherwise
208
+ """
209
+ return self._autofocus_in_progress
210
+
211
+ def _save_filter_config(self):
212
+ """Save filter configuration from adapter to settings if supported.
213
+
214
+ This method is called:
215
+ - After hardware initialization to save discovered filters
216
+ - After autofocus to save updated focus positions
217
+ - After manual filter focus updates via web API
218
+
219
+ Thread safety: This modifies self.settings and writes to disk.
220
+ Should be called from main daemon thread or properly synchronized.
221
+ """
222
+ if not self.hardware_adapter or not self.hardware_adapter.supports_filter_management():
223
+ return
224
+
225
+ try:
226
+ filter_config = self.hardware_adapter.get_filter_config()
227
+ if filter_config:
228
+ self.settings.adapter_settings["filters"] = filter_config
229
+ self.settings.save()
230
+ CITRASCOPE_LOGGER.info(f"Saved filter configuration with {len(filter_config)} filters")
231
+ except Exception as e:
232
+ CITRASCOPE_LOGGER.warning(f"Failed to save filter configuration: {e}")
233
+
234
+ def trigger_autofocus(self) -> tuple[bool, Optional[str]]:
235
+ """Trigger autofocus routine on the hardware adapter.
236
+
237
+ Requires task processing to be manually paused before running.
238
+ Checks that both:
239
+ 1. Task processing is paused
240
+ 2. No task is currently in-flight
241
+
242
+ Returns:
243
+ Tuple of (success, error_message)
244
+ """
245
+ if not self.hardware_adapter:
246
+ return False, "No hardware adapter initialized"
247
+
248
+ if not self.hardware_adapter.supports_filter_management():
249
+ return False, "Hardware adapter does not support filter management"
250
+
251
+ # Prevent concurrent autofocus operations
252
+ if self._autofocus_in_progress:
253
+ return False, "Autofocus already in progress"
254
+
255
+ # Require task processing to be manually paused
256
+ if self.task_manager:
257
+ if self.task_manager.is_processing_active():
258
+ return False, "Task processing must be paused before running autofocus"
259
+
260
+ if self.task_manager.current_task_id is not None:
261
+ return False, "A task is currently executing. Please wait for it to complete and try again"
262
+
263
+ self._autofocus_in_progress = True
264
+ try:
265
+ CITRASCOPE_LOGGER.info("Starting autofocus routine...")
266
+ self.hardware_adapter.do_autofocus()
267
+
268
+ # Save updated filter configuration after autofocus
269
+ self._save_filter_config()
270
+
271
+ CITRASCOPE_LOGGER.info("Autofocus routine completed successfully")
272
+ return True, None
273
+ except Exception as e:
274
+ error_msg = f"Autofocus failed: {str(e)}"
275
+ CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
276
+ return False, error_msg
277
+ finally:
278
+ self._autofocus_in_progress = False
279
+
208
280
  def run(self):
209
- # Start web server FIRST if enabled, so users can monitor/configure
281
+ # Start web server FIRST, so users can monitor/configure
210
282
  # The web interface will remain available even if configuration is incomplete
211
- if self.enable_web:
212
- self.web_server.start()
213
- CITRASCOPE_LOGGER.info(f"Web interface available at http://{self.web_server.host}:{self.web_server.port}")
283
+ self.web_server.start()
284
+ CITRASCOPE_LOGGER.info(f"Web interface available at http://{self.web_server.host}:{self.web_server.port}")
214
285
 
215
286
  try:
216
287
  # Try to initialize components
@@ -238,6 +309,7 @@ class CitraScopeDaemon:
238
309
  """Clean up resources on shutdown."""
239
310
  if self.task_manager:
240
311
  self.task_manager.stop()
241
- if self.enable_web and self.web_server:
312
+ if self.web_server:
242
313
  CITRASCOPE_LOGGER.info("Stopping web server...")
243
- self.web_server.stop()
314
+ if self.web_server.web_log_handler:
315
+ CITRASCOPE_LOGGER.removeHandler(self.web_server.web_log_handler)
@@ -0,0 +1,23 @@
1
+ """Application-wide constants for CitraScope.
2
+
3
+ This module contains all shared constants used across different parts of the application.
4
+ Centralizing these values prevents duplication and circular import issues.
5
+ """
6
+
7
+ # ============================================================================
8
+ # API ENDPOINTS
9
+ # ============================================================================
10
+ PROD_API_HOST = "api.citra.space"
11
+ DEV_API_HOST = "dev.api.citra.space"
12
+ DEFAULT_API_PORT = 443
13
+
14
+ # ============================================================================
15
+ # WEB APP URLs
16
+ # ============================================================================
17
+ PROD_APP_URL = "https://app.citra.space"
18
+ DEV_APP_URL = "https://dev.app.citra.space"
19
+
20
+ # ============================================================================
21
+ # WEB SERVER
22
+ # ============================================================================
23
+ DEFAULT_WEB_PORT = 24872 # "CITRA" on phone keypad
@@ -20,6 +20,18 @@ class SettingSchemaEntry(TypedDict, total=False):
20
20
  options: list[str] # List of valid options for select/dropdown inputs
21
21
 
22
22
 
23
+ class FilterConfig(TypedDict):
24
+ """Type definition for filter configuration.
25
+
26
+ Attributes:
27
+ name: Human-readable filter name (e.g., 'Luminance', 'Red', 'Ha')
28
+ focus_position: Focuser position for this filter in steps
29
+ """
30
+
31
+ name: str
32
+ focus_position: int
33
+
34
+
23
35
  class ObservationStrategy(Enum):
24
36
  MANUAL = 1
25
37
  SEQUENCE_TO_CONTROLLER = 2
@@ -176,3 +188,52 @@ class AbstractAstroHardwareAdapter(ABC):
176
188
  bool: True if alignment was successful, False otherwise.
177
189
  """
178
190
  pass
191
+
192
+ def do_autofocus(self) -> None:
193
+ """Perform autofocus routine for all filters.
194
+
195
+ This is an optional method for adapters that support filter management.
196
+ Default implementation raises NotImplementedError. Override in subclasses
197
+ that support autofocus.
198
+
199
+ Raises:
200
+ NotImplementedError: If the adapter doesn't support autofocus
201
+ """
202
+ raise NotImplementedError(f"{self.__class__.__name__} does not support autofocus")
203
+
204
+ def supports_filter_management(self) -> bool:
205
+ """Indicates whether this adapter supports filter/focus management.
206
+
207
+ Returns:
208
+ bool: True if the adapter manages filters and focus positions, False otherwise.
209
+ """
210
+ return False
211
+
212
+ def get_filter_config(self) -> dict[str, FilterConfig]:
213
+ """Get the current filter configuration including focus positions.
214
+
215
+ Returns:
216
+ dict: Dictionary mapping filter IDs (as strings) to FilterConfig.
217
+ Each FilterConfig contains:
218
+ - name (str): Filter name
219
+ - focus_position (int): Focuser position for this filter
220
+
221
+ Example:
222
+ {
223
+ "1": {"name": "Luminance", "focus_position": 9000},
224
+ "2": {"name": "Red", "focus_position": 9050}
225
+ }
226
+ """
227
+ return {}
228
+
229
+ def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
230
+ """Update the focus position for a specific filter.
231
+
232
+ Args:
233
+ filter_id: Filter ID as string
234
+ focus_position: New focus position in steps
235
+
236
+ Returns:
237
+ bool: True if update was successful, False otherwise
238
+ """
239
+ return False
@@ -5,16 +5,16 @@ import os
5
5
  import sys
6
6
  import time
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
- import platformdirs
10
10
  import requests
11
11
 
12
12
  from citrascope.hardware.abstract_astro_hardware_adapter import (
13
13
  AbstractAstroHardwareAdapter,
14
+ FilterConfig,
14
15
  ObservationStrategy,
15
16
  SettingSchemaEntry,
16
17
  )
17
- from citrascope.settings.citrascope_settings import APP_AUTHOR, APP_NAME
18
18
 
19
19
 
20
20
  class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
@@ -32,47 +32,17 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
32
32
  def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
33
33
  super().__init__(images_dir=images_dir)
34
34
  self.logger: logging.Logger = logger
35
- self._data_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR))
36
- self._focus_positions_file = self._data_dir / "nina_focus_positions.json"
37
35
  self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
38
- self.bypass_autofocus = kwargs.get("bypass_autofocus", False)
39
36
 
40
37
  self.filter_map = {}
41
- self._load_focus_positions()
42
-
43
- def _load_focus_positions(self):
44
- """Load focus positions from file if available."""
45
- try:
46
- if self._focus_positions_file.exists():
47
- with open(self._focus_positions_file, "r") as f:
48
- focus_data = json.load(f)
49
- if self.logger:
50
- self.logger.info(f"Loaded focus positions from {self._focus_positions_file}")
51
- self._focus_positions_cache = focus_data
52
- else:
53
- self._focus_positions_cache = {}
54
- except Exception as e:
55
- if self.logger:
56
- self.logger.warning(f"Could not load focus positions file: {e}")
57
- self._focus_positions_cache = {}
58
-
59
- def _save_focus_positions(self):
60
- """Save current filter_map focus positions to file."""
61
- try:
62
- # Ensure data directory exists
63
- self._data_dir.mkdir(parents=True, exist_ok=True)
64
-
65
- focus_data = {
66
- str(fid): {"name": fdata["name"], "focus_position": fdata["focus_position"]}
67
- for fid, fdata in self.filter_map.items()
68
- }
69
- with open(self._focus_positions_file, "w") as f:
70
- json.dump(focus_data, f, indent=2)
71
- if self.logger:
72
- self.logger.info(f"Saved focus positions to {self._focus_positions_file}")
73
- except Exception as e:
74
- if self.logger:
75
- self.logger.warning(f"Could not save focus positions file: {e}")
38
+ # Load filter configuration from settings if available
39
+ saved_filters = kwargs.get("filters", {})
40
+ for filter_id, filter_data in saved_filters.items():
41
+ # Convert string keys back to int for internal use
42
+ try:
43
+ self.filter_map[int(filter_id)] = filter_data
44
+ except (ValueError, TypeError) as e:
45
+ self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
76
46
 
77
47
  @classmethod
78
48
  def get_settings_schema(cls) -> list[SettingSchemaEntry]:
@@ -90,17 +60,20 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
90
60
  "placeholder": "http://localhost:1888/v2/api",
91
61
  "pattern": r"^https?://.*",
92
62
  },
93
- {
94
- "name": "bypass_autofocus",
95
- "friendly_name": "Bypass Autofocus",
96
- "type": "bool",
97
- "default": False,
98
- "description": "Skip autofocus routine when initializing, will use cached focus positions if available",
99
- "required": False,
100
- },
101
63
  ]
102
64
 
103
65
  def do_autofocus(self):
66
+ """Perform autofocus routine for all filters.
67
+
68
+ Slews telescope to Mirach (bright reference star) and runs autofocus
69
+ for each filter in the filter map, updating focus positions.
70
+
71
+ Raises:
72
+ RuntimeError: If no filters discovered or network requests fail
73
+ """
74
+ if not self.filter_map:
75
+ raise RuntimeError("No filters discovered. Cannot perform autofocus.")
76
+
104
77
  self.logger.info("Performing autofocus routine ...")
105
78
  # move telescope to bright star and start autofocus
106
79
  # Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
@@ -108,10 +81,15 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
108
81
  dec = 35 + 37 / 60.0 + 11.1 / 3600.0
109
82
 
110
83
  self.logger.info("Slewing to Mirach ...")
111
- mount_status = requests.get(
112
- self.nina_api_path + self.MOUNT_URL + "slew?ra=" + str(ra) + "&dec=" + str(dec)
113
- ).json()
114
- self.logger.info(f"Mount {mount_status['Response']}")
84
+ try:
85
+ response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}", timeout=30)
86
+ response.raise_for_status()
87
+ mount_status = response.json()
88
+ self.logger.info(f"Mount {mount_status['Response']}")
89
+ except requests.Timeout:
90
+ raise RuntimeError("Mount slew request timed out")
91
+ except requests.RequestException as e:
92
+ raise RuntimeError(f"Mount slew failed: {e}")
115
93
 
116
94
  # wait for slew to complete
117
95
  while self.telescope_is_moving():
@@ -120,14 +98,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
120
98
 
121
99
  for id, filter in self.filter_map.items():
122
100
  self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
123
- focus_value = self._auto_focus_one_filter(id, filter["name"])
101
+ # Pass existing focus position to preserve it if autofocus fails
102
+ existing_focus = filter.get("focus_position", self.DEFAULT_FOCUS_POSITION)
103
+ focus_value = self._auto_focus_one_filter(id, filter["name"], existing_focus)
124
104
  self.filter_map[id]["focus_position"] = focus_value
125
105
 
126
- # Save focus positions after autofocus
127
- self._save_focus_positions()
128
-
129
106
  # autofocus routine
130
- def _auto_focus_one_filter(self, filter_id, filter_name) -> int:
107
+ def _auto_focus_one_filter(self, filter_id: int, filter_name: str, existing_focus_position: int) -> int:
131
108
 
132
109
  # change to the requested filter
133
110
  correct_filter_in_place = False
@@ -165,8 +142,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
165
142
 
166
143
  if not last_completed_autofocus.get("Success"):
167
144
  self.logger.error(f"Failed to start autofocus: {last_completed_autofocus.get('Error')}")
168
- self.logger.warning("Using default focus position")
169
- return starting_focus_position
145
+ # Preserve existing focus position if it's not the default, otherwise use default
146
+ if existing_focus_position != self.DEFAULT_FOCUS_POSITION:
147
+ self.logger.warning(f"Preserving existing focus position: {existing_focus_position}")
148
+ return existing_focus_position
149
+ else:
150
+ self.logger.warning(f"Using default focus position: {self.DEFAULT_FOCUS_POSITION}")
151
+ return self.DEFAULT_FOCUS_POSITION
170
152
 
171
153
  while (
172
154
  last_completed_autofocus["Response"]["Filter"] != filter_name
@@ -186,13 +168,22 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
186
168
 
187
169
  def _do_point_telescope(self, ra: float, dec: float):
188
170
  self.logger.info(f"Slewing to RA: {ra}, Dec: {dec}")
189
- slew_response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}").json()
171
+ try:
172
+ response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}", timeout=30)
173
+ response.raise_for_status()
174
+ slew_response = response.json()
190
175
 
191
- if slew_response.get("Success"):
192
- self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
193
- return True
194
- else:
195
- self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
176
+ if slew_response.get("Success"):
177
+ self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
178
+ return True
179
+ else:
180
+ self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
181
+ return False
182
+ except requests.Timeout:
183
+ self.logger.error("Mount slew request timed out")
184
+ return False
185
+ except requests.RequestException as e:
186
+ self.logger.error(f"Mount slew request failed: {e}")
196
187
  return False
197
188
 
198
189
  def connect(self) -> bool:
@@ -240,12 +231,8 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
240
231
  return False
241
232
  self.logger.info(f"Mount Unparked!")
242
233
 
243
- # make a map of available filters and their focus positions
234
+ # Discover available filters (focus positions loaded from saved settings)
244
235
  self.discover_filters()
245
- if not self.bypass_autofocus:
246
- self.do_autofocus()
247
- else:
248
- self.logger.info("Bypassing autofocus routine as requested")
249
236
 
250
237
  return True
251
238
  except Exception as e:
@@ -263,16 +250,58 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
263
250
  for filter in filters:
264
251
  filter_id = filter["Id"]
265
252
  filter_name = filter["Name"]
266
- # Try to load focus position from cache, fallback to default
267
- focus_position = self._focus_positions_cache.get(str(filter_id), {}).get(
268
- "focus_position", self.DEFAULT_FOCUS_POSITION
269
- )
253
+ # Use existing focus position if filter already in map, otherwise use default
254
+ if filter_id in self.filter_map:
255
+ focus_position = self.filter_map[filter_id].get("focus_position", self.DEFAULT_FOCUS_POSITION)
256
+ self.logger.info(
257
+ f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}"
258
+ )
259
+ else:
260
+ focus_position = self.DEFAULT_FOCUS_POSITION
261
+ self.logger.info(
262
+ f"Discovered new filter: {filter_name} with ID: {filter_id}, using default focus position: {focus_position}"
263
+ )
264
+
270
265
  self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
271
- self.logger.info(f"Discovered filter: {filter_name} with ID: {filter_id}, focus position: {focus_position}")
272
266
 
273
267
  def disconnect(self):
274
268
  pass
275
269
 
270
+ def supports_filter_management(self) -> bool:
271
+ """Indicates that NINA adapter supports filter/focus management."""
272
+ return True
273
+
274
+ def get_filter_config(self) -> dict[str, FilterConfig]:
275
+ """Get current filter configuration with focus positions.
276
+
277
+ Returns:
278
+ dict: Filter configuration mapping filter ID strings to FilterConfig
279
+ """
280
+ return {
281
+ str(filter_id): {"name": filter_data["name"], "focus_position": filter_data["focus_position"]}
282
+ for filter_id, filter_data in self.filter_map.items()
283
+ }
284
+
285
+ def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
286
+ """Update the focus position for a specific filter.
287
+
288
+ Args:
289
+ filter_id: Filter ID as string
290
+ focus_position: New focus position in steps
291
+
292
+ Returns:
293
+ bool: True if update was successful, False otherwise
294
+ """
295
+ try:
296
+ filter_id_int = int(filter_id)
297
+ if filter_id_int in self.filter_map:
298
+ self.filter_map[filter_id_int]["focus_position"] = focus_position
299
+ self.logger.info(f"Updated filter {filter_id} focus position to {focus_position}")
300
+ return True
301
+ return False
302
+ except (ValueError, KeyError):
303
+ return False
304
+
276
305
  def is_telescope_connected(self) -> bool:
277
306
  """Check if telescope is connected and responsive."""
278
307
  try:
@@ -422,8 +451,8 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
422
451
 
423
452
  nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
424
453
 
425
- # Replace basic placeholders
426
- tle_data = f"{elset['tle'][0]}\n{elset['tle'][1]}"
454
+ # Replace basic placeholders (use \r\n for Windows NINA compatibility)
455
+ tle_data = f"{elset['tle'][0]}\r\n{elset['tle'][1]}"
427
456
  sequence_json["Name"] = nina_sequence_name
428
457
 
429
458
  # Navigate to the TLE container (ID 20 in the template)
@@ -23,14 +23,15 @@ class WebLogHandler(logging.Handler):
23
23
  def emit(self, record):
24
24
  """Emit a log record."""
25
25
  try:
26
- # Filter out web-related logs from the web UI
27
- # (uvicorn.access, WebSocket messages, HTTP Request logs, etc.)
28
- if (
29
- record.name.startswith("uvicorn")
30
- or "WebSocket" in record.getMessage()
31
- or "HTTP Request:" in record.getMessage()
32
- ):
33
- return
26
+ # Filter out routine web-related logs from the web UI
27
+ # but keep ERROR and CRITICAL level messages
28
+ if record.levelno < logging.ERROR:
29
+ if (
30
+ record.name.startswith("uvicorn")
31
+ or "WebSocket" in record.getMessage()
32
+ or "HTTP Request:" in record.getMessage()
33
+ ):
34
+ return
34
35
 
35
36
  # Get the original levelname without color codes
36
37
  # Record.levelname might have ANSI codes from ColoredFormatter