tinymovr 2.3.4__tar.gz → 2.5.0__tar.gz

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 (63) hide show
  1. {tinymovr-2.3.4 → tinymovr-2.5.0}/PKG-INFO +1 -1
  2. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_amt22.py +5 -5
  3. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_as5047.py +6 -6
  4. tinymovr-2.5.0/tests/test_export_import.py +371 -0
  5. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_hall.py +3 -3
  6. tinymovr-2.5.0/tests/test_nvm.py +710 -0
  7. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_watchdog.py +7 -1
  8. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/channel.py +23 -0
  9. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/config/__init__.py +2 -0
  10. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/config/config.py +33 -0
  11. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/__init__.py +1 -1
  12. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/helpers.py +15 -6
  13. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/widgets.py +5 -1
  14. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/window.py +72 -19
  15. tinymovr-2.5.0/tinymovr/specs/tinymovr_2_4_x.yaml +636 -0
  16. tinymovr-2.5.0/tinymovr/specs/tinymovr_2_5_x.yaml +641 -0
  17. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/PKG-INFO +1 -1
  18. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/SOURCES.txt +4 -1
  19. tinymovr-2.3.4/tests/test_nvm.py +0 -250
  20. {tinymovr-2.3.4 → tinymovr-2.5.0}/MANIFEST.in +0 -0
  21. {tinymovr-2.3.4 → tinymovr-2.5.0}/README.md +0 -0
  22. {tinymovr-2.3.4 → tinymovr-2.5.0}/pyproject.toml +0 -0
  23. {tinymovr-2.3.4 → tinymovr-2.5.0}/setup.cfg +0 -0
  24. {tinymovr-2.3.4 → tinymovr-2.5.0}/setup.py +0 -0
  25. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_board.py +0 -0
  26. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_dfu.py +0 -0
  27. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_homing.py +0 -0
  28. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_rates.py +0 -0
  29. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_simulation.py +0 -0
  30. {tinymovr-2.3.4 → tinymovr-2.5.0}/tests/test_traj_planner.py +0 -0
  31. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/__init__.py +0 -0
  32. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/bus_manager.py +0 -0
  33. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/bus_router.py +0 -0
  34. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/cli.py +0 -0
  35. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/codec/__init__.py +0 -0
  36. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/codec/codec.py +0 -0
  37. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/constants.py +0 -0
  38. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/device_discovery.py +0 -0
  39. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/dfu.py +0 -0
  40. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/gui.py +0 -0
  41. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/gui/worker.py +0 -0
  42. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/app_icon.png +0 -0
  43. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/call.png +0 -0
  44. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/call@2x.png +0 -0
  45. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/call_dark.png +0 -0
  46. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/call_dark@2x.png +0 -0
  47. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/empty.png +0 -0
  48. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/empty@2x.png +0 -0
  49. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/empty_dark.png +0 -0
  50. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/resources/icons/empty_dark@2x.png +0 -0
  51. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/dfu_1_0_x.yaml +0 -0
  52. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/dfu_1_1_x.yaml +0 -0
  53. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_1_3_x.yaml +0 -0
  54. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_1_4_x.yaml +0 -0
  55. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_1_5_x.yaml +0 -0
  56. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_1_6_x.yaml +0 -0
  57. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_2_0_x.yaml +0 -0
  58. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_2_1_x.yaml +0 -0
  59. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr/specs/tinymovr_2_3_x.yaml +0 -0
  60. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/dependency_links.txt +0 -0
  61. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/entry_points.txt +0 -0
  62. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/requires.txt +0 -0
  63. {tinymovr-2.3.4 → tinymovr-2.5.0}/tinymovr.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinymovr
3
- Version: 2.3.4
3
+ Version: 2.5.0
4
4
  Summary: Tinymovr Studio
5
5
  Home-page: https://github.com/yconst/Tinymovr
6
6
  Author: Yannis Chatzikonstantinou
@@ -40,7 +40,7 @@ class TestAMT22(TMTestCase):
40
40
  WARNING: This will perform one NVRAM write and two erase cycles.
