diff --git a/cflib/crazyflie/param.py b/cflib/crazyflie/param.py
index 77d474a39..1dc199e8f 100644
--- a/cflib/crazyflie/param.py
+++ b/cflib/crazyflie/param.py
@@ -445,6 +445,9 @@ def persistent_store(self, complete_name, callback=None):
@param callback Optional callback should take `complete_name` and boolean status as arguments
"""
element = self.toc.get_element_by_complete_name(complete_name)
+ if not element:
+ callback(complete_name, False)
+ return
if not element.is_persistent():
raise AttributeError(f"Param '{complete_name}' is not persistent")
diff --git a/cflib/localization/__init__.py b/cflib/localization/__init__.py
index 7a9cc98d2..6f4252c3e 100644
--- a/cflib/localization/__init__.py
+++ b/cflib/localization/__init__.py
@@ -25,6 +25,7 @@
from .lighthouse_config_manager import LighthouseConfigWriter
from .lighthouse_sweep_angle_reader import LighthouseSweepAngleAverageReader
from .lighthouse_sweep_angle_reader import LighthouseSweepAngleReader
+from .param_io import ParamFileManager
__all__ = [
'LighthouseBsGeoEstimator',
@@ -32,4 +33,5 @@
'LighthouseSweepAngleAverageReader',
'LighthouseSweepAngleReader',
'LighthouseConfigFileManager',
- 'LighthouseConfigWriter']
+ 'LighthouseConfigWriter',
+ 'ParamFileManager']
diff --git a/cflib/localization/param_io.py b/cflib/localization/param_io.py
new file mode 100644
index 000000000..7d337f46f
--- /dev/null
+++ b/cflib/localization/param_io.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+#
+# ,---------, ____ _ __
+# | ,-^-, | / __ )(_) /_______________ _____ ___
+# | ( O ) | / __ / / __/ ___/ ___/ __ `/_ / / _ \
+# | / ,--' | / /_/ / / /_/ /__/ / / /_/ / / /_/ __/
+# +------` /_____/_/\__/\___/_/ \__,_/ /___/\___/
+#
+# Copyright (C) 2024 Bitcraze AB
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, in version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import yaml
+
+from cflib.crazyflie.param import PersistentParamState
+
+
+class ParamFileManager():
+ """Reads and writes parameter configurations from file"""
+ TYPE_ID = 'type'
+ TYPE = 'persistent_param_state'
+ VERSION_ID = 'version'
+ VERSION = '1'
+ PARAMS_ID = 'params'
+
+ @staticmethod
+ def write(file_name, params={}):
+ file = open(file_name, 'w')
+ with file:
+ file_params = {}
+ for id, param in params.items():
+ assert isinstance(param, PersistentParamState)
+ if isinstance(param, PersistentParamState):
+ file_params[id] = {'is_stored': param.is_stored,
+ 'default_value': param.default_value, 'stored_value': param.stored_value}
+
+ data = {
+ ParamFileManager.TYPE_ID: ParamFileManager.TYPE,
+ ParamFileManager.VERSION_ID: ParamFileManager.VERSION,
+ ParamFileManager.PARAMS_ID: file_params
+ }
+
+ yaml.dump(data, file)
+
+ @staticmethod
+ def read(file_name):
+ file = open(file_name, 'r')
+ with file:
+ data = None
+ try:
+ data = yaml.safe_load(file)
+ except yaml.YAMLError as exc:
+ print(exc)
+
+ if ParamFileManager.TYPE_ID not in data:
+ raise Exception('Type field missing')
+
+ if data[ParamFileManager.TYPE_ID] != ParamFileManager.TYPE:
+ raise Exception('Unsupported file type')
+
+ if ParamFileManager.VERSION_ID not in data:
+ raise Exception('Version field missing')
+
+ if data[ParamFileManager.VERSION_ID] != ParamFileManager.VERSION:
+ raise Exception('Unsupported file version')
+
+ def get_data(input_data):
+ persistent_params = {}
+ for id, param in input_data.items():
+ persistent_params[id] = PersistentParamState(
+ param['is_stored'], param['default_value'], param['stored_value'])
+ return persistent_params
+
+ if ParamFileManager.PARAMS_ID in data:
+ return get_data(data[ParamFileManager.PARAMS_ID])
+ else:
+ return {}
diff --git a/test/localization/fixtures/parameters.yaml b/test/localization/fixtures/parameters.yaml
new file mode 100644
index 000000000..b61abc437
--- /dev/null
+++ b/test/localization/fixtures/parameters.yaml
@@ -0,0 +1,15 @@
+params:
+ activeMarker.back:
+ default_value: 3
+ is_stored: true
+ stored_value: 10
+ cppm.angPitch:
+ default_value: 50.0
+ is_stored: true
+ stored_value: 55.0
+ ctrlMel.i_range_z:
+ default_value: 0.4000000059604645
+ is_stored: true
+ stored_value: 0.44999998807907104
+type: persistent_param_state
+version: '1'
diff --git a/test/localization/test_param_io.py b/test/localization/test_param_io.py
new file mode 100644
index 000000000..1154e8a85
--- /dev/null
+++ b/test/localization/test_param_io.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+#
+# ,---------, ____ _ __
+# | ,-^-, | / __ )(_) /_______________ _____ ___
+# | ( O ) | / __ / / __/ ___/ ___/ __ `/_ / / _ \
+# | / ,--' | / /_/ / / /_/ /__/ / / /_/ / / /_/ __/
+# +------` /_____/_/\__/\___/_/ \__,_/ /___/\___/
+#
+# Copyright (C) 2024 Bitcraze AB
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, in version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import unittest
+from unittest.mock import ANY
+from unittest.mock import mock_open
+from unittest.mock import patch
+
+import yaml
+
+from cflib.localization import ParamFileManager
+
+
+class TestParamFileManager(unittest.TestCase):
+ def setUp(self):
+ self.data = {
+ 'type': 'persistent_param_state',
+ 'version': '1',
+ }
+
+ @patch('yaml.safe_load')
+ def test_that_read_open_correct_file(self, mock_yaml_load):
+ # Fixture
+ mock_yaml_load.return_value = self.data
+ file_name = 'some/name.yaml'
+
+ # Test
+ with patch('builtins.open', new_callable=mock_open()) as mock_file:
+ ParamFileManager.read(file_name)
+
+ # Assert
+ mock_file.assert_called_with(file_name, 'r')
+
+ @patch('yaml.safe_load')
+ def test_that_missing_file_type_raises(self, mock_yaml_load):
+ # Fixture
+ self.data.pop('type')
+ mock_yaml_load.return_value = self.data
+
+ # Test
+ # Assert
+ with self.assertRaises(Exception):
+ with patch('builtins.open', new_callable=mock_open()):
+ ParamFileManager.read('some/name.yaml')
+
+ @patch('yaml.safe_load')
+ def test_that_wrong_file_type_raises(self, mock_yaml_load):
+ # Fixture
+ self.data['type'] = 'wrong_type'
+ mock_yaml_load.return_value = self.data
+
+ # Test
+ # Assert
+ with self.assertRaises(Exception):
+ with patch('builtins.open', new_callable=mock_open()):
+ ParamFileManager.read('some/name.yaml')
+
+ @patch('yaml.safe_load')
+ def test_that_missing_version_raises(self, mock_yaml_load):
+
+ # Fixture
+ self.data.pop('version')
+ mock_yaml_load.return_value = self.data
+
+ # Test
+ # Assert
+ with self.assertRaises(Exception):
+ with patch('builtins.open', new_callable=mock_open()):
+ ParamFileManager.read('some/name.yaml')
+
+ @patch('yaml.safe_load')
+ def test_that_wrong_version_raises(self, mock_yaml_load):
+ # Fixture
+ self.data['version'] = 'wrong_version'
+ mock_yaml_load.return_value = self.data
+
+ # Test
+ # Assert
+ with self.assertRaises(Exception):
+ with patch('builtins.open', new_callable=mock_open()):
+ ParamFileManager.read('some/name.yaml')
+
+ @patch('yaml.safe_load')
+ def test_that_no_data_returns_empty_default_data(self, mock_yaml_load):
+ # Fixture
+ mock_yaml_load.return_value = self.data
+
+ # Test
+ with patch('builtins.open', new_callable=mock_open()):
+ actual_params = ParamFileManager.read('some/name.yaml')
+
+ # Assert
+ self.assertEqual(0, len(actual_params))
+
+ @patch('yaml.dump')
+ def test_file_end_to_end_write_read(self, mock_yaml_dump):
+ # Fixture
+ fixture_file = 'test/localization/fixtures/parameters.yaml'
+
+ file = open(fixture_file, 'r')
+ expected = yaml.safe_load(file)
+ file.close()
+
+ # Test
+ params = ParamFileManager.read(fixture_file)
+ with patch('builtins.open', new_callable=mock_open()):
+ ParamFileManager.write('some/name.yaml', params=params)
+
+ # Assert
+ mock_yaml_dump.assert_called_with(expected, ANY)
+
+ @patch('yaml.dump')
+ def test_file_write_to_correct_file(self, mock_yaml_dump):
+ # Fixture
+ file_name = 'some/name.yaml'
+
+ # Test
+ with patch('builtins.open', new_callable=mock_open()) as mock_file:
+ ParamFileManager.write(file_name)
+
+ # Assert
+ mock_file.assert_called_with(file_name, 'w')