citrascope 0.1.0__py3-none-any.whl → 0.3.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.
Files changed (35) hide show
  1. citrascope/__main__.py +8 -5
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +30 -1
  4. citrascope/citra_scope_daemon.py +214 -61
  5. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  6. citrascope/hardware/adapter_registry.py +94 -0
  7. citrascope/hardware/indi_adapter.py +456 -16
  8. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  9. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  10. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  11. citrascope/logging/__init__.py +2 -1
  12. citrascope/logging/_citrascope_logger.py +80 -1
  13. citrascope/logging/web_log_handler.py +74 -0
  14. citrascope/settings/citrascope_settings.py +145 -0
  15. citrascope/settings/settings_file_manager.py +126 -0
  16. citrascope/tasks/runner.py +124 -28
  17. citrascope/tasks/scope/base_telescope_task.py +25 -10
  18. citrascope/tasks/scope/static_telescope_task.py +11 -3
  19. citrascope/web/__init__.py +1 -0
  20. citrascope/web/app.py +470 -0
  21. citrascope/web/server.py +123 -0
  22. citrascope/web/static/api.js +82 -0
  23. citrascope/web/static/app.js +500 -0
  24. citrascope/web/static/config.js +362 -0
  25. citrascope/web/static/img/citra.png +0 -0
  26. citrascope/web/static/img/favicon.png +0 -0
  27. citrascope/web/static/style.css +120 -0
  28. citrascope/web/static/websocket.js +127 -0
  29. citrascope/web/templates/dashboard.html +354 -0
  30. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
  31. citrascope-0.3.0.dist-info/RECORD +38 -0
  32. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
  33. citrascope/settings/_citrascope_settings.py +0 -42
  34. citrascope-0.1.0.dist-info/RECORD +0 -21
  35. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,328 @@
