Coverage for src/melvonaut/settings.py: 98%
102 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 09:36 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 09:36 +0000
1# Our settings, could be changed later
2import datetime
3import json
4import pathlib
5from json import JSONDecodeError
6from typing import Optional, Any
8from dotenv import load_dotenv
9import os
11from pydantic import BaseModel
13from shared.models import CameraAngle, MELVINTask
14from shared import constants as con
15from loguru import logger
17load_dotenv()
19file_log_handler_id = None
22class Settings(BaseModel):
23 """Startup settings for Melvonaut, can be changed by Melvonaut API."""
25 model_config = {"arbitrary_types_allowed": True}
27 # [Logging]
28 TRACING: bool = bool(os.getenv("TRACING", False))
30 TERMINAL_LOGGING_LEVEL: str = os.getenv("TERMINAL_LOGGING_LEVEL", "INFO").upper()
31 FILE_LOGGING_LEVEL: str = os.getenv("FILE_LOGGING_LEVEL", "INFO").upper()
33 API_PORT: int = int(os.getenv("API_PORT", 8080))
34 DISCORD_WEBHOOK_TOKEN: Optional[str] = os.getenv("DISCORD_WEBHOOK_TOKEN", None)
35 DISCORD_ALERTS_ENABLED: bool = bool(os.getenv("DISCORD_ALERTS_ENABLED", False))
37 NETWORK_SIM_ENABLED: bool = bool(os.getenv("NETWORK_SIMULATION", False))
39 ## [Stateplaning]
40 OBSERVATION_REFRESH_RATE: int = int(
41 os.getenv("OBSERVATION_REFRESH_RATE", 5)
42 ) # Seconds between observation requests
43 BATTERY_LOW_THRESHOLD: int = int(os.getenv("BATTERY_LOW_THRESHOLD", 20))
44 BATTERY_HIGH_THRESHOLD: int = int(
45 os.getenv("BATTERY_HIGH_THRESHOLD", 0)
46 ) # Difference to Max Battery before switching
48 TARGET_ANGLE_DEG: float = float(
49 os.getenv("TARGET_ANGLE_DEG", 23.0)
50 ) # The angle was calculated through simulations
51 # With total speed over 50, cannot use wide angle camera
52 # 49.9 = y + x
53 # x = 2.35585 * y
54 # 49.9 = 2.35585 * y + y
55 # 49.9 = 3.35585 * y
56 # y = 49.9 / 3.35585
57 # y = 14.87
58 # 49.9 - 14.87 = 35.03 = x
59 TARGET_SPEED_NORMAL_X: float = float(
60 os.getenv("TARGET_SPEED_NORMAL_X", 35.03)
61 ) # 2.35585 times as much as Y
62 TARGET_SPEED_NORMAL_Y: float = float(os.getenv("TARGET_SPEED_NORMAL_Y", 14.87))
64 # With total speed over 10, cannot use narrow angle camera
65 # 9.9 = y + x
66 # y = 9.9 / 3.35585
67 # y = 2.95
68 # 9.9 - 2.95 = 6.95 = x
69 TARGET_SPEED_NARROW_X: float = float(os.getenv("TARGET_SPEED_NARROW_X", 6.95))
70 TARGET_SPEED_NARROW_Y: float = float(os.getenv("TARGET_SPEED_NARROW_Y", 2.95))
72 # Total speed can be up to 71
73 # 71 = y + x
74 # y = 71 / 3.35585
75 # y = 21.16
76 # 71 - 21.16 = 49.84 = x
77 TARGET_SPEED_WIDE_X: float = float(os.getenv("TARGET_SPEED_WIDE_X", 49.84))
78 TARGET_SPEED_WIDE_Y: float = float(os.getenv("TARGET_SPEED_WIDE_Y", 21.16))
80 DISTANCE_BETWEEN_IMAGES: int = int(
81 os.getenv("DISTANCE_BETWEEN_IMAGES", 450)
82 ) # How many pixel before taking another image
84 # [Melvin Task Planing]
85 # Standard mapping, with no objectives and the camera angle below
86 CURRENT_MELVIN_TASK: MELVINTask = MELVINTask.Mapping
87 TARGET_CAMERA_ANGLE_ACQUISITION: CameraAngle = CameraAngle.Narrow
89 # Automatically do the next upcoming objective
90 # CURRENT_MELVIN_TASK: MELVINTasks = MELVINTasks.Next_objective
92 # Do a specific objective
93 # CURRENT_MELVIN_TASK: MELVINTasks = MELVINTasks.Fixed_objective
94 # FIXED_OBJECTIVE = "Aurora 10"
96 # Go for the emergency beacon tracker
97 # CURRENT_MELVIN_TASK: MELVINTask = MELVINTask.EBT
99 # [Legacy]
100 # To set a custom time window to be active, or to disable all timing checks
101 DO_TIMING_CHECK: bool = False
102 START_TIME: datetime.datetime = datetime.datetime(
103 2025, 1, 2, 12, 00, tzinfo=datetime.timezone.utc
104 )
105 STOP_TIME: datetime.datetime = datetime.datetime(
106 2025, 1, 30, 12, 00, tzinfo=datetime.timezone.utc
107 )
108 DO_ACTUALLY_EXIT: bool = True # Used in testing
109 OVERRIDES: dict[str, Any] = {}
111 # load settings
112 def load_settings(self) -> None:
113 """Loads settings from a persistent JSON file.
115 If the settings file does not exist or contains invalid JSON,
116 the overrides dictionary is reset to an empty state.
117 """
118 if not pathlib.Path(con.MEL_PERSISTENT_SETTINGS).exists(): 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 logger.debug("Settings don't exist")
120 self.OVERRIDES = {}
121 with open(con.MEL_PERSISTENT_SETTINGS, "r") as f:
122 try:
123 loaded = json.loads(f.read())
124 except JSONDecodeError:
125 logger.warning("Failed to load settings")
126 self.OVERRIDES = {}
127 return
128 # logger.debug(f"{loaded=}")
129 for key, value in loaded.items():
130 self.OVERRIDES[key.upper()] = value
131 # logger.debug(f"{self.OVERRIDES=}")
133 # save settings
134 def save_settings(self) -> None:
135 """Saves the current settings overrides to a persistent JSON file."""
136 with open(con.MEL_PERSISTENT_SETTINGS, "w") as f:
137 f.write(json.dumps(self.OVERRIDES))
139 # get settings
140 def get_setting(self, key: str, default: Any = None) -> Any:
141 """Retrieves a setting value from overrides or returns the default.
143 Args:
144 key (str): The setting key to retrieve.
145 default (Any, optional): The default value if the key is not found. Defaults to None.
147 Returns:
148 Any: The value of the setting if it exists, otherwise the default.
149 """
150 return self.OVERRIDES.get(key.upper(), default)
152 # set setting
153 def set_setting(self, key: str, value: Any) -> None:
154 """Sets a single setting in overrides and saves the settings.
156 Args:
157 key (str): The setting key.
158 value (Any): The value to assign to the setting.
159 """
160 # logger.debug(f"Setting {key.upper()} to {value}")
161 # logger.debug(f"{self.OVERRIDES=}")
162 self.OVERRIDES[key.upper()] = value
163 # logger.debug(f"{self.OVERRIDES=}")
164 self.save_settings()
166 def set_settings(self, key_values: dict[str, Any]) -> None:
167 """Sets multiple settings at once and saves them.
169 Args:
170 key_values (dict[str, Any]): A dictionary of key-value pairs to update.
171 """
172 # logger.debug(f"Setting {self.OVERRIDES}")
173 if len(key_values.keys()) == 0:
174 logger.debug("Clearing settings")
175 self.set_setting("OVERRIDES", {})
176 else:
177 for key, value in key_values.items():
178 self.set_setting(key, value)
179 # logger.debug(f"Setting {self.OVERRIDES}")
180 self.save_settings()
182 def delete_settings(self, keys: list[str]) -> None:
183 """Deletes specified settings from overrides and saves the settings.
185 Args:
186 keys (list[str]): A list of setting keys to remove.
187 """
188 # logger.debug(f"Deleting {keys}")
189 for key in keys:
190 del self.OVERRIDES[key.upper()]
191 # logger.debug(f"{self.OVERRIDES=}")
192 self.save_settings()
194 def init_settings(self) -> bool:
195 """Initializes settings by checking for an existing settings file.
197 Returns:
198 bool: True if settings were newly created, False if they already exist.
199 """
200 if pathlib.Path(con.MEL_PERSISTENT_SETTINGS).exists():
201 logger.debug("Settings already exist")
202 return False
203 logger.debug("Settings created")
204 self.save_settings()
205 return True
207 # clear settings
208 def clear_settings(self) -> None:
209 """Clears all settings by setting overrides to None and saving."""
210 self.OVERRIDES = None # type: ignore
211 # logger.debug(f"{self.OVERRIDES=}")
213 def get_default_setting(self, key: str) -> Any:
214 """Retrieves the default value of a setting from the class attributes.
216 Args:
217 key (str): The setting key to retrieve.
219 Returns:
220 Any: The default value of the setting.
221 """
222 return super().__getattribute__(key)
224 def __init__(self) -> None:
225 """Initializes the Settings object, loading settings if they exist."""
226 super().__init__()
227 if not self.init_settings():
228 self.load_settings()
230 def __getattribute__(self, item: str) -> Any:
231 """Overrides attribute access to check overrides before default settings.
233 Args:
234 item (str): The attribute key to retrieve.
236 Returns:
237 Any: The overridden value if it exists, otherwise the default.
238 """
239 if item.startswith("__"):
240 return super().__getattribute__(item)
241 # logger.debug(f"Getting {item}")
242 overrides = super().__getattribute__("OVERRIDES")
243 if item.upper() in overrides:
244 return overrides[item.upper()]
245 return super().__getattribute__(item)
247 def __setattr__(self, key: str, value: Any) -> None:
248 """Overrides attribute setting to ensure settings are properly stored.
250 Args:
251 key (str): The setting key.
252 value (Any): The value to assign to the setting.
253 """
254 # logger.debug(f"Setting {key} to {value}")
255 if key == "OVERRIDES" and value is None:
256 self.OVERRIDES.clear()
257 self.save_settings()
258 elif type(value) is dict:
259 self.set_settings(value)
260 else:
261 self.set_setting(key.upper(), value)
264settings = Settings()