Repository: proveskit/pysquared
Pull request #229: Built the validation side of overwriting config 
Author: login: Alandlt15
Created: 2025-04-09T14:49:08Z | Merged: 2025-05-12T17:34:40Z

## Summary
### Validation Logic Overview:
1. Checks if the value wanting to be changed is actually allowed to be changed (otherwise raises KeyError)
2. If true, checks that the value's type matches the expected type (otherwise raises TypeError)
3. If true, checks that the value is in a range (min >= & <= max) (otherwise raises ValueError)
4. Lastly, checks if its a temporary or permanent change

### Tests:
I created unit tests to make sure validation logic worked as expected:

<img width="619" alt="image" src="https://github.com/user-attachments/assets/05b7b2f0-56d0-4a8a-9be5-ba1201a0b586" />


### Board logs:
How I tested on the board, (correct me if I'm wrong or if there's a better way):
1. Ran `make build` in pysquared repo
2. Ran `make install` in v4 repo 
3. From pysquared repo, I copied contents of pysquared dir within artifacts dir
4. Pasted contents inside pysquared dir within lib on the board

I'm not too sure if the tca error is normal in the logs below, however everything else seems to be running fine:
<img width="868" alt="image" src="https://github.com/user-attachments/assets/8dd0f675-d0f7-4a2e-b550-433c41352a71" />

### Final Note:
When one runs `make test` the tests change the config.json file, so I set the permanent updates to change config.test.json for now.

## How was this tested
- [x] Added new unit tests
- [x] Ran code on hardware (screenshots are helpful)
- [ ] Other (Please describe)



