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

1# Our settings, could be changed later 

2import datetime 

3import json 

4import pathlib 

5from json import JSONDecodeError 

6from typing import Optional, Any 

7 

8from dotenv import load_dotenv 

9import os 

10 

11from pydantic import BaseModel 

12 

13from shared.models import CameraAngle, MELVINTask 

14from shared import constants as con 

15from loguru import logger 

16 

17load_dotenv() 

18 

19file_log_handler_id = None 

20 

21 

22class Settings(BaseModel): 

23 """Startup settings for Melvonaut, can be changed by Melvonaut API.""" 

24 

25 model_config = {"arbitrary_types_allowed": True} 

26 

27 # [Logging] 

28 TRACING: bool = bool(os.getenv("TRACING", False)) 

29 

30 TERMINAL_LOGGING_LEVEL: str = os.getenv("TERMINAL_LOGGING_LEVEL", "INFO").upper() 

31 FILE_LOGGING_LEVEL: str = os.getenv("FILE_LOGGING_LEVEL", "INFO").upper() 

32 

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)) 

36 

37 NETWORK_SIM_ENABLED: bool = bool(os.getenv("NETWORK_SIMULATION", False)) 

38 

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 

47 

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)) 

63 

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)) 

71 

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)) 

79 

80 DISTANCE_BETWEEN_IMAGES: int = int( 

81 os.getenv("DISTANCE_BETWEEN_IMAGES", 450) 

82 ) # How many pixel before taking another image 

83 

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 

88 

89 # Automatically do the next upcoming objective 

90 # CURRENT_MELVIN_TASK: MELVINTasks = MELVINTasks.Next_objective 

91 

92 # Do a specific objective 

93 # CURRENT_MELVIN_TASK: MELVINTasks = MELVINTasks.Fixed_objective 

94 # FIXED_OBJECTIVE = "Aurora 10" 

95 

96 # Go for the emergency beacon tracker 

97 # CURRENT_MELVIN_TASK: MELVINTask = MELVINTask.EBT 

98 

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] = {} 

110 

111 # load settings 

112 def load_settings(self) -> None: 

113 """Loads settings from a persistent JSON file. 

114 

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=}") 

132 

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)) 

138 

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. 

142 

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. 

146 

147 Returns: 

148 Any: The value of the setting if it exists, otherwise the default. 

149 """ 

150 return self.OVERRIDES.get(key.upper(), default) 

151 

152 # set setting 

153 def set_setting(self, key: str, value: Any) -> None: 

154 """Sets a single setting in overrides and saves the settings. 

155 

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() 

165 

166 def set_settings(self, key_values: dict[str, Any]) -> None: 

167 """Sets multiple settings at once and saves them. 

168 

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() 

181 

182 def delete_settings(self, keys: list[str]) -> None: 

183 """Deletes specified settings from overrides and saves the settings. 

184 

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() 

193 

194 def init_settings(self) -> bool: 

195 """Initializes settings by checking for an existing settings file. 

196 

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 

206 

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=}") 

212 

213 def get_default_setting(self, key: str) -> Any: 

214 """Retrieves the default value of a setting from the class attributes. 

215 

216 Args: 

217 key (str): The setting key to retrieve. 

218 

219 Returns: 

220 Any: The default value of the setting. 

221 """ 

222 return super().__getattribute__(key) 

223 

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() 

229 

230 def __getattribute__(self, item: str) -> Any: 

231 """Overrides attribute access to check overrides before default settings. 

232 

233 Args: 

234 item (str): The attribute key to retrieve. 

235 

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) 

246 

247 def __setattr__(self, key: str, value: Any) -> None: 

248 """Overrides attribute setting to ensure settings are properly stored. 

249 

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) 

262 

263 

264settings = Settings()