41
41
  """
42
42
  self.check_state(0)
43
- self.tm.erase_config()
43
+ self.erase_config()
44
44
  time.sleep(0.2)
45
45
 
46
46
  self.configure_sensors(self.tm.sensors.setup.external_spi.type.AMT22)
@@ -70,7 +70,7 @@ class TestAMT22(TMTestCase):
70
70
 
71
71
  time.sleep(0.1)
72
72
 
73
- self.tm.save_config()
73
+ self.save_config()
74
74
 
75
75
  time.sleep(0.2)
76
76
 
@@ -89,7 +89,7 @@ class TestAMT22(TMTestCase):
89
89
  delta=200 * tick,
90
90
  )
91
91
 
92
- self.tm.erase_config()
92
+ self.erase_config()
93
93
  time.sleep(0.2)
94
94
 
95
95
  @pytest.mark.sensor_amt22
@@ -100,7 +100,7 @@ class TestAMT22(TMTestCase):
100
100
  to an onboard connection, and verify if the position control maintains accuracy.
101
101
  """
102
102
  self.check_state(0)
103
- self.tm.erase_config()
103
+ self.erase_config()
104
104
  time.sleep(0.2)
105
105
 
106
106
  # Initially configure with external SPI sensor
@@ -151,7 +151,7 @@ class TestAMT22(TMTestCase):
151
151
  delta=2000 * tick,
152
152
  )
153
153
 
154
- self.tm.erase_config()
154
+ self.erase_config()
155
155
  time.sleep(0.2)
156
156
 
157
157
 
@@ -40,7 +40,7 @@ class TestAS5047(TMTestCase):
40
40
  WARNING: This will perform one NVRAM write and two erase cycles.
41
41
  """
42
42
  self.check_state(0)
43
- self.tm.erase_config()
43
+ self.erase_config()
44
44
  time.sleep(0.2)
45
45
 
46
46
  self.configure_sensors(self.tm.sensors.setup.external_spi.type.AS5047)
@@ -69,7 +69,7 @@ class TestAS5047(TMTestCase):
69
69
 
70
70
  time.sleep(0.1)
71
71
 
72
- self.tm.save_config()
72
+ self.save_config()
73
73
 
74
74
  time.sleep(0.2)
75
75
 
@@ -88,7 +88,7 @@ class TestAS5047(TMTestCase):
88
88
  delta=2000 * tick,
89
89
  )
90
90
 
91
- self.tm.erase_config()
91
+ self.erase_config()
92
92
  time.sleep(0.2)
93
93
 
94
94
  @pytest.mark.sensor_as5047
@@ -99,7 +99,7 @@ class TestAS5047(TMTestCase):
99
99
  to an onboard connection, and verify if the position control maintains accuracy.
100
100
  """
101
101
  self.check_state(0)
102
- self.tm.erase_config()
102
+ self.erase_config()
103
103
  time.sleep(0.2)
104
104
 
105
105
  # Initially configure with external SPI sensor
@@ -150,7 +150,7 @@ class TestAS5047(TMTestCase):
150
150
  delta=2000 * tick,
151
151
  )
152
152
 
153
- self.tm.erase_config()
153
+ self.erase_config()
154
154
  time.sleep(0.2)
155
155
 
156
156
  @pytest.mark.sensor_as5047
@@ -160,7 +160,7 @@ class TestAS5047(TMTestCase):
160
160
  different baud rates.