1
+ {
2
+ "$id": "1",
3
+ "$type": "NINA.Sequencer.Container.SequenceRootContainer, NINA.Sequencer",
4
+ "Strategy": {
5
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
6
+ },
7
+ "Name": "{{SEQUENCE_NAME}}",
8
+ "Conditions": {
9
+ "$id": "2",
10
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
11
+ "$values": []
12
+ },
13
+ "IsExpanded": true,
14
+ "Items": {
15
+ "$id": "3",
16
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
17
+ "$values": [
18
+ {
19
+ "$id": "4",
20
+ "$type": "NINA.Sequencer.Container.StartAreaContainer, NINA.Sequencer",
21
+ "Strategy": {
22
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
23
+ },
24
+ "Name": "Start",
25
+ "Conditions": {
26
+ "$id": "5",
27
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
28
+ "$values": []
29
+ },
30
+ "IsExpanded": true,
31
+ "Items": {
32
+ "$id": "6",
33
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
34
+ "$values": []
35
+ },
36
+ "Triggers": {
37
+ "$id": "7",
38
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
39
+ "$values": []
40
+ },
41
+ "Parent": {
42
+ "$ref": "1"
43
+ },
44
+ "ErrorBehavior": 0,
45
+ "Attempts": 1
46
+ },
47
+ {
48
+ "$id": "8",
49
+ "$type": "NINA.Sequencer.Container.TargetAreaContainer, NINA.Sequencer",
50
+ "Strategy": {
51
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
52
+ },
53
+ "Name": "Targets",
54
+ "Conditions": {
55
+ "$id": "9",
56
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
57
+ "$values": []
58
+ },
59
+ "IsExpanded": true,
60
+ "Items": {
61
+ "$id": "10",
62
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
63
+ "$values": [
64
+ {
65
+ "$id": "11",
66
+ "$type": "NINA.Sequencer.Container.SequentialContainer, NINA.Sequencer",
67
+ "Strategy": {
68
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
69
+ },
70
+ "Name": "Sequential Instruction Set",
71
+ "Conditions": {
72
+ "$id": "12",
73
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
74
+ "$values": []
75
+ },
76
+ "IsExpanded": true,
77
+ "Items": {
78
+ "$id": "15",
79
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
80
+ "$values": [
81
+ {
82
+ "$id": "20",
83
+ "$type": "NINA.Joko.Plugin.Orbitals.SequenceItems.ManualTLEContainer, NINA.Joko.Plugin.Orbitals",
84
+ "TLEData": "{{TLE_DATA}}",
85
+ "OffsetExpanded": false,
86
+ "OffsetCoordinates": {
87
+ "$id": "21",
88
+ "$type": "NINA.Joko.Plugin.Orbitals.Utility.InputCoordinatesEx, NINA.Joko.Plugin.Orbitals",
89
+ "RAHours": 0,
90
+ "RAMinutes": 0,
91
+ "RASeconds": 0.0,
92
+ "NegativeDec": false,
93
+ "NegativeRA": false,
94
+ "DecDegrees": 0,
95
+ "DecMinutes": 0,
96
+ "DecSeconds": 0.0
97
+ },
98
+ "Target": {
99
+ "$id": "22",
100
+ "$type": "NINA.Astrometry.InputTarget, NINA.Astrometry",
101
+ "Expanded": true,
102
+ "TargetName": "{{SATELLITE_NAME}}",
103
+ "PositionAngle": 0.0,
104
+ "InputCoordinates": {
105
+ "$id": "23",
106
+ "$type": "NINA.Astrometry.InputCoordinates, NINA.Astrometry",
107
+ "RAHours": 3,
108
+ "RAMinutes": 33,
109
+ "RASeconds": 12.92135,
110
+ "NegativeDec": true,
111
+ "DecDegrees": -6,
112
+ "DecMinutes": 0,
113
+ "DecSeconds": 9.52085
114
+ }
115
+ },
116
+ "Strategy": {
117
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
118
+ },
119
+ "Name": "{{SATELLITE_NAME}}",
120
+ "Conditions": {
121
+ "$id": "24",
122
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
123
+ "$values": []
124
+ },
125
+ "IsExpanded": true,
126
+ "Items": {
127
+ "$id": "25",
128
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
129
+ "$values": [
130
+ {
131
+ "$id": "26",
132
+ "$type": "DaleGhent.NINA.PlaneWaveTools.TLE.TLEControl, PlaneWave Tools",
133
+ "Line1": "{{TLE_LINE1}}",
134
+ "Line2": "{{TLE_LINE2}}",
135
+ "Axis0Distance": -0.14,
136
+ "Axis1Distance": -0.01,
137
+ "Parent": {
138
+ "$ref": "20"
139
+ },
140
+ "ErrorBehavior": 0,
141
+ "Attempts": 1
142
+ },
143
+
144
+ {
145
+ "$id": "27",
146
+ "$type": "NINA.Sequencer.SequenceItem.FilterWheel.SwitchFilter, NINA.Sequencer",
147
+ "Filter": {
148
+ "$id": "28",
149
+ "$type": "NINA.Core.Model.Equipment.FilterInfo, NINA.Core",
150
+ "_name": "Clear",
151
+ "_focusOffset": 0,
152
+ "_position": 0,
153
+ "_autoFocusExposureTime": -1.0,
154
+ "_autoFocusFilter": true,
155
+ "FlatWizardFilterSettings": {
156
+ "$id": "29",
157
+ "$type": "NINA.Core.Model.Equipment.FlatWizardFilterSettings, NINA.Core",
158
+ "FlatWizardMode": 0,
159
+ "HistogramMeanTarget": 0.5,
160
+ "HistogramTolerance": 0.1,
161
+ "MaxFlatExposureTime": 30.0,
162
+ "MinFlatExposureTime": 0.01,
163
+ "MaxAbsoluteFlatDeviceBrightness": 32767,
164
+ "MinAbsoluteFlatDeviceBrightness": 0,
165
+ "Gain": -1,
166
+ "Offset": -1,
167
+ "Binning": {
168
+ "$id": "30",
169
+ "$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
170
+ "X": 1,
171
+ "Y": 1
172
+ }
173
+ },
174
+ "_autoFocusBinning": {
175
+ "$id": "31",
176
+ "$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
177
+ "X": 1,
178
+ "Y": 1
179
+ },
180
+ "_autoFocusGain": -1,
181
+ "_autoFocusOffset": -1
182
+ },
183
+ "Parent": {
184
+ "$ref": "20"
185
+ },
186
+ "ErrorBehavior": 0,
187
+ "Attempts": 1
188
+ },
189
+ {
190
+ "$id": "32",
191
+ "$type": "NINA.Sequencer.SequenceItem.Focuser.MoveFocuserAbsolute, NINA.Sequencer",
192
+ "Position": 9000,
193
+ "Parent": {
194
+ "$ref": "20"
195
+ },
196
+ "ErrorBehavior": 0,
197
+ "Attempts": 1
198
+ },
199
+ {
200
+ "$id": "33",
201
+ "$type": "NINA.Sequencer.SequenceItem.Imaging.TakeExposure, NINA.Sequencer",
202
+ "ExposureTime": 1.0,
203
+ "Gain": -1,
204
+ "Offset": -1,
205
+ "Binning": {
206
+ "$id": "34",
207
+ "$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
208
+ "X": 4,
209
+ "Y": 4
210
+ },
211
+ "ImageType": "LIGHT",
212
+ "ExposureCount": 130,
213
+ "Parent": {
214
+ "$ref": "20"
215
+ },
216
+ "ErrorBehavior": 0,
217
+ "Attempts": 1
218
+ }
219
+
220
+ ]
221
+ },
222
+ "Triggers": {
223
+ "$id": "63",
224
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
225
+ "$values": []
226
+ },
227
+ "Parent": {
228
+ "$ref": "11"
229
+ },
230
+ "ErrorBehavior": 0,
231
+ "Attempts": 1
232
+ }
233
+ ]
234
+ },
235
+ "Triggers": {
236
+ "$id": "112",
237
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
238
+ "$values": []
239
+ },
240
+ "Parent": {
241
+ "$ref": "8"
242
+ },
243
+ "ErrorBehavior": 0,
244
+ "Attempts": 1
245
+ }
246
+ ]
247
+ },
248
+ "Triggers": {
249
+ "$id": "113",
250
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
251
+ "$values": []
252
+ },
253
+ "Parent": {
254
+ "$ref": "1"
255
+ },
256
+ "ErrorBehavior": 0,
257
+ "Attempts": 1
258
+ },
259
+ {
260
+ "$id": "114",
261
+ "$type": "NINA.Sequencer.Container.EndAreaContainer, NINA.Sequencer",
262
+ "Strategy": {
263
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
264
+ },
265
+ "Name": "End",
266
+ "Conditions": {
267
+ "$id": "115",
268
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
269
+ "$values": []
270
+ },
271
+ "IsExpanded": true,
272
+ "Items": {
273
+ "$id": "116",
274
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
275
+ "$values": [
276
+ {
277
+ "$id": "117",
278
+ "$type": "NINA.Sequencer.Container.SequentialContainer, NINA.Sequencer",
279
+ "Strategy": {
280
+ "$type": "NINA.Sequencer.Container.ExecutionStrategy.SequentialStrategy, NINA.Sequencer"
281
+ },
282
+ "Name": "Sequential Instruction Set",
283
+ "Conditions": {
284
+ "$id": "118",
285
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Conditions.ISequenceCondition, NINA.Sequencer]], System.ObjectModel",
286
+ "$values": []
287
+ },
288
+ "IsExpanded": true,
289
+ "Items": {
290
+ "$id": "119",
291
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.SequenceItem.ISequenceItem, NINA.Sequencer]], System.ObjectModel",
292
+ "$values": []
293
+ },
294
+ "Triggers": {
295
+ "$id": "123",
296
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
297
+ "$values": []
298
+ },
299
+ "Parent": {
300
+ "$ref": "114"
301
+ },
302
+ "ErrorBehavior": 0,
303
+ "Attempts": 1
304
+ }
305
+ ]
306
+ },
307
+ "Triggers": {
308
+ "$id": "124",
309
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
310
+ "$values": []
311
+ },
312
+ "Parent": {
313
+ "$ref": "1"
314
+ },
315
+ "ErrorBehavior": 0,
316
+ "Attempts": 1
317
+ }
318
+ ]
319
+ },
320
+ "Triggers": {
321
+ "$id": "125",
322
+ "$type": "System.Collections.ObjectModel.ObservableCollection`1[[NINA.Sequencer.Trigger.ISequenceTrigger, NINA.Sequencer]], System.ObjectModel",
323
+ "$values": []
324
+ },
325
+ "Parent": null,
326
+ "ErrorBehavior": 0,
327
+ "Attempts": 1
328
+ }
@@ -1,3 +1,4 @@
1
1
  from citrascope.logging._citrascope_logger import CITRASCOPE_LOGGER