Diff:
diff --git a/pysquared/cdh.py b/pysquared/cdh.py
index 1f96fb37..56185d13 100644
--- a/pysquared/cdh.py
+++ b/pysquared/cdh.py
@@ -38,6 +38,7 @@ def __init__(
         self._joke_reply: list[str] = config.joke_reply
         self._super_secret_code: bytes = config.super_secret_code.encode("utf-8")
         self._repeat_code: bytes = config.repeat_code.encode("utf-8")
+        self.config = config
         self._log.info(
             "The satellite has a super secret code!",
             super_secret_code=str(self._super_secret_code),
@@ -165,3 +166,32 @@ def query(self, cubesat: Satellite, args: str) -> None:
     def exec_cmd(self, cubesat: Satellite, args: str) -> None:
         self._log.info("Executing command", args=args)
         exec(args)
+
+    def update_config(self, cubesat: Satellite, args: str) -> None:
+        # update these values with args
+        temporary: bool = False
+        key: str = ""
+        value = ""
+
+        """
+        1. KeyError:
+            Occurs when trying to access a dictionary element using a key that does not exist.
+            - Can use this for when trying to change a value that isn't in config
+
+        2. TypeError:
+            Occurs when an operation or function is applied to an object of an inappropriate type, like adding a number to a string.
+            - Can use this for when a value is of wrong type
+
+        3. ValueError:
+            Signifies that a function received an argument of the correct type but an inappropriate value, like trying to convert "abc" to an integer.
+            - Can use this for when a value is not in range in schema
+        """
+        try:
+            self.config.update_config(key, value, temporary)
+            self._log.info("Updated config value successfully")
+        except KeyError as e:
+            self._log.error("Value not in config or immutable", e)
+        except TypeError as e:
+            self._log.error("Value type incorrect", e)
+        except ValueError as e:
+            self._log.error("Value not in acceptable range", e)
diff --git a/pysquared/config/config.py b/pysquared/config/config.py
index cd78b3e7..89e110ed 100644
--- a/pysquared/config/config.py
+++ b/pysquared/config/config.py
@@ -4,7 +4,11 @@
 that use them. Instantiation happens in main.
 
 Also it allow values to be set temporarily or permanently using the
-Attempting to follow the FPrime model.
+update_config method. Validation occurs before the update.
+
+It is assumed the key and value to update are not empty when the
+funciton is called. Also, it's important not to use the same key for
+different values in the config file.
 """
 
 import json
@@ -14,13 +18,13 @@
 
 class Config:
     def __init__(self, config_path: str) -> None:
+        self.config_file = config_path
         # parses json & assigns data to variables
-        with open(config_path, "r") as f:
+        with open(self.config_file, "r") as f:
             json_data = json.loads(f.read())
 
         self.radio: RadioConfig = RadioConfig(json_data["radio"])
         self.cubesat_name: str = json_data["cubesat_name"]
-        self.last_battery_temp: float = json_data["last_battery_temp"]
         self.sleep_duration: int = json_data["sleep_duration"]
         self.detumble_enable_z: bool = json_data["detumble_enable_z"]
         self.detumble_enable_x: bool = json_data["detumble_enable_x"]
@@ -34,9 +38,166 @@ def __init__(self, config_path: str) -> None:
         self.normal_charge_current: float = json_data["normal_charge_current"]
         self.normal_battery_voltage: float = json_data["normal_battery_voltage"]
         self.critical_battery_voltage: float = json_data["critical_battery_voltage"]
-        self.current_draw: float = json_data["current_draw"]
         self.reboot_time: int = json_data["reboot_time"]
         self.turbo_clock: bool = json_data["turbo_clock"]
         self.super_secret_code: str = json_data["super_secret_code"]
         self.repeat_code: str = json_data["repeat_code"]
         self.joke_reply: list[str] = json_data["joke_reply"]
+
+        self.CONFIG_SCHEMA = {
+            "cubesat_name": {"type": str, "min_length": 1, "max_length": 10},
+            "super_secret_code": {"type": bytes, "min": 1, "max": 24},
+            "repeat_code": {"type": bytes, "min": 1, "max": 4},
+            "normal_charge_current": {"type": float, "min": 0.0, "max": 2000.0},
+            "normal_battery_voltage": {"type": float, "min": 6.0, "max": 8.4},
+            "critical_battery_voltage": {"type": float, "min": 5.4, "max": 7.2},
+            "sleep_duration": {"type": int, "min": 1, "max": 86400},
+            "normal_temp": {"type": int, "min": 5, "max": 40},
+            "normal_battery_temp": {"type": int, "min": 1, "max": 35},
+            "normal_micro_temp": {"type": int, "min": 1, "max": 50},
+            "reboot_time": {"type": int, "min": 3600, "max": 604800},
+            "detumble_enable_z": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_x": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_y": {"type": bool, "allowed_values": [True, False]},
+            "debug": {"type": bool, "allowed_values": [True, False]},
+            "heating": {"type": bool, "allowed_values": [True, False]},
+            "turbo_clock": {"type": bool, "allowed_values": [True, False]},
+        }
+
+        self.RADIO_SCHEMA = {
+            "license": {"type": bool, "allowed_values": [True, False]},
+            "receiver_id": {"type": int, "min": 0, "max": 255},
+            "sender_id": {"type": int, "min": 0, "max": 255},
+            "start_time": {"type": int, "min": 0, "max": 80000},
+            "transmit_frequency": {
+                "type": float,
+                "min0": 435,
+                "max0": 438.0,
+                "min1": 915.0,
+                "max1": 915.0,
+            },
+        }
+
+        self.FSK_SCHEMA = {
+            "ack_delay": {"type": float, "min": 0.0, "max": 2.0},
+            "coding_rate": {"type": int, "min": 4, "max": 8},
+            "cyclic_redundancy_check": {"type": bool, "allowed_values": [True, False]},
+            "max_output": {"type": bool, "allowed_values": [True, False]},
+            "spreading_factor": {"type": int, "min": 6, "max": 12},
+            "transmit_power": {"type": int, "min": 5, "max": 23},
+        }
+
+        self.LORA_SCHEMA = {
+            "broadcast_address": {"type": int, "min": 0, "max": 255},
+            "node_address": {"type": int, "min": 0, "max": 255},
+            "modulation_type": {"type": int, "min": 0, "max": 1},
+        }
+
+    # validates values from input
+    def _validate(self, key: str, value) -> None:
+        # first checks if key is actually part of config/radio dict
+        if key in self.CONFIG_SCHEMA:
+            schema = self.CONFIG_SCHEMA[key]
+
+        elif key in self.RADIO_SCHEMA:
+            schema = self.RADIO_SCHEMA[key]
+
+        elif key in self.FSK_SCHEMA:
+            schema = self.FSK_SCHEMA[key]
+
+        elif key in self.LORA_SCHEMA:
+            schema = self.LORA_SCHEMA[key]
+
+        else:
+            raise KeyError
+
+        expected_type = schema["type"]
+
+        # checks value is of same type; also covers bools
+        if not isinstance(value, expected_type):
+            raise TypeError
+
+        # checks int, float, and bytes range
+        if isinstance(value, (int, float, bytes)):
+            if "min" in schema and value < schema["min"]:
+                raise ValueError
+            if "max" in schema and value > schema["max"]:
+                raise ValueError
+
+            # specific to transmit_frequency
+            if key == "transmit_frequency":
+                if "min0" in schema and value < schema["min0"]:
+                    raise ValueError
+                if "max1" in schema and value > schema["max1"]:
+                    raise ValueError
+                if (
+                    "max0" in schema
+                    and value > schema["max0"]
+                    and "min1" in schema
+                    and value < schema["min1"]
+                ):
+                    raise ValueError
+
+        # checks string range
+        else:
+            # isinstance(value, str):
+            if "min_length" in schema and len(value) < schema["min_length"]:
+                raise ValueError
+            if "max_length" in schema and len(value) > schema["max_length"]:
+                raise ValueError
+
+    # permanently updates values
+    def _save_config(self, key: str, value) -> None:
+        with open(self.config_file, "r") as f:
+            json_data = json.loads(f.read())
+
+        json_data[key] = value
+
+        with open(self.config_file, "w") as f:
+            f.write(json.dumps(json_data))
+
+    # handles temp or permanent updates
+    def update_config(self, key: str, value, temporary: bool) -> None:
+        # validates key and value and should raise error if any
+        self._validate(key, value)
+
+        if key in self.CONFIG_SCHEMA:
+            # if permanent, saves to config
+            if not temporary:
+                self._save_config(key, value)
+            # updates RAM
+            setattr(self, key, value)
+
+        elif key in self.RADIO_SCHEMA:
+            # if permanent, saves to config
+            if not temporary:
+                with open(self.config_file, "r") as f:
+                    json_data = json.loads(f.read())
+                json_data["radio"][key] = value
+                with open(self.config_file, "w") as f:
+                    f.write(json.dumps(json_data))
+            # updates RAM
+            setattr(self.radio, key, value)
+
+        elif key in self.FSK_SCHEMA:
+            # if permanent, saves to config
+            if not temporary:
+                with open(self.config_file, "r") as f:
+                    json_data = json.loads(f.read())
+                json_data["radio"]["fsk"][key] = value
+                with open(self.config_file, "w") as f:
+                    f.write(json.dumps(json_data))
+            # updates RAM
+            setattr(self.radio.fsk, key, value)
+
+        else:
+            # key is in self.LORA_SCHEMA
+            # if permanent, saves to config
+            if not temporary:
+                with open(self.config_file, "r") as f:
+                    json_data = json.loads(f.read())
+                json_data["radio"]["lora"][key] = value
+                with open(self.config_file, "w") as f:
+                    f.write(json.dumps(json_data))
+            # updates RAM
+            setattr(self.radio.lora, key, value)
diff --git a/pysquared/functions.py b/pysquared/functions.py
index bbc650b8..5c7abd50 100755
--- a/pysquared/functions.py
+++ b/pysquared/functions.py
@@ -60,7 +60,7 @@ def __init__(
 
         self.cubesat_name: str = config.cubesat_name
         self.jokes: list[str] = config.jokes
-        self.last_battery_temp: float = config.last_battery_temp
+        # self.last_battery_temp: float = config.last_battery_temp
         self.sleep_duration: int = config.sleep_duration
 
     """
@@ -128,15 +128,15 @@ def state_of_health(self) -> None:
         try:
             self.state_list: list[str] = [
                 f"PM:{self.cubesat.power_mode}",
-                f"VB:{self.cubesat.battery_voltage}",
-                f"ID:{self.cubesat.current_draw}",
+                # f"VB:{self.cubesat.battery_voltage}",
+                # f"ID:{self.cubesat.current_draw}",
                 f"IC:{self.cubesat.charge_current}",
                 f"UT:{self.cubesat.get_system_uptime}",
                 f"BN:{self.cubesat.boot_count.get()}",
                 f"MT:{microcontroller.cpu.temperature}",
                 f"RT:{self.radio.get_temperature()}",
                 f"AT:{self.imu.get_temperature()}",
-                f"BT:{self.last_battery_temp}",
+                # f"BT:{self.last_battery_temp}",
                 f"EC:{self.logger.get_error_count()}",
                 f"AB:{int(self.cubesat.f_burned.get())}",
                 f"BO:{int(self.cubesat.f_brownout.get())}",
diff --git a/pysquared/satellite.py b/pysquared/satellite.py
index cc134dfa..6b209f92 100644
--- a/pysquared/satellite.py
+++ b/pysquared/satellite.py
@@ -57,7 +57,7 @@ def __init__(
         self.normal_charge_current: float = config.normal_charge_current
         self.normal_battery_voltage: float = config.normal_battery_voltage
         self.critical_battery_voltage: float = config.critical_battery_voltage
-        self.current_draw: float = config.current_draw
+        # self.current_draw: float = config.current_draw
         self.reboot_time: int = config.reboot_time
         self.turbo_clock: bool = config.turbo_clock
         self.cubesat_name: str = config.cubesat_name
@@ -69,7 +69,8 @@ def __init__(
         # These are here because of problems integrating the battery board on Orphues.
         # They should be moved to the battery board in the future.
         # They are only used in state of health
-        self.battery_voltage: Optional[float] = None
+
+        # self.battery_voltage: Optional[float] = None
         self.charge_current: Optional[float] = None
         self.power_mode: str = "normal"
 
diff --git a/tests/unit/files/config.test.json b/tests/unit/files/config.test.json
index 8a170428..24099fab 100644
--- a/tests/unit/files/config.test.json
+++ b/tests/unit/files/config.test.json
@@ -1,13 +1,47 @@
 {
-  "cubesat_name": "Test Satellite",
+  "cubesat_name": "Orpheus",
   "last_battery_temp": 20.0,
   "sleep_duration": 30,
   "detumble_enable_z": true,
-  "detumble_enable_x": true,
+  "detumble_enable_x":true,
   "detumble_enable_y": true,
   "jokes": [
-    "Hey it is pretty cold up here, did someone forget to pay the electric bill?"
-  ],
+          "Hey it is pretty cold up here, did someone forget to pay the electric bill?",
+          "sudo rf - rf*",
+          "Why did the astronaut break up with his girlfriend? He needed space.",
+          "Why did the sun go to school? To get a little brighter.",
+          "why is the mall called the mall? because instead of going to one store you go to them all",
+          "Alien detected. Blurring photo...",
+          "Wait it is all open source? Always has been... www.github.com/proveskit",
+          "What did 0 say to 1? You're a bit too much.",
+          "Pleiades - Orpheus has been recently acquired by the Onion News Network",
+          "This jokesat was brought to you by the Bronco Space Ministry of Labor and Job Placement",
+          "Catch you on the next pass!",
+          "Pleiades - Orpheus was not The Impostor",
+          "Sorry for messing with your long-exposure astrophoto!",
+          "Better buy a telescope. Wanna see me. Buy a telescope. Gonna be in space.",
+          "According to all known laws of aviation, there is no way bees should be able to fly...",
+          "You lost the game ",
+          "Bobby Tables is a good friend of mine",
+          "Why did the computer cross the road? To get a byte to eat!",
+          "Why are the astronauts not hungry when they got to space? They had a big launch.",
+          "Why did the computer get glasses? To improve its web sight!",
+          "What are computers favorite snacks? Chips!",
+          "Wait! I think I see a White 2019 Subaru Crosstrek 2.0i Premium",
+          "IS THAT A SUPRA?!",
+          "Finally escpaed the LA Traffic",
+          "My CubeSat is really good at jokes, but its delivery is always delayed.",
+          "exec order 66",
+          "I had a joke about UDP, but I am not sure if you'd get it.",
+          "I am not saying FSK modulation is the best way to send jokes, but at least it is never monotone!",
+          "I am sorry David, I am afrain I can not do that.",
+          "My memory is volatile like RAM, so it only makes sense that I forget things.",
+          "Imagine it gets stuck and just keeps repeating this joke every 2 mins",
+          "Check Engine: Error Code 404: Joke Not Found",
+          "CQ CQ KN6NAQ ... KN6NAT are you out there?",
+          "Woah is that the Launcher Orbiter?????",
+          "Everything in life is a spring if you think hard enough!"
+      ],
   "debug": true,
   "heating": false,
   "normal_temp": 20,
@@ -16,32 +50,37 @@
   "normal_charge_current": 0.5,
   "normal_battery_voltage": 6.9,
   "critical_battery_voltage": 6.6,
-  "battery_voltage": 3.3,
+  "battery_voltage": 5.2,
   "current_draw": 240.5,
   "reboot_time": 3600,
   "turbo_clock": false,
   "radio": {
-    "license": "",
-    "sender_id": 251,
-    "receiver_id": 250,
-    "transmit_frequency": 437,
-    "start_time": 80000,
-    "fsk": {
-      "broadcast_address": 255,
-      "node_address": 1,
-      "modulation_type": 0
-    },
-    "lora": {
-      "ack_delay": 0.2,
-      "coding_rate": 8,
-      "cyclic_redundancy_check": true,
-      "spreading_factor": 8,
-      "transmit_power": 23
-    }
+      "license": "",
+      "receiver_id": 250,
+      "sender_id": 251,
+      "start_time": 80000,
+      "transmit_frequency": 437.4,
+      "lora": {
+          "ack_delay": 0.2,
+          "coding_rate": 8,
+          "cyclic_redundancy_check": true,
+          "max_output": true,
+          "spreading_factor": 8,
+          "transmit_power": 23
+      },
+      "fsk": {
+          "broadcast_address": 255,
+          "node_address": 1,
+          "modulation_type": 0
+      }
   },
   "super_secret_code": "ABCD",
   "repeat_code": "RP",
   "joke_reply": [
-    "Your Mom"
-  ]
-}
+          "Your Mom",
+          "Your Mum",
+          "Your Face",
+          "not True lol",
+          "I have brought peace, freedom, justice, and security to my new empire! Your New Empire?"
+      ]
+  }
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 62823298..8eb43cc6 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -1,13 +1,27 @@
 import json
 import os
+import tempfile
+
+import pytest
 
 from pysquared.config.config import Config
 
-os.path.dirname(__file__)
-file = f"{os.path.dirname(__file__)}/files/config.test.json"
 
+@pytest.fixture(autouse=True)
+def cleanup():
+    temp_dir = tempfile.mkdtemp()
+    file = os.path.join(temp_dir, "config.test.json")
+
+    source_file = f"{os.path.dirname(__file__)}/files/config.test.json"
+    with open(source_file, "r") as src, open(file, "w") as dest:
+        dest.write(src.read())
+
+    yield file
+    os.remove(file)
 
-def test_radio_cfg() -> None:
+
+def test_radio_cfg(cleanup) -> None:
+    file = cleanup
     with open(file, "r") as f:
         json_data = json.loads(f.read())
 
@@ -59,7 +73,8 @@ def test_radio_cfg() -> None:
     ), "No match for: lora.transmit_power"
 
 
-def test_strings() -> None:
+def test_strings(cleanup) -> None:
+    file = cleanup
     with open(file, "r") as f:
         json_data = json.loads(f.read())
 
@@ -74,7 +89,8 @@ def test_strings() -> None:
     assert config.repeat_code == json_data["repeat_code"], "No match for: repeat_code"
 
 
-def test_ints() -> None:
+def test_ints(cleanup) -> None:
+    file = cleanup
     with open(file, "r") as f:
         json_data = json.loads(f.read())
 
@@ -93,15 +109,13 @@ def test_ints() -> None:
     assert config.reboot_time == json_data["reboot_time"], "No match for: reboot_time"
 
 
-def test_floats() -> None:
+def test_floats(cleanup) -> None:
+    file = cleanup
     with open(file, "r") as f:
         json_data = json.loads(f.read())
 
     config = Config(file)
 
-    assert (
-        config.last_battery_temp == json_data["last_battery_temp"]
-    ), "No match for: last_battery_temp"
     assert (
         config.normal_charge_current == json_data["normal_charge_current"]
     ), "No match for: normal_charge_current"
@@ -111,12 +125,10 @@ def test_floats() -> None:
     assert (
         config.critical_battery_voltage == json_data["critical_battery_voltage"]
     ), "No match for: critical_battery_voltage"
-    assert (
-        config.current_draw == json_data["current_draw"]
-    ), "No match for: current_draw"
 
 
-def test_bools() -> None:
+def test_bools(cleanup) -> None:
+    file = cleanup
     with open(file, "r") as f:
         json_data = json.loads(f.read())
 
@@ -134,3 +146,150 @@ def test_bools() -> None:
     assert config.debug == json_data["debug"], "No match for: debug"
     assert config.heating == json_data["heating"], "No match for: heating"
     assert config.turbo_clock == json_data["turbo_clock"], "No match for: turbo_clock"
+
+
+def test_validation_updateable(cleanup) -> None:
+    file = cleanup
+    config = Config(file)
+
+    # raises KeyError
+    with pytest.raises(KeyError):
+        config._validate("not_in_config", "trash")
+
+    # config
+    try:
+        config._validate("cubesat_name", "maia")
+    except KeyError as e:
+        print(e)
+
+    # radio
+    try:
+        config._validate("receiver_id", 11)
+    except KeyError as e:
+        print(e)
+
+    # fsk
+    try:
+        config._validate("ack_delay", 1.5)
+    except KeyError as e:
+        print(e)
+
+    # lora
+    try:
+        config._validate("node_address", 11)
+    except KeyError as e:
+        print(e)
+
+
+def test_validation_type(cleanup) -> None:
+    file = cleanup
+    config = Config(file)
+
+    # raises TypeError
+    with pytest.raises(TypeError):
+        config._validate("cubesat_name", 1)
+
+    # config
+    try:
+        config._validate("cubesat_name", "maia")
+    except KeyError as e:
+        print(e)
+
+
+def test_validation_range(cleanup) -> None:
+    file = cleanup
+    config = Config(file)
+
+    # normal config
+    with pytest.raises(ValueError):
+        config._validate("cubesat_name", "")
+    with pytest.raises(ValueError):
+        config._validate("cubesat_name", "more_than_seven")
+
+    # radio config
+    with pytest.raises(ValueError):
+        config._validate("receiver_id", -1)
+    with pytest.raises(ValueError):
+        config._validate("receiver_id", 256)
+
+    # transmit_frequency specific
+    with pytest.raises(ValueError):
+        config._validate("transmit_frequency", 0.0)
+    with pytest.raises(ValueError):
+        config._validate("transmit_frequency", 500.0)
+    with pytest.raises(ValueError):
+        config._validate("transmit_frequency", 916.0)
+    try:
+        config._validate("transmit_frequency", 436.0)
+    except ValueError as e:
+        print(e)
+
+    with pytest.raises(ValueError):
+        config._validate("cubesat_name", "more_than_10____")
+
+    with pytest.raises(ValueError):
+        config._validate("cubesat_name", "more_than_10____")
+
+    # config
+    try:
+        config._validate("cubesat_name", "accept")
+    except ValueError as e:
+        print(e)
+
+
+def test_save_config(cleanup) -> None:
+    file = cleanup
+    config = Config(file)
+    try:
+        config._save_config("cubesat_name", "accept")
+    except ValueError as e:
+        print(e)
+
+
+def test_update_config(cleanup) -> None:
+    file = cleanup
+    config = Config(file)
+
+    # config temp
+    try:
+        config.update_config("cubesat_name", "accept", False)
+    except ValueError as e:
+        print(e)
+    # config permanent
+    try:
+        config.update_config("cubesat_name", "accept", True)
+    except ValueError as e:
+        print(e)
+
+    # radio temp
+    try:
+        config.update_config("receiver_id", 1, False)
+    except ValueError as e:
+        print(e)
+    # radio permanent
+    try:
+        config.update_config("receiver_id", 1, True)
+    except ValueError as e:
+        print(e)
+
+    # fsk temp
+    try:
+        config.update_config("ack_delay", 1.0, False)
+    except ValueError as e:
+        print(e)
+    # fsk permanent
+    try:
+        config.update_config("ack_delay", 1.0, True)
+    except ValueError as e:
+        print(e)
+
+    # lora temp
+    try:
+        config.update_config("broadcast_address", 1, False)
+    except ValueError as e:
+        print(e)
+    # lora permanent
+    try:
+        config.update_config("broadcast_address", 1, True)
+    except ValueError as e:
+        print(e)


Reviews:
user: login: hrfarmer
body: Comments in the review are just a few small things I noticed.

Also just want to point out that right now from what I can tell, if two schemas were to have keys with the same name, the logic for finding the key in a schema would fall apart. Not sure if this is something that *needs* to be addressed currently (since there isn't any shared keys, and there probably won't be), but I think it's still something to keep in mind. There could probably be a comment put somewhere so we have it in mind in the future.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T00:54:20Z
user: login: Alandlt15
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T01:31:25Z
user: login: Alandlt15
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T01:36:14Z
user: login: hrfarmer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T01:49:24Z
user: login: Alandlt15
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T02:06:15Z
user: login: hrfarmer
body: Thanks @Mikefly123 @Alandlt15 for the info, was able to get past the read-only error like that. 

However @Alandlt15 I apologize because I misled you earlier, apparently CircuitPython's implementation of the `json` library and `dumps()` [doesn't actually support the indent argument](https://docs.circuitpython.org/en/latest/docs/library/json.html)
<img width="700" alt="image" src="https://github.com/user-attachments/assets/8193c48d-de79-4e14-8454-3434bf4dbab8" />

I tested with removing the argument and it saves to the board & reads the file fine, it just destroys the formatting and writes to a single line. Unless there's another easy way to go about preserving formatting, it'll probably be fine to save without the formatting since users won't be interacting with the config file during runtime.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-07T21:32:21Z
user: login: hrfarmer
body: LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T19:02:20Z

Review comments:
diff_hunk: @@ -14,13 +14,13 @@
 
 class Config:
     def __init__(self, config_path: str) -> None:
+        self.temp_config_file = "tests/unit/files/config.test.json"
path: pysquared/config/config.py
user: login: hrfarmer
body: Probably fine for testing for now, but once this is actually merged this should be changed to `self.config_file` and be assigned to the passed in `config_path`, and any references to `self.temp_config_file` should be changed accordingly.
created_at: 2025-04-29T23:05:48Z
author_association: MEMBER
diff_hunk: @@ -34,9 +34,163 @@ def __init__(self, config_path: str) -> None:
         self.normal_charge_current: float = json_data["normal_charge_current"]
         self.normal_battery_voltage: float = json_data["normal_battery_voltage"]
         self.critical_battery_voltage: float = json_data["critical_battery_voltage"]
-        self.current_draw: float = json_data["current_draw"]
         self.reboot_time: int = json_data["reboot_time"]
         self.turbo_clock: bool = json_data["turbo_clock"]
         self.super_secret_code: str = json_data["super_secret_code"]
         self.repeat_code: str = json_data["repeat_code"]
         self.joke_reply: list[str] = json_data["joke_reply"]
+
+        self.CONFIG_SCHEMA = {
+            "cubesat_name": {"type": str, "min_length": 1, "max_length": 10},
+            "super_secret_code": {"type": bytes, "min": 1, "max": 24},
+            "repeat_code": {"type": bytes, "min": 1, "max": 4},
+            "normal_charge_current": {"type": float, "min": 0.0, "max": 2000.0},
+            "normal_battery_voltage": {"type": float, "min": 6.0, "max": 8.4},
+            "critical_battery_voltage": {"type": float, "min": 5.4, "max": 7.2},
+            "sleep_duration": {"type": int, "min": 1, "max": 86400},
+            "normal_temp": {"type": int, "min": 5, "max": 40},
+            "normal_battery_temp": {"type": int, "min": 1, "max": 35},
+            "normal_micro_temp": {"type": int, "min": 1, "max": 50},
+            "reboot_time": {"type": int, "min": 3600, "max": 604800},
+            "detumble_enable_z": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_x": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_y": {"type": bool, "allowed_values": [True, False]},
+            "debug": {"type": bool, "allowed_values": [True, False]},
+            "heating": {"type": bool, "allowed_values": [True, False]},
+            "turbo_clock": {"type": bool, "allowed_values": [True, False]},
+        }
+
+        self.RADIO_SCHEMA = {
+            "license": {"type": bool, "allowed_values": [True, False]},
+            "receiver_id": {"type": int, "min": 0, "max": 255},
+            "sender_id": {"type": int, "min": 0, "max": 255},
+            "start_time": {"type": int, "min": 0, "max": 80000},
+            "transmit_frequency": {
+                "type": float,
+                "min0": 435,
+                "max0": 438.0,
+                "min1": 915.0,
+                "max1": 915.0,
+            },
+        }
+
+        self.FSK_SCHEMA = {
+            "ack_delay": {"type": float, "min": 0.0, "max": 2.0},
+            "coding_rate": {"type": int, "min": 4, "max": 8},
+            "cyclic_redundancy_check": {"type": bool, "allowed_values": [True, False]},
+            "max_output": {"type": bool, "allowed_values": [True, False]},
+            "spreading_factor": {"type": int, "min": 6, "max": 12},
+            "transmit_power": {"type": int, "min": 5, "max": 23},
+        }
+
+        self.LORA_SCHEMA = {
+            "broadcast_address": {"type": int, "min": 0, "max": 255},
+            "node_address": {"type": int, "min": 0, "max": 255},
+            "modulation_type": {"type": int, "min": 0, "max": 1},
+        }
+
+    # validates values from input
+    def validate(self, key: str, value) -> None:
path: pysquared/config/config.py
user: login: hrfarmer
body: Unless there's some use cases I'm not considering, this should probably be a "private" method (`_validate`). Don't really see when this function itself would be used during actual usage, since `update_config` already calls this.

Only thing I can think of is for tests, but since as mentioned above it's already called when calling `update_config`, it'll still throw anyways.
created_at: 2025-04-29T23:09:03Z
author_association: MEMBER
diff_hunk: @@ -34,9 +34,163 @@ def __init__(self, config_path: str) -> None:
         self.normal_charge_current: float = json_data["normal_charge_current"]
         self.normal_battery_voltage: float = json_data["normal_battery_voltage"]
         self.critical_battery_voltage: float = json_data["critical_battery_voltage"]
-        self.current_draw: float = json_data["current_draw"]
         self.reboot_time: int = json_data["reboot_time"]
         self.turbo_clock: bool = json_data["turbo_clock"]
         self.super_secret_code: str = json_data["super_secret_code"]
         self.repeat_code: str = json_data["repeat_code"]
         self.joke_reply: list[str] = json_data["joke_reply"]
+
+        self.CONFIG_SCHEMA = {
+            "cubesat_name": {"type": str, "min_length": 1, "max_length": 10},
+            "super_secret_code": {"type": bytes, "min": 1, "max": 24},
+            "repeat_code": {"type": bytes, "min": 1, "max": 4},
+            "normal_charge_current": {"type": float, "min": 0.0, "max": 2000.0},
+            "normal_battery_voltage": {"type": float, "min": 6.0, "max": 8.4},
+            "critical_battery_voltage": {"type": float, "min": 5.4, "max": 7.2},
+            "sleep_duration": {"type": int, "min": 1, "max": 86400},
+            "normal_temp": {"type": int, "min": 5, "max": 40},
+            "normal_battery_temp": {"type": int, "min": 1, "max": 35},
+            "normal_micro_temp": {"type": int, "min": 1, "max": 50},
+            "reboot_time": {"type": int, "min": 3600, "max": 604800},
+            "detumble_enable_z": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_x": {"type": bool, "allowed_values": [True, False]},
+            "detumble_enable_y": {"type": bool, "allowed_values": [True, False]},
+            "debug": {"type": bool, "allowed_values": [True, False]},
+            "heating": {"type": bool, "allowed_values": [True, False]},
+            "turbo_clock": {"type": bool, "allowed_values": [True, False]},
+        }
+
+        self.RADIO_SCHEMA = {
+            "license": {"type": bool, "allowed_values": [True, False]},
+            "receiver_id": {"type": int, "min": 0, "max": 255},
+            "sender_id": {"type": int, "min": 0, "max": 255},
+            "start_time": {"type": int, "min": 0, "max": 80000},
+            "transmit_frequency": {
+                "type": float,
+                "min0": 435,
+                "max0": 438.0,
+                "min1": 915.0,
+                "max1": 915.0,
+            },
+        }
+
+        self.FSK_SCHEMA = {
+            "ack_delay": {"type": float, "min": 0.0, "max": 2.0},
+            "coding_rate": {"type": int, "min": 4, "max": 8},
+            "cyclic_redundancy_check": {"type": bool, "allowed_values": [True, False]},
+            "max_output": {"type": bool, "allowed_values": [True, False]},
+            "spreading_factor": {"type": int, "min": 6, "max": 12},
+            "transmit_power": {"type": int, "min": 5, "max": 23},
+        }
+
+        self.LORA_SCHEMA = {
+            "broadcast_address": {"type": int, "min": 0, "max": 255},
+            "node_address": {"type": int, "min": 0, "max": 255},
+            "modulation_type": {"type": int, "min": 0, "max": 1},
+        }
+
+    # validates values from input
+    def validate(self, key: str, value) -> None:
path: pysquared/config/config.py
user: login: Alandlt15
body: Yes, you're correct. This function should only be used inside of the config class. Thank you for pointing this out!
created_at: 2025-05-01T01:31:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,13 +14,13 @@
 
 class Config:
     def __init__(self, config_path: str) -> None:
+        self.temp_config_file = "tests/unit/files/config.test.json"
path: pysquared/config/config.py
user: login: Alandlt15
body: I'm a little unsure on how to handle this one here. I changed it to .test.json, because whenever I or anyone runs `make test` it will actually change the .json file and put everything into one line and its kinda annoying. 

Maybe this is fine, but idk if we can also put a note or comment to change it back to the original json file?
created_at: 2025-05-01T01:36:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,13 +14,13 @@
 
 class Config:
     def __init__(self, config_path: str) -> None:
+        self.temp_config_file = "tests/unit/files/config.test.json"
path: pysquared/config/config.py
user: login: hrfarmer
body: Well for the one line thing, we probably don't want the config files to be losing their formatting on save at all. If you add an indent to `json.dumps()`, like this
```py
f.write(json.dumps(json_data, indent=2))
```
in all the places that the file is being saved, it will preserve file formatting.

For the file getting changed in `make test`, specifically the save and update config tests, the easiest thing I can think of would be to make a copy of the test JSON file when the test starts, and then delete it after the test finishes.
created_at: 2025-05-01T01:49:23Z
author_association: MEMBER
diff_hunk: @@ -14,13 +14,13 @@
 
 class Config:
     def __init__(self, config_path: str) -> None:
+        self.temp_config_file = "tests/unit/files/config.test.json"
path: pysquared/config/config.py
user: login: Alandlt15
body: I'll test this out, thank you!
created_at: 2025-05-01T02:06:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: Mikefly123
created_at: 2025-04-19T00:58:36Z
body: Hey @Alandlt15 just wanted to check in on if this was still in the review pipeline? Forgot to ask about it during the weekly this last week.
author_association: MEMBER
user: login: Alandlt15
created_at: 2025-04-19T01:13:21Z
body: Yes! This is still in the review pipeline. Nate told me on Monday he was going to find sometime to review it.

It would help a lot if you're also wanting to get some eyes on this PR too. Thank you!
author_association: CONTRIBUTOR
user: login: nateinaction
created_at: 2025-04-21T06:37:05Z
body: Apologies for the delay, I held off on reviewing this this weekend because of how it touches state of health. I'm working through that review first and then will switch to this.
author_association: MEMBER
user: login: Alandlt15
created_at: 2025-05-01T01:38:13Z
body: @hrfarmer thank you for taking the time to review my PR! I will get to work on it when I get a chance. And to your first comment, I can def put a comment as a reminder to prevent ourselves from keys having the same name.
author_association: CONTRIBUTOR
user: login: Alandlt15
created_at: 2025-05-07T17:22:01Z
body: Latest updates:

Cleaned up config a little bit by doing `self.config_file = config_path` in the constructor.
Updated private methods by prefacing their name with a `_`.
Running config tests no longer updates the config file. 
author_association: CONTRIBUTOR
user: login: hrfarmer
created_at: 2025-05-07T20:56:11Z
body: @Alandlt15 I changed _validate to validate because I ended up realizing that it would be useful to have in some other places (specifically to use for #244)

However whenever I try to update the config on the board I'm getting a Read-only filesystem error, just wondering if anyone else is running into this?
<img width="699" alt="image" src="https://github.com/user-attachments/assets/84841a0e-93e3-456c-9a21-224e7404ff4c" />


Other than that though this looks good to me 👍 
author_association: MEMBER
user: login: Mikefly123
created_at: 2025-05-07T21:07:10Z
body: Hey @hrfarmer I believe you need to remount the CircuitPy drive to allow for the runtime to edit files rather than your computer to edit files. You can read a little more about it in this guide: 

https://learn.adafruit.com/circuitpython-essentials/circuitpython-storage

I don't remember if you can just call `storage.remount("/", readonly=False)` from the REPL, but give it a try and see what happens.
author_association: MEMBER
user: login: Alandlt15
created_at: 2025-05-07T21:14:08Z
body: Yup @hrfarmer you can try what Michael suggested. How me and @asiemsen  usually do it is by putting that line of code in boot.py. 

Once you do that you wont be able to delete or replace files on the board. So you need to go to the REPL and type these lines to reset the file system:
```
import storage
storage.erase_filesystem()
```
more on that here: https://forums.adafruit.com/viewtopic.php?t=173164
author_association: CONTRIBUTOR
user: login: Alandlt15
created_at: 2025-05-08T16:25:54Z
body: Dam that's a bummer, but you're right I don't think formatting will be an issue. I will remove the indent off the code. Thank you again for looking into this PR!
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-09T16:07:43Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=proveskit_circuitpy_flight_software&pullRequest=229) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=proveskit_circuitpy_flight_software&pullRequest=229&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=proveskit_circuitpy_flight_software&pullRequest=229&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=proveskit_circuitpy_flight_software&pullRequest=229&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [89.6% Coverage on New Code](https://sonarcloud.io/component_measures?id=proveskit_circuitpy_flight_software&pullRequest=229&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=proveskit_circuitpy_flight_software&pullRequest=229&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=proveskit_circuitpy_flight_software&pullRequest=229)


author_association: NONE