161
161
  """
162
162
  self.check_state(0)
163
- self.tm.erase_config()
163
+ self.erase_config()
164
164
  time.sleep(0.2)
165
165
 
166
166
  self.configure_sensors(self.tm.sensors.setup.external_spi.type.AS5047)
@@ -0,0 +1,371 @@
1
+ """
2
+ Tinymovr Export/Import Tests
3
+ Copyright Ioannis Chatzikonstantinou 2020-2023
4
+
5
+ Tests for export/import functionality:
6
+ - YAML spec consistency (all settable config attrs have export: True)
7
+ - HITL round-trip (export -> reset -> import -> verify)
8
+ - HITL export completeness (all export-tagged attrs appear in output)
9
+
10
+ This program is free software: you can redistribute it and/or modify it under
11
+ the terms of the GNU General Public License as published by the Free Software
12
+ Foundation, either version 3 of the License, or (at your option) any later
13
+ version.
14
+ This program is distributed in the hope that it will be useful, but WITHOUT
15
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16
+ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17
+ You should have received a copy of the GNU General Public License along with
18
+ this program. If not, see <http://www.gnu.org/licenses/>.
19
+ """
20
+
21
+ import os
22
+ import time
23
+ import json
24
+ import unittest
25
+
26
+ import yaml
27
+ import pytest
28
+ from importlib.resources import files as importlib_files
29
+
30
+ from avlos.unit_field import get_registry
31
+ from avlos.json_codec import AvlosEncoder
32
+ from avlos.definitions import RemoteAttribute, RemoteEnum, RemoteBitmask
33
+
34
+ from tinymovr.config import export_config, import_config
35
+ from tests import TMTestCase
36
+
37
+ ureg = get_registry()
38
+ A = ureg.ampere
39
+ Hz = ureg.hertz
40
+ tick = ureg.ticks
41
+ s = ureg.second
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Helpers for YAML spec walking
45
+ # ---------------------------------------------------------------------------
46
+
47
+ SPECS_DIR = str(importlib_files("tinymovr").joinpath("specs"))
48
+
49
+ # Attributes that have setters but should NOT have export: True.
50
+ # Includes runtime controls and dangerous-to-import parameters.
51
+ EXCLUDED_SETTER_PATHS = {
52
+ "controller.state",
53
+ "controller.mode",
54
+ "comms.can.rate", # importing a mismatched CAN rate kills communication
55
+ "comms.can.id", # importing CAN ID mid-batch disrupts addressing
56
+ }
57
+
58
+ # Meta keys that indicate a runtime setpoint rather than a config parameter.
59
+ RUNTIME_META_KEYS = {"jog_step"}
60
+
61
+
62
+ def _walk_spec_attrs(attrs, path_prefix=""):
63
+ """
64
+ Recursively yield (dotted_path, attr_dict) for every leaf attribute
65
+ in a YAML spec attribute tree.
66
+ """
67
+ for attr in attrs:
68
+ name = attr["name"]
69
+ dotted = f"{path_prefix}.{name}" if path_prefix else name
70
+ if "remote_attributes" in attr:
71
+ yield from _walk_spec_attrs(attr["remote_attributes"], dotted)
72
+ else:
73
+ yield dotted, attr
74
+
75
+
76
+ def _is_excluded_setter(dotted_path, attr):
77
+ """
78
+ Return True if this attribute has a setter but should NOT
79
+ be expected to have export: True (e.g. runtime controls,
80
+ dangerous-to-import parameters).
81
+ """
82
+ if dotted_path in EXCLUDED_SETTER_PATHS:
83
+ return True
84
+ meta = attr.get("meta", {})
85
+ if any(k in meta for k in RUNTIME_META_KEYS):
86
+ return True
87
+ return False
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # 4a. YAML Spec Consistency Test (no hardware required)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ class TestSpecExportConsistency(unittest.TestCase):
95
+ """
96
+ Verify that every settable config attribute in the latest YAML spec
97
+ has meta: {export: True}. This is a pure-Python test with no hardware
98
+ dependency.
99
+ """
100
+
101
+ def _load_spec(self, filename):
102
+ path = os.path.join(SPECS_DIR, filename)
103
+ with open(path, "r") as f:
104
+ return yaml.safe_load(f)
105
+
106
+ def _check_spec(self, filename):
107
+ spec = self._load_spec(filename)
108
+ missing = []
109
+ for dotted, attr in _walk_spec_attrs(spec.get("remote_attributes", [])):
110
+ if "setter_name" not in attr:
111
+ continue
112
+ if _is_excluded_setter(dotted, attr):
113
+ continue
114
+ # Also skip void callers masquerading as setters (arguments list)
115
+ if "arguments" in attr:
116
+ continue
117
+ meta = attr.get("meta", {})
118
+ if not meta.get("export"):
119
+ missing.append(dotted)
120
+ return missing
121
+
122
+ def test_latest_spec_export_tags(self):
123
+ """
124
+ All settable config attributes in tinymovr_2_5_x.yaml must have
125
+ meta: {export: True}.
126
+ """
127
+ missing = self._check_spec("tinymovr_2_5_x.yaml")
128
+ self.assertEqual(
129
+ missing,
130
+ [],
131
+ "The following settable config attributes are missing "
132
+ "meta: {export: True} in tinymovr_2_5_x.yaml:\n "
133
+ + "\n ".join(missing),
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # 4b. HITL Export/Import Round-Trip Test
139
+ # ---------------------------------------------------------------------------
140
+
141
+ @pytest.mark.hitl_default
142
+ class TestExportImportRoundTrip(TMTestCase):
143
+ """
144
+ Hardware-in-the-loop test: modify values, export, reset to defaults,
145
+ import, then verify the values were restored.
146
+ """
147
+
148
+ def test_a_export_import_round_trip(self):
149
+ """
150
+ Set non-default values on key attributes, export, reset, import,
151
+ and verify each value is correctly restored.
152
+ """
153
+ self.check_state(0)
154
+ self.erase_config()
155
+ time.sleep(0.2)
156
+
157
+ self.try_calibrate()
158
+ self.assertEqual(self.tm.motor.calibrated, True)
159
+
160
+ # --- Define test values (non-default) ---
161
+ test_values = {
162
+ "controller.position.p_gain": 35.0,
163
+ "controller.velocity.p_gain": 5e-5,
164
+ "controller.velocity.i_gain": 1.5e-2,
165
+ "controller.velocity.limit": 80000,
166
+ "controller.velocity.deadband": 300,
167
+ "controller.velocity.increment": 150.0,
168
+ "controller.current.bandwidth": 1500.0,
169
+ "controller.current.max_Ibrake": 2.0,
170
+ "traj_planner.max_vel": 90000,
171
+ "traj_planner.max_accel": 400000,
172
+ "traj_planner.max_decel": 400000,
173
+ }
174
+
175
+ # Conditionally add attrs that may exist in newer specs
176
+ if hasattr(self.tm.controller.current, "Iq_limit"):
177
+ test_values["controller.current.Iq_limit"] = 6.0
178
+ if hasattr(self.tm.controller.current, "max_Ibus_regen"):
179
+ test_values["controller.current.max_Ibus_regen"] = 1.5
180
+
181
+ # --- Set values ---
182
+ self.tm.controller.position.p_gain = test_values["controller.position.p_gain"]
183
+ self.tm.controller.velocity.p_gain = test_values["controller.velocity.p_gain"]
184
+ self.tm.controller.velocity.i_gain = test_values["controller.velocity.i_gain"]
185
+ self.tm.controller.velocity.limit = test_values["controller.velocity.limit"] * tick / s
186
+ self.tm.controller.velocity.deadband = test_values["controller.velocity.deadband"] * tick
187
+ self.tm.controller.velocity.increment = test_values["controller.velocity.increment"]
188
+ self.tm.controller.current.bandwidth = test_values["controller.current.bandwidth"]
189
+ self.tm.controller.current.max_Ibrake = test_values["controller.current.max_Ibrake"] * A
190
+ self.tm.traj_planner.max_vel = test_values["traj_planner.max_vel"] * tick / s
191
+ self.tm.traj_planner.max_accel = test_values["traj_planner.max_accel"] * tick / s
192
+ self.tm.traj_planner.max_decel = test_values["traj_planner.max_decel"] * tick / (s * s)
193
+
194
+ if "controller.current.Iq_limit" in test_values:
195
+ self.tm.controller.current.Iq_limit = test_values["controller.current.Iq_limit"]
196
+ if "controller.current.max_Ibus_regen" in test_values:
197
+ self.tm.controller.current.max_Ibus_regen = test_values["controller.current.max_Ibus_regen"]
198
+
199
+ time.sleep(0.1)
200
+
201
+ # --- Export ---
202
+ exported = export_config(self.tm)
203
+ self.assertIsNotNone(exported, "export_config() returned None")
204
+ self.assertIsInstance(exported, dict, "export_config() should return a dict")
205
+
206
+ # Verify JSON round-trip (mimics what the GUI does)
207
+ json_str = json.dumps(exported, cls=AvlosEncoder)
208
+ reimported = json.loads(json_str)
209
+ self.assertIsInstance(reimported, dict)
210
+
211
+ # --- Verify exported contains key sections ---
212
+ self.assertIn("controller", exported)
213
+ self.assertIn("traj_planner", exported)
214
+
215
+ # --- Reset to defaults ---
216
+ self.tm.controller.idle()
217
+ time.sleep(0.1)
218
+ self.erase_config()
219
+ time.sleep(0.2)
220
+ self.reset_and_wait()
221
+
222
+ # --- Import ---
223
+ import_config(self.tm, reimported)
224
+ time.sleep(0.2)
225
+
226
+ # --- Verify values restored ---
227
+ self.assertAlmostEqual(
228
+ self.tm.controller.position.p_gain,
229
+ test_values["controller.position.p_gain"],
230
+ places=1,
231
+ msg="p_gain not restored after import",
232
+ )
233
+ self.assertAlmostEqual(
234
+ self.tm.controller.velocity.p_gain,
235
+ test_values["controller.velocity.p_gain"],
236
+ places=8,
237
+ msg="velocity p_gain not restored after import",
238
+ )
239
+ self.assertAlmostEqual(
240
+ self.tm.controller.velocity.i_gain,
241
+ test_values["controller.velocity.i_gain"],
242
+ places=5,
243
+ msg="velocity i_gain not restored after import",
244
+ )
245
+ self.assertAlmostEqual(
246
+ self.tm.controller.velocity.limit,
247
+ test_values["controller.velocity.limit"] * tick / s,
248
+ msg="velocity limit not restored after import",
249
+ )
250
+ self.assertAlmostEqual(
251
+ self.tm.controller.velocity.deadband,
252
+ test_values["controller.velocity.deadband"] * tick,
253
+ msg="velocity deadband not restored after import",
254
+ )
255
+ self.assertAlmostEqual(
256
+ self.tm.controller.current.bandwidth,
257
+ test_values["controller.current.bandwidth"] * Hz,
258
+ delta=50 * Hz,
259
+ msg="current bandwidth not restored after import",
260
+ )
261
+ self.assertAlmostEqual(
262
+ self.tm.controller.current.max_Ibrake,
263
+ test_values["controller.current.max_Ibrake"] * A,
264
+ msg="max_Ibrake not restored after import",
265
+ )
266
+ self.assertAlmostEqual(
267
+ self.tm.traj_planner.max_vel,
268
+ test_values["traj_planner.max_vel"] * tick / s,
269
+ msg="traj_planner max_vel not restored after import",
270
+ )
271
+ self.assertAlmostEqual(
272
+ self.tm.traj_planner.max_accel,
273
+ test_values["traj_planner.max_accel"] * tick / s,
274
+ msg="traj_planner max_accel not restored after import",
275
+ )
276
+
277
+ if "controller.current.Iq_limit" in test_values:
278
+ self.assertAlmostEqual(
279
+ self.tm.controller.current.Iq_limit,
280
+ test_values["controller.current.Iq_limit"] * A,
281
+ msg="Iq_limit not restored after import",
282
+ )
283
+ if "controller.current.max_Ibus_regen" in test_values:
284
+ self.assertAlmostEqual(
285
+ self.tm.controller.current.max_Ibus_regen,
286
+ test_values["controller.current.max_Ibus_regen"] * A,
287
+ msg="max_Ibus_regen not restored after import",
288
+ )
289
+
290
+ # --- Clean up ---
291
+ self.erase_config()
292
+ time.sleep(0.2)
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # 4c. HITL Export Completeness Test
297
+ # ---------------------------------------------------------------------------
298
+
299
+ @pytest.mark.hitl_default
300
+ class TestExportCompleteness(TMTestCase):
301
+ """
302
+ Verify every attribute with meta: {export: True} on the *connected
303
+ device* actually appears in the output of export_values().
304
+
305
+ Uses the live device attribute tree (not a YAML file) so the test
306
+ is accurate regardless of which firmware/spec version is running.
307
+ """
308
+
309
+ def _collect_device_export_paths(self, node, prefix=""):
310
+ """
311
+ Walk the live Avlos node tree and collect dotted paths of all
312
+ attributes that have meta["export"] == True.
313
+ """
314
+ paths = []
315
+ if hasattr(node, "remote_attributes"):
316
+ for name, child in node.remote_attributes.items():
317
+ dotted = f"{prefix}.{name}" if prefix else name
318
+ paths.extend(self._collect_device_export_paths(child, dotted))
319
+ else:
320
+ if isinstance(node, (RemoteAttribute, RemoteEnum, RemoteBitmask)):
321
+ if node.meta.get("export"):
322
+ paths.append(prefix)
323
+ return paths
324
+
325
+ def _lookup_nested(self, data, dotted_path):
326
+ """
327
+ Look up a dotted path like 'controller.position.p_gain' in a
328
+ nested dict. Returns (True, value) or (False, None).
329
+ """
330
+ keys = dotted_path.split(".")
331
+ current = data
332
+ for key in keys:
333
+ if not isinstance(current, dict) or key not in current:
334
+ return False, None
335
+ current = current[key]
336
+ return True, current
337
+
338
+ def test_a_all_export_tagged_attrs_present(self):
339
+ """
340
+ Every attribute with export: True on the connected device must
341
+ appear in export_values() output.
342
+ """
343
+ self.check_state(0)
344
+ self.try_calibrate()
345
+
346
+ exported = export_config(self.tm)
347
+ self.assertIsNotNone(exported, "export_config() returned None")
348
+
349
+ expected_paths = self._collect_device_export_paths(self.tm)
350
+ self.assertGreater(len(expected_paths), 0,
351
+ "No export-tagged attributes found on device")
352
+
353
+ missing = []
354
+ for path in expected_paths:
355
+ found, _ = self._lookup_nested(exported, path)
356
+ if not found:
357
+ missing.append(path)
358
+
359
+ self.assertEqual(
360
+ missing,
361
+ [],
362
+ "The following export-tagged attributes are missing from "
363
+ "export_values() output:\n " + "\n ".join(missing),
364
+ )
365
+
366
+ self.erase_config()
367
+ time.sleep(0.2)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ unittest.main()
@@ -43,7 +43,7 @@ class TestHall(TMTestCase):
43
43
  WARNING: This will perform one NVRAM write and two erase cycles.
44
44
  """
45
45
  self.check_state(0)
46
- self.tm.erase_config()
46
+ self.erase_config()
47
47
  time.sleep(0.2)
48
48
 
49
49
  self.tm.sensors.select.position_sensor.connection = self.tm.sensors.select.position_sensor.connection.HALL
@@ -78,7 +78,7 @@ class TestHall(TMTestCase):
78
78
 
79
79
  time.sleep(0.1)
80
80
 
81
- self.tm.save_config()
81
+ self.save_config()
82
82
 
83
83
  time.sleep(0.2)
84
84
 
@@ -97,7 +97,7 @@ class TestHall(TMTestCase):
97
97
  delta=30000 * tick,
98
98
  )
99
99
 
100
- self.tm.erase_config()
100
+ self.erase_config()
101
101
  time.sleep(0.2)
102
102
 
103
103