2
+ from citrascope.logging.web_log_handler import WebLogHandler
2
3
 
3
- __all__ = ["CITRASCOPE_LOGGER"]
4
+ __all__ = ["CITRASCOPE_LOGGER", "WebLogHandler"]
@@ -1,4 +1,7 @@
1
1
  import logging
2
+ from logging.handlers import TimedRotatingFileHandler
3
+ from pathlib import Path
4
+ from typing import Optional
2
5
 
3
6
 
4
7
  class ExcludeHttpRequestFilter(logging.Filter):
@@ -6,6 +9,22 @@ class ExcludeHttpRequestFilter(logging.Filter):
6
9
  return "HTTP Request:" not in record.getMessage()
7
10
 
8
11
 
12
+ class ExcludeWebLogsFilter(logging.Filter):
13
+ """Filter out web-related logs (uvicorn, HTTP requests) from file logging."""
14
+
15
+ def filter(self, record):
16
+ # Exclude uvicorn loggers
17
+ if record.name.startswith("uvicorn"):
18
+ return False
19
+ # Exclude HTTP request messages
20
+ if "HTTP Request:" in record.getMessage():
21
+ return False
22
+ # Exclude WebSocket messages
23
+ if "WebSocket" in record.getMessage():
24
+ return False
25
+ return True
26
+
27
+
9
28
  class ColoredFormatter(logging.Formatter):
