Skip to content

Commit 62cf726

Browse files
bizfscacolomb
andauthored
Add unit tests for CiA 402 profile (#644)
- State402 enum and state decoding from statusword - Command word generation for state transitions - Next-state calculation through the state machine - Operation mode switching and reading - TPDO callback handling for statusword updates - Lookup table consistency checks - Full state machine transitions simulating drive controlword / statusword exchange Co-authored-by: André Colomb <src@andre.colomb.de>
1 parent 7f8347f commit 62cf726

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

‎test/test_p402.py‎

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
4+
from canopen.objectdictionary import ODVariable, ObjectDictionary
5+
from canopen.objectdictionary.datatypes import INTEGER8, UNSIGNED16, UNSIGNED32
6+
from canopen.profiles.p402 import BaseNode402, OperationMode, State402
7+
8+
9+
def _make_od():
10+
"""Create a minimal OD with DS402 objects for testing."""
11+
od = ObjectDictionary()
12+
# Controlword
13+
var = ODVariable("Controlword", 0x6040)
14+
var.data_type = UNSIGNED16
15+
var.access_type = "rw"
16+
od.add_object(var)
17+
# Statusword
18+
var = ODVariable("Statusword", 0x6041)
19+
var.data_type = UNSIGNED16
20+
var.access_type = "ro"
21+
od.add_object(var)
22+
# Modes of operation
23+
var = ODVariable("Modes of operation", 0x6060)
24+
var.data_type = INTEGER8
25+
var.access_type = "rw"
26+
od.add_object(var)
27+
# Modes of operation display
28+
var = ODVariable("Modes of operation display", 0x6061)
29+
var.data_type = INTEGER8
30+
var.access_type = "ro"
31+
od.add_object(var)
32+
# Supported drive modes
33+
var = ODVariable("Supported drive modes", 0x6502)
34+
var.data_type = UNSIGNED32
35+
var.access_type = "ro"
36+
od.add_object(var)
37+
return od
38+
39+
40+
def _inject_tpdo(node, index, value):
41+
"""Simulate TPDO reception for a single OD object."""
42+
fake_obj = MagicMock(index=index, raw=value)
43+
fake_map = MagicMock()
44+
fake_map.__iter__ = lambda s: iter([fake_obj])
45+
node.on_TPDOs_update_callback(fake_map)
46+
47+
48+
class _FakeRpdoVar:
49+
"""Fake RPDO variable that calls a callback when raw is written."""
50+
51+
def __init__(self, on_write):
52+
self._raw = 0
53+
self._on_write = on_write
54+
self.pdo_parent = MagicMock(is_periodic=False)
55+
56+
@property
57+
def raw(self):
58+
return self._raw
59+
60+
@raw.setter
61+
def raw(self, value):
62+
self._raw = value
63+
self._on_write(value)
64+
65+
66+
class TestState402(unittest.TestCase):
67+
"""Tests for the State402 static helper and its lookup tables."""
68+
69+
def test_sw_mask_all_states_defined(self):
70+
expected = {
71+
"NOT READY TO SWITCH ON",
72+
"SWITCH ON DISABLED",
73+
"READY TO SWITCH ON",
74+
"SWITCHED ON",
75+
"OPERATION ENABLED",
76+
"FAULT",
77+
"FAULT REACTION ACTIVE",
78+
"QUICK STOP ACTIVE",
79+
}
80+
self.assertEqual(set(State402.SW_MASK.keys()), expected)
81+
82+
def test_sw_mask_values_are_unique(self):
83+
"""Each state must produce a unique (mask, value) pair."""
84+
pairs = list(State402.SW_MASK.values())
85+
self.assertEqual(len(pairs), len(set(pairs)))
86+
87+
def test_cw_code_commands_round_trip(self):
88+
"""CW_CODE_COMMANDS and CW_COMMANDS_CODE should be inverses."""
89+
for code, name in State402.CW_CODE_COMMANDS.items():
90+
self.assertEqual(State402.CW_COMMANDS_CODE[name], code)
91+
for name, code in State402.CW_COMMANDS_CODE.items():
92+
self.assertEqual(State402.CW_CODE_COMMANDS[code], name)
93+
94+
def test_next_state_indirect_from_all_known_states(self):
95+
"""Every known state should have an indirect next state."""
96+
for state in State402.SW_MASK:
97+
result = State402.next_state_indirect(state)
98+
# All states except OPERATION ENABLED should have a path
99+
if state != "OPERATION ENABLED":
100+
self.assertIsNotNone(result, f"No indirect path from {state}")
101+
self.assertIn(
102+
result,
103+
State402.SW_MASK,
104+
f"Indirect state {result} is not a known state",
105+
)
106+
107+
def test_next_state_indirect_specific_paths(self):
108+
self.assertEqual(
109+
State402.next_state_indirect("SWITCH ON DISABLED"), "READY TO SWITCH ON"
110+
)
111+
self.assertEqual(State402.next_state_indirect("READY TO SWITCH ON"), "SWITCHED ON")
112+
self.assertEqual(State402.next_state_indirect("SWITCHED ON"), "OPERATION ENABLED")
113+
self.assertEqual(State402.next_state_indirect("FAULT"), "SWITCH ON DISABLED")
114+
self.assertEqual(State402.next_state_indirect("FAULT REACTION ACTIVE"), "FAULT")
115+
self.assertEqual(
116+
State402.next_state_indirect("QUICK STOP ACTIVE"), "SWITCH ON DISABLED"
117+
)
118+
119+
def test_next_state_indirect_unknown_state(self):
120+
self.assertIsNone(State402.next_state_indirect("NONEXISTENT"))
121+
122+
def test_transition_table_keys_are_valid_states(self):
123+
known = set(State402.SW_MASK.keys()) | {"START", "DISABLE VOLTAGE"}
124+
for from_state, to_state in State402.TRANSITIONTABLE:
125+
self.assertIn(from_state, known, f"Unknown from-state: {from_state}")
126+
self.assertIn(to_state, known, f"Unknown to-state: {to_state}")
127+
128+
129+
class TestBaseNode402State(unittest.TestCase):
130+
"""Test state property reading from simulated TPDO reception."""
131+
132+
def setUp(self):
133+
self.node = BaseNode402(1, _make_od())
134+
135+
def _inject_statusword(self, sw):
136+
"""Simulate TPDO reception with the given statusword."""
137+
_inject_tpdo(self.node, 0x6041, sw)
138+
139+
def test_state_from_statusword(self):
140+
"""Verify all state decoding from TPDO-received statusword."""
141+
test_cases = [
142+
(0x0000, "NOT READY TO SWITCH ON"),
143+
(0x0040, "SWITCH ON DISABLED"),
144+
(0x0021, "READY TO SWITCH ON"),
145+
(0x0023, "SWITCHED ON"),
146+
(0x0027, "OPERATION ENABLED"),
147+
(0x0008, "FAULT"),
148+
(0x000F, "FAULT REACTION ACTIVE"),
149+
(0x0007, "QUICK STOP ACTIVE"),
150+
]
151+
for sw, expected_state in test_cases:
152+
with self.subTest(statusword=hex(sw)):
153+
self._inject_statusword(sw)
154+
self.assertEqual(self.node.state, expected_state)
155+
156+
def test_state_unknown_statusword(self):
157+
self._inject_statusword(0xFFFF)
158+
self.assertEqual(self.node.state, "UNKNOWN")
159+
160+
def test_is_faulted_true(self):
161+
self._inject_statusword(0x0008) # FAULT
162+
self.assertTrue(self.node.is_faulted())
163+
164+
def test_is_faulted_false(self):
165+
self._inject_statusword(0x0040) # SWITCH ON DISABLED
166+
self.assertFalse(self.node.is_faulted())
167+
168+
def test_controlword_read_raises(self):
169+
with self.assertRaises(RuntimeError):
170+
_ = self.node.controlword
171+
172+
def test_state_transition_sequence(self):
173+
"""Walk through the state machine by injecting TPDO statusword updates."""
174+
expected_sequence = [
175+
(0x0040, "SWITCH ON DISABLED"),
176+
(0x0021, "READY TO SWITCH ON"),
177+
(0x0023, "SWITCHED ON"),
178+
(0x0027, "OPERATION ENABLED"),
179+
]
180+
for sw, expected_state in expected_sequence:
181+
self._inject_statusword(sw)
182+
self.assertEqual(self.node.state, expected_state)
183+
184+
def test_state_fault_recovery_sequence(self):
185+
"""Simulate a fault recovery path via TPDO updates."""
186+
self._inject_statusword(0x000F) # FAULT REACTION ACTIVE
187+
self.assertEqual(self.node.state, "FAULT REACTION ACTIVE")
188+
self._inject_statusword(0x0008) # FAULT
189+
self.assertEqual(self.node.state, "FAULT")
190+
self._inject_statusword(0x0040) # SWITCH ON DISABLED
191+
self.assertEqual(self.node.state, "SWITCH ON DISABLED")
192+
193+
def test_statusword_sdo_fallback(self):
194+
"""When no TPDO value is cached, statusword falls back to SDO."""
195+
node = BaseNode402(1, _make_od())
196+
node.sdo = MagicMock()
197+
node.sdo.__getitem__ = MagicMock(return_value=MagicMock(raw=0x0040))
198+
self.assertEqual(node.statusword, 0x0040)
199+
200+
def test_check_statusword_periodic_tpdo(self):
201+
"""check_statusword waits for periodic TPDO reception."""
202+
self._inject_statusword(0x0040)
203+
tpdo_ptr = MagicMock()
204+
tpdo_ptr.pdo_parent.is_periodic = True
205+
tpdo_ptr.pdo_parent.wait_for_reception.return_value = 1234.0
206+
self.node.tpdo_pointers[0x6041] = tpdo_ptr # type: ignore[assignment]
207+
result = self.node.check_statusword()
208+
tpdo_ptr.pdo_parent.wait_for_reception.assert_called_once()
209+
self.assertEqual(result, 0x0040)
210+
211+
def test_check_statusword_periodic_tpdo_timeout(self):
212+
"""check_statusword raises on TPDO reception timeout."""
213+
tpdo_ptr = MagicMock()
214+
tpdo_ptr.pdo_parent.is_periodic = True
215+
tpdo_ptr.pdo_parent.wait_for_reception.return_value = None
216+
self.node.tpdo_pointers[0x6041] = tpdo_ptr # type: ignore[assignment]
217+
with self.assertRaises(RuntimeError):
218+
self.node.check_statusword()
219+
220+
def test_check_statusword_non_periodic_tpdo(self):
221+
"""check_statusword reads SDO for non-periodic TPDO."""
222+
tpdo_ptr = MagicMock()
223+
tpdo_ptr.pdo_parent.is_periodic = False
224+
self.node.tpdo_pointers[0x6041] = tpdo_ptr # type: ignore[assignment]
225+
self.node.sdo = MagicMock()
226+
self.node.sdo.__getitem__ = MagicMock(return_value=MagicMock(raw=0x0023))
227+
result = self.node.check_statusword()
228+
self.assertEqual(result, 0x0023)
229+
230+
231+
class TestBaseNode402StateTransition(unittest.TestCase):
232+
"""Test state machine transitions with simulated TPDO/RPDO."""
233+
234+
def setUp(self):
235+
self.node = BaseNode402(1, _make_od())
236+
self.cw_log = []
237+
238+
def _on_cw_write(value):
239+
self.cw_log.append(value)
240+
# Simulate drive response: look up the transition and inject statusword
241+
current = self.node.state
242+
for (from_s, to_s), cw in State402.TRANSITIONTABLE.items():
243+
if cw == value and from_s == current:
244+
if to_s in State402.SW_MASK:
245+
_, bits = State402.SW_MASK[to_s]
246+
self.node.tpdo_values[0x6041] = bits
247+
return
248+
249+
self.node.rpdo_pointers[0x6040] = _FakeRpdoVar(_on_cw_write) # type: ignore[assignment]
250+
251+
def test_transition_to_operation_enabled(self):
252+
"""Walk from SWITCH ON DISABLED to OPERATION ENABLED via state setter."""
253+
_inject_tpdo(self.node, 0x6041, 0x0040) # SWITCH ON DISABLED
254+
self.node.state = "OPERATION ENABLED"
255+
self.assertEqual(self.node.state, "OPERATION ENABLED")
256+
self.assertEqual(
257+
self.cw_log,
258+
[
259+
State402.CW_SHUTDOWN,
260+
State402.CW_SWITCH_ON,
261+
State402.CW_OPERATION_ENABLED,
262+
],
263+
)
264+
265+
def test_transition_from_fault(self):
266+
"""Walk from FAULT to SWITCH ON DISABLED via state setter."""
267+
_inject_tpdo(self.node, 0x6041, 0x0008) # FAULT
268+
self.node.state = "SWITCH ON DISABLED"
269+
self.assertEqual(self.node.state, "SWITCH ON DISABLED")
270+
self.assertEqual(self.cw_log, [State402.CW_SWITCH_ON_DISABLED])
271+
272+
def test_transition_quick_stop(self):
273+
"""Transition from OPERATION ENABLED to QUICK STOP ACTIVE."""
274+
_inject_tpdo(self.node, 0x6041, 0x0027) # OPERATION ENABLED
275+
self.node.state = "QUICK STOP ACTIVE"
276+
self.assertEqual(self.node.state, "QUICK STOP ACTIVE")
277+
self.assertEqual(self.cw_log, [State402.CW_QUICK_STOP])
278+
279+
def test_transition_fault_to_operation_enabled(self):
280+
"""Full path from FAULT through to OPERATION ENABLED."""
281+
_inject_tpdo(self.node, 0x6041, 0x0008) # FAULT
282+
self.node.state = "OPERATION ENABLED"
283+
self.assertEqual(self.node.state, "OPERATION ENABLED")
284+
self.assertEqual(
285+
self.cw_log,
286+
[
287+
State402.CW_SWITCH_ON_DISABLED,
288+
State402.CW_SHUTDOWN,
289+
State402.CW_SWITCH_ON,
290+
State402.CW_OPERATION_ENABLED,
291+
],
292+
)
293+
294+
def test_illegal_target_fault_raises(self):
295+
_inject_tpdo(self.node, 0x6041, 0x0040) # SWITCH ON DISABLED
296+
with self.assertRaises(ValueError):
297+
self.node.state = "FAULT"
298+
299+
def test_illegal_target_not_ready_raises(self):
300+
_inject_tpdo(self.node, 0x6041, 0x0040) # SWITCH ON DISABLED
301+
with self.assertRaises(ValueError):
302+
self.node.state = "NOT READY TO SWITCH ON"
303+
304+
def test_illegal_target_fault_reaction_raises(self):
305+
_inject_tpdo(self.node, 0x6041, 0x0040) # SWITCH ON DISABLED
306+
with self.assertRaises(ValueError):
307+
self.node.state = "FAULT REACTION ACTIVE"
308+
309+
def test_controlword_sdo_fallback(self):
310+
"""Controlword write falls back to SDO without RPDO configured."""
311+
node = BaseNode402(1, _make_od())
312+
node.sdo = MagicMock()
313+
node.controlword = 0x0006
314+
self.assertEqual(node.sdo[0x6040].raw, 0x0006)
315+
316+
317+
class TestBaseNode402OpMode(unittest.TestCase):
318+
"""Test operation mode reading via simulated TPDO."""
319+
320+
def setUp(self):
321+
self.node = BaseNode402(1, _make_od())
322+
tpdo_ptr = MagicMock()
323+
tpdo_ptr.pdo_parent.is_periodic = False
324+
self.node.tpdo_pointers[0x6061] = tpdo_ptr # type: ignore[assignment]
325+
326+
def test_op_mode_read_all(self):
327+
"""Read each operation mode from TPDO-cached value."""
328+
for code, name in OperationMode.CODE2NAME.items():
329+
with self.subTest(code=code):
330+
_inject_tpdo(self.node, 0x6061, code)
331+
self.assertEqual(self.node.op_mode, name)
332+
333+
def test_op_mode_sdo_fallback(self):
334+
"""op_mode reads from SDO when TPDO pointer is missing."""
335+
node = BaseNode402(1, _make_od())
336+
node.sdo = MagicMock()
337+
node.sdo.__getitem__ = MagicMock(
338+
return_value=MagicMock(raw=OperationMode.PROFILED_POSITION)
339+
)
340+
self.assertEqual(node.op_mode, "PROFILED POSITION")
341+
342+
def test_op_mode_periodic_tpdo(self):
343+
"""op_mode waits for periodic TPDO then reads cached value."""
344+
tpdo_ptr = MagicMock()
345+
tpdo_ptr.pdo_parent.is_periodic = True
346+
tpdo_ptr.pdo_parent.wait_for_reception.return_value = 1234.0
347+
self.node.tpdo_pointers[0x6061] = tpdo_ptr # type: ignore[assignment]
348+
_inject_tpdo(self.node, 0x6061, OperationMode.HOMING)
349+
self.assertEqual(self.node.op_mode, "HOMING")
350+
351+
def test_op_mode_periodic_tpdo_timeout(self):
352+
"""op_mode raises RuntimeError on TPDO reception timeout."""
353+
tpdo_ptr = MagicMock()
354+
tpdo_ptr.pdo_parent.is_periodic = True
355+
tpdo_ptr.pdo_parent.wait_for_reception.return_value = None
356+
self.node.tpdo_pointers[0x6061] = tpdo_ptr # type: ignore[assignment]
357+
with self.assertRaises(RuntimeError):
358+
_ = self.node.op_mode
359+
360+
361+
class TestOperationMode(unittest.TestCase):
362+
"""Test OperationMode lookup tables."""
363+
364+
def test_code2name_name2code_round_trip(self):
365+
for code, name in OperationMode.CODE2NAME.items():
366+
self.assertEqual(OperationMode.NAME2CODE[name], code)
367+
368+
def test_all_named_modes_have_support_bit(self):
369+
for name in OperationMode.NAME2CODE:
370+
self.assertIn(name, OperationMode.SUPPORTED)
371+
372+
373+
if __name__ == "__main__":
374+
unittest.main()

0 commit comments

Comments
 (0)