10
29
  COLORS = {
11
30
  "DEBUG": "\033[94m", # Blue
@@ -17,14 +36,26 @@ class ColoredFormatter(logging.Formatter):
17
36
  RESET = "\033[0m"
18
37
 
19
38
  def format(self, record):
39
+ # Save original levelname
40
+ original_levelname = record.levelname
41
+
42
+ # Temporarily add color codes
20
43
  color = self.COLORS.get(record.levelname, self.RESET)
21
44
  record.levelname = f"{color}{record.levelname}{self.RESET}"
22
- return super().format(record)
45
+
46
+ # Format the record
47
+ result = super().format(record)
48
+
49
+ # Restore original levelname so other handlers don't get colored version
50
+ record.levelname = original_levelname
51
+
52
+ return result
23
53
 
24
54
 
25
55
  CITRASCOPE_LOGGER = logging.getLogger()
26
56
  CITRASCOPE_LOGGER.setLevel(logging.INFO)
27
57
 
58
+ # Console handler with colors
28
59
  handler = logging.StreamHandler()
29
60
  handler.addFilter(ExcludeHttpRequestFilter())
30
61
  log_format = "%(asctime)s %(levelname)s %(message)s"
@@ -33,3 +64,51 @@ formatter = ColoredFormatter(fmt=log_format, datefmt=date_format)
33
64
  handler.setFormatter(formatter)
34
65
  CITRASCOPE_LOGGER.handlers.clear()
35
66
  CITRASCOPE_LOGGER.addHandler(handler)
67
+
68
+ # File handler will be added by setup_file_logging()
69
+ _file_handler: Optional[TimedRotatingFileHandler] = None
70
+
71
+
72
+ def setup_file_logging(log_file_path: Path, backup_count: int = 30) -> None:
73
+ """Setup file-based logging with daily rotation.
74
+
75
+ Args:
76
+ log_file_path: Path to the log file (should include date in filename).
77
+ backup_count: Number of daily log files to keep (default 30 days).
78
+ """
79
+ global _file_handler
80
+
81
+ # Remove existing file handler if present
82
+ if _file_handler is not None:
83
+ CITRASCOPE_LOGGER.removeHandler(_file_handler)
84
+ _file_handler.close()
85
+ _file_handler = None
86
+
87
+ # Create new file handler with daily rotation
88
+ _file_handler = TimedRotatingFileHandler(
89
+ filename=str(log_file_path),
90
+ when="midnight",
91
+ interval=1,
92
+ backupCount=backup_count,
93
+ encoding="utf-8",
94
+ )
95
+
96
+ # Add filter to exclude web logs
97
+ _file_handler.addFilter(ExcludeWebLogsFilter())
98
+
99
+ # Use plain formatter (no ANSI colors for files)
100
+ plain_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
101
+ _file_handler.setFormatter(plain_formatter)
102
+ _file_handler.setLevel(logging.INFO)
103
+
104
+ # Add to logger
105
+ CITRASCOPE_LOGGER.addHandler(_file_handler)
106
+
107
+
108
+ def get_file_handler() -> Optional[TimedRotatingFileHandler]:
109
+ """Get the current file handler.
110
+
111
+ Returns:
112
+ The file handler if file logging is set up, None otherwise.
113
+ """
114
+ return _file_handler
@@ -0,0 +1,74 @@
1
+ """Log handler that streams logs to web clients via WebSocket."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import deque
6
+ from typing import Optional
7
+
8
+
9
+ class WebLogHandler(logging.Handler):
10
+ """Custom log handler that buffers logs and makes them available to web clients."""
11
+
12
+ def __init__(self, max_logs: int = 1000):
13
+ super().__init__()
14
+ self.log_buffer = deque(maxlen=max_logs)
15
+ self.web_app = None
16
+ self.loop = None
17
+
18
+ def set_web_app(self, web_app, loop=None):
19
+ """Set the web app instance for broadcasting logs."""
20
+ self.web_app = web_app
21
+ self.loop = loop
22
+
23
+ def emit(self, record):
24
+ """Emit a log record."""
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
34
+
35
+ # Get the original levelname without color codes
36
+ # Record.levelname might have ANSI codes from ColoredFormatter
37
+ level = record.levelname
38
+ # Strip ANSI codes from level if present
39
+ import re
40
+
41
+ level = re.sub(r"\x1b\[\d+m", "", level)
42
+
43
+ log_entry = {
44
+ "timestamp": self.format_time(record),
45
+ "level": level,
46
+ "message": record.getMessage(), # Use raw message, not formatted
47
+ "module": record.module,
48
+ }
49
+ self.log_buffer.append(log_entry)
50
+
51
+ # Broadcast to web clients if available
52
+ if self.web_app and hasattr(self.web_app, "broadcast_log") and self.loop:
53
+ # Schedule the broadcast in the web server's event loop
54
+ try:
55
+ if self.loop.is_running():
56
+ asyncio.run_coroutine_threadsafe(self.web_app.broadcast_log(log_entry), self.loop)
57
+ except Exception:
58
+ # Silently fail if we can't broadcast
59
+ pass
60
+
61
+ except Exception:
62
+ self.handleError(record)
63
+
64
+ def format_time(self, record):
65
+ """Format the timestamp."""
66
+ from datetime import datetime
67
+
68
+ return datetime.fromtimestamp(record.created).isoformat()
69
+
70
+ def get_recent_logs(self, limit: Optional[int] = None):
71
+ """Get recent log entries."""
72
+ if limit:
73
+ return list(self.log_buffer)[-limit:]
74
+ return list(self.log_buffer)
@@ -0,0 +1,145 @@
1
+ """CitraScope settings class using JSON-based configuration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional
5
+
6
+ import platformdirs
7
+
8
+ # Application constants for platformdirs
9
+ # Defined before imports to avoid circular dependency
10
+ APP_NAME = "citrascope"
11
+ APP_AUTHOR = "citra-space"
12
+
13
+ from citrascope.logging import CITRASCOPE_LOGGER
14
+ from citrascope.settings.settings_file_manager import SettingsFileManager
15
+
16
+
17
+ class CitraScopeSettings:
18
+ """Settings for CitraScope loaded from JSON configuration file."""
19
+
20
+ def __init__(
21
+ self,
22
+ dev: bool = False,
23
+ log_level: str = "INFO",
24
+ keep_images: bool = False,
25
+ ):
26
+ """Initialize settings from JSON config file.
27
+
28
+ Args:
29
+ dev: If True, use development API endpoint
30
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
31
+ keep_images: If True, preserve captured images
32
+ """
33
+ self.config_manager = SettingsFileManager()
34
+
35
+ # Load configuration from file
36
+ config = self.config_manager.load_config()
37
+
38
+ # Application data directories
39
+ self._images_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR)) / "images"
40
+
41
+ # API Settings
42
+ self.host: str = config.get("host", "dev.api.citra.space" if dev else "api.citra.space")
43
+ self.port: int = config.get("port", 443)
44
+ self.use_ssl: bool = config.get("use_ssl", True)
45
+ self.personal_access_token: str = config.get("personal_access_token", "")
46
+ self.telescope_id: str = config.get("telescope_id", "")
47
+
48
+ # Hardware adapter selection
49
+ self.hardware_adapter: str = config.get("hardware_adapter", "")
50
+
51
+ # Hardware adapter-specific settings stored as dict
52
+ self.adapter_settings: Dict[str, Any] = config.get("adapter_settings", {})
53
+
54
+ # Runtime settings (can be overridden by CLI flags)
55
+ self.log_level: str = log_level if log_level != "INFO" else config.get("log_level", "INFO")
56
+ self.keep_images: bool = keep_images if keep_images else config.get("keep_images", False)
57
+
58
+ # Task retry configuration
59
+ self.max_task_retries: int = config.get("max_task_retries", 3)
60
+ self.initial_retry_delay_seconds: int = config.get("initial_retry_delay_seconds", 30)
61
+ self.max_retry_delay_seconds: int = config.get("max_retry_delay_seconds", 300)
62
+
63
+ # Log file configuration
64
+ self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
65
+ self.log_retention_days: int = config.get("log_retention_days", 30)
66
+
67
+ if dev:
68
+ self.host = "dev.api.citra.space"
69
+ CITRASCOPE_LOGGER.info("Using development API endpoint.")
70
+
71
+ def get_images_dir(self) -> Path:
72
+ """Get the path to the images directory.
73
+
74
+ Returns:
75
+ Path object pointing to the images directory.
76
+ """
77
+ return self._images_dir
78
+
79
+ def ensure_images_directory(self) -> None:
80
+ """Create images directory if it doesn't exist."""
81
+ if not self._images_dir.exists():
82
+ self._images_dir.mkdir(parents=True)
83
+
84
+ def is_configured(self) -> bool:
85
+ """Check if minimum required configuration is present.
86
+
87
+ Returns:
88
+ True if personal_access_token, telescope_id, and hardware_adapter are set.
89
+ """
90
+ return bool(self.personal_access_token and self.telescope_id and self.hardware_adapter)
91
+
92
+ def to_dict(self) -> Dict[str, Any]:
93
+ """Convert settings to dictionary for serialization.
94
+
95
+ Returns:
96
+ Dictionary of all settings.
97
+ """
98
+ return {
99
+ "host": self.host,
100
+ "port": self.port,
101
+ "use_ssl": self.use_ssl,
102
+ "personal_access_token": self.personal_access_token,
103
+ "telescope_id": self.telescope_id,
104
+ "hardware_adapter": self.hardware_adapter,
105
+ "adapter_settings": self.adapter_settings,
106
+ "log_level": self.log_level,
107
+ "keep_images": self.keep_images,
108
+ "max_task_retries": self.max_task_retries,
109
+ "initial_retry_delay_seconds": self.initial_retry_delay_seconds,
110
+ "max_retry_delay_seconds": self.max_retry_delay_seconds,
111
+ "file_logging_enabled": self.file_logging_enabled,
112
+ "log_retention_days": self.log_retention_days,
113
+ }
114
+
115
+ def save(self) -> None:
116
+ """Save current settings to JSON config file."""
117
+ self.config_manager.save_config(self.to_dict())
118
+ CITRASCOPE_LOGGER.info(f"Configuration saved to {self.config_manager.get_config_path()}")
119
+
120
+ @classmethod
121
+ def from_dict(cls, config: Dict[str, Any]) -> "CitraScopeSettings":
122
+ """Create settings instance from dictionary.
123
+
124
+ Args:
125
+ config: Dictionary of configuration values.
126
+
127
+ Returns:
128
+ New CitraScopeSettings instance.
129
+ """
130
+ settings = cls()
131
+ settings.host = config.get("host", settings.host)
132
+ settings.port = config.get("port", settings.port)
133
+ settings.use_ssl = config.get("use_ssl", settings.use_ssl)
134
+ settings.personal_access_token = config.get("personal_access_token", "")
135
+ settings.telescope_id = config.get("telescope_id", "")
136
+ settings.hardware_adapter = config.get("hardware_adapter", "")
137
+ settings.adapter_settings = config.get("adapter_settings", {})
138
+ settings.log_level = config.get("log_level", "INFO")
139
+ settings.keep_images = config.get("keep_images", False)
140
+ settings.max_task_retries = config.get("max_task_retries", 3)
141
+ settings.initial_retry_delay_seconds = config.get("initial_retry_delay_seconds", 30)
142
+ settings.max_retry_delay_seconds = config.get("max_retry_delay_seconds", 300)
143
+ settings.file_logging_enabled = config.get("file_logging_enabled", True)
144
+ settings.log_retention_days = config.get("log_retention_days", 30)
145
+ return settings