feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""Targeted attachment checks preserve generation and topology rejection."""
|
||||
from pathlib import Path
|
||||
import sys,tempfile,unittest
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.serial import Attachment, attachment_at, check_attachment, discover
|
||||
|
||||
class AttachmentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name);self.device=self.root/'1-2.3';self.device.mkdir()
|
||||
for name,value in {'idVendor':'0483','idProduct':'5740','product':'ChibiOS/RT Virtual COM Port','devnum':'17','speed':'12'}.items():
|
||||
(self.device/name).write_text(value)
|
||||
(self.device/'1-2.3:1.0/tty/ttyACM2').mkdir(parents=True)
|
||||
self.expected=Attachment('1-2.3','17','ttyACM2','12 Мбит/с')
|
||||
|
||||
def test_target_check_never_enumerates_siblings(self):
|
||||
original=Path.iterdir
|
||||
def entries(path):
|
||||
if path==self.root:raise AssertionError('full bus scan in per-request check')
|
||||
return original(path)
|
||||
with patch.object(Path,'iterdir',entries):check_attachment(self.expected,self.root)
|
||||
self.assertEqual(discover(self.root),[self.expected])
|
||||
|
||||
def test_generation_change_or_removed_port_invalidates_old_owner(self):
|
||||
(self.device/'devnum').write_text('18')
|
||||
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||
(self.device/'devnum').write_text('17')
|
||||
(self.device/'1-2.3:1.0/tty/ttyACM2').rmdir()
|
||||
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||
|
||||
def test_ambiguous_tty_or_other_vendor_rejected(self):
|
||||
extra=self.device/'1-2.3:1.1/tty/ttyACM3';extra.mkdir(parents=True)
|
||||
self.assertIsNone(attachment_at(self.device))
|
||||
extra.rmdir();(self.device/'idVendor').write_text('1234')
|
||||
self.assertIsNone(attachment_at(self.device))
|
||||
|
||||
def test_driver_sibling_tty_cannot_be_mistaken_for_device_interface(self):
|
||||
(self.device/'driver/tty/ttyACM9').mkdir(parents=True)
|
||||
self.assertEqual(attachment_at(self.device),self.expected)
|
||||
with self.assertRaises(OSError):check_attachment(Attachment('../1-2.3','17','ttyACM2','12 Мбит/с'),self.root)
|
||||
|
||||
def test_generation_change_during_read_is_rejected(self):
|
||||
original=Path.read_text;reads=0
|
||||
def read(path,*args,**kwargs):
|
||||
nonlocal reads
|
||||
if path==self.device/'devnum':
|
||||
reads+=1;return '17' if reads==1 else '18'
|
||||
return original(path,*args,**kwargs)
|
||||
with patch.object(Path,'read_text',read):self.assertIsNone(attachment_at(self.device))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""User-assigned drive layout, persistence and conflict tests; no motor I/O."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
import test_reader
|
||||
|
||||
|
||||
class DriveTests(test_reader.ServiceTests):
|
||||
def assign(self, device, slot, layout='1x1', revision=None):
|
||||
item=next(i for i in self.service.inventory('node_synthetic')['items'] if i['id']==device.id)
|
||||
command=self.command(item,action='vesc.drive.assign')
|
||||
command['parameters']={'layout':layout,'slot':slot,'revision':self.service.drive.value['revision'] if revision is None else revision}
|
||||
return self.service.execute(command)
|
||||
|
||||
def test_assign_is_user_metadata_without_serial_commands_and_survives_restart(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assertEqual(self.assign(left,'left.1')['state'],'complete')
|
||||
self.assertEqual(self.assign(right,'right.1')['state'],'complete')
|
||||
self.assertTrue(all(link.commands==[0] for link in test_reader.FakeLink.instances))
|
||||
from runtime.drive_profile import DriveProfile
|
||||
restored=DriveProfile(self.service.root,self.service.drive.atomic)
|
||||
self.assertEqual(restored.value,self.service.drive.value)
|
||||
self.assertEqual(restored.value['layout'],'1x1')
|
||||
self.assertEqual(restored.value['bindings']['left.1']['device_id'],left.id)
|
||||
self.assertEqual(self.service.drive.path.stat().st_mode & 0o777,0o600)
|
||||
|
||||
def test_duplicate_slot_and_stale_revision_never_replace_an_assignment(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assign(left,'left.1')
|
||||
self.assertEqual(self.assign(right,'left.1')['state'],'error')
|
||||
self.assertEqual(self.assign(right,'right.1',revision=0)['state'],'error')
|
||||
self.assertEqual(list(self.service.drive.value['bindings']),['left.1'])
|
||||
|
||||
def test_layout_shrink_requires_explicit_unassign_of_removed_slots(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assign(left,'left.2','2x2')
|
||||
self.assign(right,'right.1','2x2')
|
||||
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'error')
|
||||
command=self.command(action='vesc.drive.unassign')
|
||||
command['parameters']={'slot':'left.2','revision':self.service.drive.value['revision']}
|
||||
self.assertEqual(self.service.execute(command)['state'],'complete')
|
||||
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'complete')
|
||||
self.assertEqual(list(self.service.drive.value['bindings']),['right.1'])
|
||||
|
||||
def test_reassign_moves_one_controller_and_replay_does_not_repeat(self):
|
||||
device=next(iter(self.service.devices.values()))
|
||||
self.assign(device,'left.1')
|
||||
command=self.command(action='vesc.drive.assign')
|
||||
command['parameters']={'layout':'2x2','slot':'right.2','revision':1}
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(list(result['result']['bindings']),['right.2'])
|
||||
self.assertEqual(self.service.execute(command),result)
|
||||
self.assertEqual(self.service.drive.value['revision'],2)
|
||||
|
||||
test_two_attachments_promote_independently = None
|
||||
test_uuid_collision_never_aliases_a_controller = None
|
||||
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||
test_receipt_replay_never_repeats_serial_query = None
|
||||
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Fault injection at the native calibration transaction boundary; no hardware."""
|
||||
from datetime import datetime, timedelta
|
||||
import unittest
|
||||
import struct
|
||||
|
||||
import test_motor_test
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
|
||||
|
||||
class CalibrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
c = self.case
|
||||
self.original = configuration('motor', {**decode(c.motor, 'motor'),
|
||||
'l_current_min': -60, 'l_in_current_min': -55, 'foc_openloop_rpm': 600, 'foc_sl_erpm': 2000})
|
||||
self.configs = {d.id: self.original for d in c.devices}
|
||||
self.starts = []
|
||||
self.finished = False
|
||||
for device in c.devices:
|
||||
previous = device.link.query
|
||||
def query(code, timeout=2, previous=previous, device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
if code == 62: return bytes([62])
|
||||
return previous(code, timeout)
|
||||
device.link.query = query
|
||||
def start(loss, device=device):
|
||||
self.starts.append((device.id, loss))
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.original, 'motor'),
|
||||
'foc_motor_r': 0.08, 'foc_motor_l': 0.00003, 'foc_motor_flux_linkage': 0.009, 'foc_sensor_mode': 0})
|
||||
device.link.calibrate_foc = start
|
||||
def result():
|
||||
if not self.finished:
|
||||
self.finished = True
|
||||
return {'running': True}
|
||||
return {'running': False, 'uncertain': False, 'result': {
|
||||
'kind': 'foc', 'completed': True, 'success': True, 'validated': True, 'code': 0, 'sensor_mode': 0}}
|
||||
device.link.procedure_result = result
|
||||
|
||||
def command(self):
|
||||
c = self.case.command(action='vesc.foc.calibrate')
|
||||
c['deadline_at'] = (datetime.fromisoformat(c['requested_at']) + timedelta(seconds=300)).isoformat()
|
||||
c['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True, 'max_power_loss_w': 50,
|
||||
'sessions': {d.id: d.session for d in self.case.devices}}
|
||||
return c
|
||||
|
||||
def test_native_success_archives_both_versions_and_never_sends_host_torque(self):
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
result = receipt['result']
|
||||
self.assertTrue(result['success'], receipt)
|
||||
self.assertEqual(result['native']['sensor_mode'], 0)
|
||||
self.assertEqual(self.starts, [(self.case.devices[0].id, 50)])
|
||||
self.assertEqual(len(result['backups']), 2)
|
||||
self.assertEqual(len(result['after_backups']), 2)
|
||||
self.assertEqual(self.case.currents, [])
|
||||
self.assertTrue(all(action == 'release' for _, action in self.case.sent))
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_unknown_completion_persists_across_service_restart_and_blocks_new_motion(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['completed'])
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
from runtime.service import Service
|
||||
restarted = Service(self.case.service.root, discover_fn=self.case.service.discover_fn)
|
||||
self.assertTrue(restarted.motor.latched)
|
||||
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_peer_configuration_change_is_archived_and_blocks_further_movement(self):
|
||||
start = self.case.devices[0].link.calibrate_foc
|
||||
def mutate(loss):
|
||||
start(loss)
|
||||
self.configs[self.case.devices[1].id] = configuration('motor', {**decode(self.original, 'motor'), 'foc_motor_r': 1})
|
||||
self.case.devices[0].link.calibrate_foc = mutate
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['success'])
|
||||
self.assertFalse(result['configuration_verified'])
|
||||
self.assertEqual(len(result['after_backups']), 2)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
|
||||
def test_known_can_peers_still_block_upstream_broadcast_side_effect(self):
|
||||
target = self.case.devices[0]
|
||||
query = target.link.query
|
||||
target.link.query = lambda code, timeout=2: bytes([62, 11]) if code == 62 else query(code, timeout)
|
||||
result = self.case.service.execute(self.command())
|
||||
self.assertEqual(result['state'], 'error')
|
||||
self.assertEqual(self.starts, [])
|
||||
|
||||
def test_confirmed_failed_cycle_restores_and_can_be_retried_explicitly(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.calibrate_foc = lambda loss: self.starts.append((target.id, loss))
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': False, 'result': {
|
||||
'completed': True, 'success': False, 'validated': True, 'code': -10}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue(result['configuration_verified'])
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
|
||||
def test_missing_native_write_ack_never_accepts_calibration(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {
|
||||
'completed': True, 'success': False, 'validated': False, 'code': 0, 'error': 'ACK missing'}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
|
||||
def test_calibration_heating_budget_is_finite_and_explicit(self):
|
||||
for loss in (True, float('nan'), float('inf'), 0, 151):
|
||||
command = self.command(); command['parameters']['max_power_loss_w'] = loss
|
||||
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||
self.assertEqual(self.starts, [])
|
||||
|
||||
def averaged_current(self, transient=True):
|
||||
target = self.case.devices[0]
|
||||
query = target.link.query
|
||||
reads = []
|
||||
def read(code, timeout=2):
|
||||
raw = query(code, timeout)
|
||||
if code == 4 and self.starts:
|
||||
reads.append(1)
|
||||
if not transient or len(reads) == 1:
|
||||
return raw[:5] + struct.pack('>i', 367) + raw[9:]
|
||||
return raw
|
||||
target.link.query = read
|
||||
return query, reads
|
||||
|
||||
def test_cycle_average_is_drained_before_verifying_released_current(self):
|
||||
_, reads = self.averaged_current()
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertTrue(result['success'], result)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertGreaterEqual(len(reads), 2)
|
||||
self.assertEqual(result['after'][self.case.devices[0].id]['motor_current_a'], 0)
|
||||
|
||||
def test_explicit_neutral_recovery_after_verified_cycle_never_recalibrates(self):
|
||||
query, _ = self.averaged_current(transient=False)
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
self.assertFalse(receipt['result']['release_confirmed'])
|
||||
self.assertTrue(receipt['result']['configuration_verified'])
|
||||
self.case.devices[0].link.query = query
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
result = self.case.service.execute(release)['result']
|
||||
self.assertEqual(result['authority'], 'ready')
|
||||
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(self.case.currents, [])
|
||||
|
||||
def test_recovery_rejects_new_configuration_and_does_not_overwrite_it(self):
|
||||
query, _ = self.averaged_current(transient=False)
|
||||
self.case.service.execute(self.command())
|
||||
self.case.devices[0].link.query = query
|
||||
key = self.case.devices[0].id
|
||||
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
|
||||
before = self.configs[key]
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
receipt = self.case.service.execute(release)
|
||||
self.assertEqual(receipt['state'], 'error')
|
||||
self.assertEqual(self.configs[key], before)
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_unknown_completion_cannot_be_cleared_by_neutral_return(self):
|
||||
self.case.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||
self.case.service.execute(self.command())
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
self.assertEqual(self.case.service.execute(release)['state'], 'error')
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Synthetic complete-profile motion, shared timing and all-peer failure tests."""
|
||||
import struct
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import test_motor_test
|
||||
import test_reader
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
from runtime.drive_profile import LAYOUTS
|
||||
from runtime.temporary_limits import FIELDS
|
||||
|
||||
|
||||
class GroupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
self.configure(2)
|
||||
|
||||
def configure(self, count):
|
||||
c = self.case
|
||||
if len(c.devices) != count:
|
||||
c.attachments = [test_reader.Attachment(f'2-{i+1}', str(i+1), f'ttyACM{i}', '12') for i in range(count)]
|
||||
c.service.scan(); c.configure_controllers()
|
||||
self.configs, self.speeds, self.commands, self.tacho, self.sets = {}, {}, [], {}, []
|
||||
self.motion = lambda device: True
|
||||
for device in c.devices:
|
||||
self.configs[device.id] = configuration('motor', {**decode(c.motor, 'motor'),
|
||||
'l_current_min': -60, 'l_current_min_scale': 1, 'l_current_max_scale': 1})
|
||||
original = device.link.query
|
||||
def query(code, timeout=2, original=original, device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
raw = original(code, timeout)
|
||||
if code == 4 and device.id in self.speeds and self.motion(device):
|
||||
self.tacho[device.id] = self.tacho.get(device.id, 0)+6
|
||||
raw = raw[:23]+struct.pack('>i', round(self.speeds[device.id]))+raw[27:]
|
||||
raw = raw[:45]+struct.pack('>ii', self.tacho[device.id], self.tacho[device.id])+raw[53:]
|
||||
return raw
|
||||
device.link.query = query
|
||||
def limits(config, device=device):
|
||||
self.sets.append(device.id)
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'),
|
||||
**{k: config[k] for k in FIELDS}})
|
||||
device.link.set_temporary_limits = limits
|
||||
def speed(value, device=device):
|
||||
self.speeds[device.id] = value
|
||||
self.commands.append((device.id, value))
|
||||
device.link.test_speed = speed
|
||||
self.originals = dict(self.configs)
|
||||
layout = '1x1' if count == 2 else '2x2'
|
||||
c.service.drive.value = {'layout': layout, 'revision': 4,
|
||||
'bindings': {slot: {'device_id': d.id, 'uuid': d.identity['uuid']}
|
||||
for slot, d in zip(LAYOUTS[layout], c.devices)}}
|
||||
|
||||
def command(self):
|
||||
command = self.case.command_for_pulse()
|
||||
command['action_id'] = 'vesc.drive.run'
|
||||
command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4,
|
||||
device_ids=[d.id for d in self.case.devices])
|
||||
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
|
||||
return command
|
||||
|
||||
def test_pair_and_four_motors_run_one_common_interval_and_restore(self):
|
||||
for count in (2, 4):
|
||||
with self.subTest(count=count):
|
||||
self.configure(count)
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
result = receipt['result']
|
||||
self.assertEqual(result['outcome'], 'duration', result)
|
||||
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||
self.assertLess(result['rotation_s'], 3.2)
|
||||
self.assertEqual(set(self.speeds), {d.id for d in self.case.devices})
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
before = list(self.commands)
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertEqual(before, self.commands)
|
||||
|
||||
def test_count_starts_only_after_slower_motor_reaches_speed(self):
|
||||
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now >= 8
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['outcome'], 'duration')
|
||||
first = next(s['at'] for s in result['samples'] if s['rotation_s'] > 0)
|
||||
self.assertGreater(first, 7.5)
|
||||
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||
|
||||
def test_stationary_peer_never_counts_single_motor_as_joint_rotation(self):
|
||||
self.motion = lambda d: d is self.case.devices[0]
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['rotation_s'], 0)
|
||||
self.assertIn('не вышел', result['outcome'])
|
||||
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_rc_preemption_releases_every_motor_and_latches(self):
|
||||
original = self.case.devices[0].link.test_speed
|
||||
def speed(value):
|
||||
original(value); self.case.changed = True
|
||||
self.case.devices[0].link.test_speed = speed
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['rotation_s'], 0)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertFalse(result['limits_restored'])
|
||||
self.assertTrue(list(self.case.service.root.glob('limits_*.json')))
|
||||
self.case.changed = False
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_losing_one_motor_stops_joint_timer_and_releases_pair(self):
|
||||
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now < 7
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertLess(result['rotation_s'], 3)
|
||||
self.assertIn('перестал удерживать', result['outcome'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_failed_peer_lease_never_sends_speed_and_keeps_cause(self):
|
||||
original = self.case.devices[1].link.test_command
|
||||
def send(action):
|
||||
if action == 'claim': raise TimeoutError('synthetic lease timeout')
|
||||
original(action)
|
||||
self.case.devices[1].link.test_command = send
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(result['failure']['message'], 'synthetic lease timeout')
|
||||
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_second_limit_ack_failure_restores_both_without_motion(self):
|
||||
original = self.case.devices[1].link.set_temporary_limits
|
||||
def apply(config):
|
||||
original(config)
|
||||
if len(self.sets) == 2: raise TimeoutError('lost second ACK')
|
||||
self.case.devices[1].link.set_temporary_limits = apply
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_stale_incomplete_duplicate_or_changed_profile_never_runs(self):
|
||||
command = self.command(); command['parameters']['profile_revision'] -= 1
|
||||
self.assertEqual(self.case.service.execute(command)['state'], 'error')
|
||||
command = self.command(); command['parameters']['device_ids'] *= 2
|
||||
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||
self.case.service.drive.value['bindings'].pop('right.1')
|
||||
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(self.sets, [])
|
||||
|
||||
def test_current_bound_is_checked_for_every_motor(self):
|
||||
device = self.case.devices[1]
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'), 'l_current_max': 10})
|
||||
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(self.sets, [])
|
||||
@@ -0,0 +1,87 @@
|
||||
"""No-power read measurements and failure attribution; no USB hardware."""
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
import test_motor_test
|
||||
from runtime.link_check import measure, summary
|
||||
|
||||
|
||||
class LinkCheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case=test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp(); self.addCleanup(self.case.tearDown)
|
||||
self.reads=[]
|
||||
for device in self.case.devices:
|
||||
original=device.link.query
|
||||
def query(code,timeout=2,original=original,device=device):
|
||||
self.reads.append((device.id,code,timeout))
|
||||
return original(code,timeout)
|
||||
device.link.query=query
|
||||
|
||||
def command(self):
|
||||
c=self.case.command_for_pulse();c['action_id']='vesc.link.check'
|
||||
c['parameters']={'sessions':c['parameters']['sessions']}
|
||||
return c
|
||||
|
||||
def run_check(self):
|
||||
return measure(self.case.service,self.command(),self.case.devices,
|
||||
sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||
|
||||
def test_reads_pair_at_group_cadence_without_any_motor_commands(self):
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'complete')
|
||||
self.assertFalse(result['motor_commands_sent'])
|
||||
self.assertEqual(len(self.reads),400)
|
||||
self.assertEqual({r[1] for r in self.reads},{31,4})
|
||||
self.assertTrue(all(r[2]==.5 for r in self.reads))
|
||||
self.assertEqual(self.case.sent,[])
|
||||
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
|
||||
self.assertGreaterEqual(result['duration_s'],9.9)
|
||||
|
||||
def test_late_replies_are_measured_without_relaxing_motor_budget(self):
|
||||
result=summary([{'elapsed_ms':x} for x in [20,30,55,80,120]])
|
||||
self.assertEqual(result['over_60_ms'],2)
|
||||
self.assertEqual(result['p50_ms'],55)
|
||||
self.assertEqual(result['max_ms'],120)
|
||||
|
||||
def test_no_response_keeps_peer_command_and_native_cause(self):
|
||||
device=self.case.devices[1]
|
||||
def query(code,timeout=2):
|
||||
error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True}
|
||||
raise error
|
||||
device.link.query=query
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'read_failed')
|
||||
self.assertEqual(result['failure'][0]['device_id'],device.id)
|
||||
self.assertEqual(result['failure'][0]['command'],31)
|
||||
self.assertTrue(result['failure'][0]['native_rpc']['attachment_present'])
|
||||
self.assertEqual(self.case.sent,[])
|
||||
|
||||
def test_active_receiver_stops_measurement_without_claiming_control(self):
|
||||
self.case.changed=True
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'not_idle')
|
||||
self.assertEqual(self.case.sent,[])
|
||||
self.assertEqual({r[1] for r in self.reads},{31})
|
||||
|
||||
def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self):
|
||||
command=self.command()
|
||||
original=measure
|
||||
def fast(*args):return original(*args,sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||
with patch('runtime.link_check.measure',side_effect=fast):
|
||||
result=self.case.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(self.case.service.execute(command),result)
|
||||
self.assertEqual(len(self.reads),400)
|
||||
self.case.service.operation_lock.acquire()
|
||||
try:
|
||||
with self.assertRaises(ValueError):self.case.service.execute(self.command())
|
||||
finally:self.case.service.operation_lock.release()
|
||||
|
||||
def test_changed_sessions_rejected_before_reads_and_stop_is_respected(self):
|
||||
command=self.command();command['parameters']['sessions']={}
|
||||
with self.assertRaises(ValueError): measure(self.case.service,command,self.case.devices)
|
||||
self.assertEqual(self.reads,[])
|
||||
command=self.command();self.case.service.motor.cancel()
|
||||
result=measure(self.case.service,command,self.case.devices)
|
||||
self.assertEqual(result['outcome'],'stopped')
|
||||
self.assertEqual(self.reads,[])
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Synthetic fault-injection tests. No real ports, private config or motors."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.configuration import decode, crc32c
|
||||
from runtime.motor_test import Rejected, LimitExceeded, check_configuration, check_values
|
||||
from runtime.protocol import Decoder, test_packet, ppm, current_packet
|
||||
import test_reader
|
||||
from runtime.protocol import firmware
|
||||
from test_reader import telemetry
|
||||
|
||||
|
||||
def configuration(kind, overrides):
|
||||
code, name = {"motor": (14, "mcconf"), "application": (17, "appconf")}[kind]
|
||||
xml = ET.parse(Path(__file__).parents[1] / "runtime/schemas/5.02" / ("parameters_" + name + ".xml")).getroot()
|
||||
params = {p.tag: p for p in xml.find("Params")}; order = [p.text for p in xml.find("SerOrder")]
|
||||
sig = ''.join(n + params[n].findtext('type', '0') + params[n].findtext('vTx', '0') + ''.join(x.text or '' for x in params[n].findall('enumNames')) for n in order)
|
||||
out = bytes([code]) + crc32c(sig.encode()).to_bytes(4, 'big')
|
||||
for n in order:
|
||||
p=params[n];typ=int(p.findtext('type'));tx=int(p.findtext('vTx','0'));v=overrides.get(n,0)
|
||||
if typ in (4,5):fmt='b'
|
||||
elif typ==6:fmt='B'
|
||||
elif typ==2:fmt={1:'B',2:'b',3:'H',4:'h',5:'I',6:'i'}[tx]
|
||||
else:
|
||||
fmt={7:'h',8:'i',9:'I'}[tx]
|
||||
if tx==9:
|
||||
f,e=math.frexp(abs(v));v=0 if v==0 else ((e+126)<<23)|int((f-0.5)*16777216)|(0x80000000 if v<0 else 0)
|
||||
else:v=round(v*float(p.findtext('vTxDoubleScale','1')))
|
||||
out+=struct.pack('>'+fmt,v)
|
||||
return out
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):self.now=0
|
||||
def monotonic(self):return self.now
|
||||
def sleep(self,value):self.now+=value
|
||||
|
||||
|
||||
class PulseTests(test_reader.ServiceTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.configure_controllers()
|
||||
|
||||
def configure_controllers(self):
|
||||
self.clock=Clock();self.service.motor.sleep=self.clock.sleep;self.service.motor.monotonic=self.clock.monotonic
|
||||
self.devices=list(self.service.devices.values());self.changed=False
|
||||
self.sent=[]
|
||||
self.currents=[]
|
||||
self.motor=configuration('motor',{'motor_type':2,'l_current_max':60,'l_in_current_max':55,'l_min_erpm':-60000,'l_max_erpm':60000,'l_max_duty':0.95})
|
||||
for i,device in enumerate(self.devices):
|
||||
raw=b'\0\5\2' + b'75_300_R2\0' + bytes([i+1])*12 + bytes(4)
|
||||
device.identity=firmware(raw)
|
||||
app=configuration('application',{'controller_id':i+10,'app_to_use':1,'timeout_msec':1000,'timeout_brake_current':0,'app_ppm_conf.ctrl_type':4,'app_ppm_conf.hyst':0.15})
|
||||
def query(code,timeout=2,raw=raw,app=app):
|
||||
if code==0:return raw
|
||||
if code==14:return self.motor
|
||||
if code==17:return app
|
||||
if code==4:return telemetry()[:1]+struct.pack('>hhiiiihihiiiiiiB',250,-729,0,0,0,0,0,0,500,0,0,0,0,0,0,0)
|
||||
if code==31:return bytes([31])+struct.pack('>ii',600000 if self.changed else 0,1500000)
|
||||
if code==62:return bytes([62])+bytes(range(10,10+len(self.devices)))
|
||||
raise AssertionError(code)
|
||||
device.link.query=query
|
||||
device.link.test_command=lambda action,i=i:self.sent.append((i,action))
|
||||
def send_current(amps,device=device,i=i):
|
||||
self.currents.append((i,amps))
|
||||
device.link.test_command('current')
|
||||
device.link.test_current=send_current
|
||||
|
||||
def command_for_pulse(self):
|
||||
c=self.command(action='vesc.motor.pulse')
|
||||
c['parameters']={'rig_clear':True,'sessions':{d.id:d.session for d in self.devices},'duration_s':1.5,'current_a':2}
|
||||
return c
|
||||
|
||||
def test_fixed_wire_commands_no_broadcast_and_no_parameters(self):
|
||||
self.assertEqual(Decoder().feed(test_packet('claim')),[bytes([63,0])+struct.pack('>i',250)])
|
||||
self.assertEqual(Decoder().feed(test_packet('current')),[bytes([6])+struct.pack('>i',2000)])
|
||||
self.assertEqual(Decoder().feed(current_packet(5)),[bytes([6])+struct.pack('>i',5000)])
|
||||
with self.assertRaises(KeyError):test_packet('arbitrary')
|
||||
with self.assertRaises(ValueError):ppm(bytes([31])+bytes(7))
|
||||
|
||||
def test_only_selected_motor_receives_current_and_receipt_never_replays(self):
|
||||
c=self.command_for_pulse();r=self.service.execute(c)
|
||||
self.assertEqual(r['state'],'complete',r)
|
||||
self.assertEqual(r['result']['outcome'],'duration')
|
||||
self.assertTrue(r['result']['release_confirmed'])
|
||||
self.assertIn((0,'current'),self.sent);self.assertNotIn((1,'current'),self.sent)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertLessEqual(len([x for x in self.sent if x==(0,'current')]),31)
|
||||
before=list(self.sent);self.assertEqual(self.service.execute(c),r);self.assertEqual(self.sent,before)
|
||||
self.assertEqual(len(list(self.service.root.glob('backup_*.json'))),2)
|
||||
|
||||
def test_active_rc_blocks_before_any_command_and_latches(self):
|
||||
self.changed=True;r=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(r['state'],'error');self.assertEqual(self.sent,[]);self.assertTrue(self.service.motor.latched)
|
||||
self.changed=False;self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||
|
||||
def test_rc_preemption_releases_both_and_blocks_next_click(self):
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.changed=True
|
||||
self.devices[0].link.test_command=send
|
||||
r=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(r['state'],'complete');self.assertTrue(self.service.motor.latched)
|
||||
self.assertEqual(len([x for x in self.sent if x==(0,'current')]),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
|
||||
def test_missing_second_lease_and_disconnect_release_first(self):
|
||||
def send(action):
|
||||
if action=='claim':raise OSError('Disconnected')
|
||||
self.sent.append((1,action))
|
||||
self.devices[1].link.test_command=send
|
||||
r=self.service.execute(self.command_for_pulse())
|
||||
self.assertNotIn((0,'current'),self.sent);self.assertIn((0,'release'),self.sent)
|
||||
self.assertNotEqual(r['result']['outcome'],'duration')
|
||||
|
||||
def test_bad_schema_stale_peer_and_no_rig_confirmation_never_write(self):
|
||||
self.assertRaises(ValueError,decode,self.motor[:-1],'motor')
|
||||
c=self.command_for_pulse();c['parameters']['sessions'][self.devices[1].id]='stale'
|
||||
self.assertEqual(self.service.execute(c)['state'],'error')
|
||||
c=self.command_for_pulse();c['parameters']['rig_clear']=False
|
||||
with self.assertRaises(ValueError):self.service.execute(c)
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_stop_received_before_queued_pulse_cannot_be_cleared_by_start(self):
|
||||
pulse = self.command_for_pulse()
|
||||
result = self.service.execute(self.command(action='vesc.motor.stop'))
|
||||
self.assertTrue(result['result']['stop_requested'])
|
||||
self.assertEqual(self.service.execute(pulse)['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'complete')
|
||||
|
||||
def test_single_six_and_ten_controllers_target_only_with_durable_backups(self):
|
||||
for count in (1, 6, 10):
|
||||
with self.subTest(count=count):
|
||||
self.attachments = [test_reader.Attachment(f"2-{i+1}", str(i+1), f"ttyACM{i}", "12") for i in range(count)]
|
||||
self.service.scan(); self.configure_controllers()
|
||||
command=self.command_for_pulse()
|
||||
target=self.devices[-1]
|
||||
command['session']={'device_id':target.id,'session_id':target.session}
|
||||
command['parameters']['duration_s']=10
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete',result)
|
||||
self.assertEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(result['result']['duration_limit_s'],10)
|
||||
self.assertEqual({i for i,action in self.sent if action=='current'},{count-1})
|
||||
self.assertTrue(result['result']['release_confirmed'])
|
||||
self.assertEqual(len(result['result']['backups']),count)
|
||||
self.assertEqual(len(result['result']['release']),count)
|
||||
self.assertLessEqual(self.clock.now,11.36)
|
||||
|
||||
def test_new_controller_during_preflight_or_pulse_stops_current(self):
|
||||
extra=test_reader.Attachment('3-1','3','ttyACM3','12')
|
||||
self.attachments.append(extra)
|
||||
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
self.attachments.remove(extra)
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.attachments.append(extra)
|
||||
self.devices[0].link.test_command=send
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
|
||||
def test_unmanaged_can_peer_on_other_controller_blocks_all_current(self):
|
||||
original=self.devices[1].link.query
|
||||
self.devices[1].link.query=lambda code,timeout=2:bytes([62,99]) if code==62 else original(code,timeout)
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_slow_multi_controller_cycle_never_sends_torque(self):
|
||||
original=self.devices[1].link.query
|
||||
def delayed(code,timeout=2):
|
||||
if code==31:self.clock.sleep(0.13)
|
||||
return original(code,timeout)
|
||||
self.devices[1].link.query=delayed
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_duration_is_allowlisted_and_stop_interrupts_ten_second_pulse(self):
|
||||
for duration in (True,0,30.01,100,'10'):
|
||||
command=self.command_for_pulse();command['parameters']['duration_s']=duration
|
||||
with self.assertRaises(ValueError):self.service.execute(command)
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.service.motor.cancel()
|
||||
self.devices[0].link.test_command=send
|
||||
command=self.command_for_pulse();command['parameters']['duration_s']=10
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['result']['outcome'],'stopped')
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertLess(self.clock.now,2)
|
||||
|
||||
def test_can_timeout_reports_preflight_failure_without_current(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
if code==62:
|
||||
self.assertEqual(timeout,8)
|
||||
raise TimeoutError('synthetic CAN timeout')
|
||||
return original(code,timeout)
|
||||
self.devices[0].link.query=query
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertIn('CAN',result['error'])
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_can_inspection_does_not_claim_or_drive_motors(self):
|
||||
result=self.service.execute(self.command(action='vesc.can.read'))
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(result['result']['can_ids'],[10,11])
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_old_coasting_thresholds_no_longer_interrupt_current(self):
|
||||
original=self.devices[0].link.query
|
||||
speeds=iter([0,210,880,1200,3000])
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4:
|
||||
speed=next(speeds,3000)
|
||||
raw=raw[:21]+struct.pack('>hi',120 if speed else 0,speed)+raw[27:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(result['duration_limit_s'],30)
|
||||
self.assertGreater(len(result['samples']),590)
|
||||
self.assertTrue(all(s['commanded_current_a'] > 0 for s in result['samples']))
|
||||
self.assertEqual(self.sent.count((0,'release')),1)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_current_ramps_from_half_amp_to_entered_ceiling(self):
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=10)
|
||||
result=self.service.execute(command)['result']
|
||||
amps=[value for _,value in self.currents]
|
||||
self.assertEqual(amps[0],0.5)
|
||||
self.assertEqual(max(amps),5)
|
||||
self.assertTrue(all(0<=b-a<=0.10001 for a,b in zip(amps,amps[1:])))
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(result['current_ramp_a_per_s'],2)
|
||||
|
||||
def test_thirty_amp_thirty_second_moving_target_and_bounded_receipt(self):
|
||||
original=self.devices[0].link.query
|
||||
tacho=0
|
||||
def query(code,timeout=2):
|
||||
nonlocal tacho
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.currents:
|
||||
tacho+=4
|
||||
raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||
raw=raw[:45]+struct.pack('>ii',tacho,tacho)+raw[53:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(max(amps for _,amps in self.currents),30)
|
||||
self.assertEqual({i for i,_ in self.currents},{0})
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
import json
|
||||
self.assertLess(len(json.dumps(result).encode()),1024*1024)
|
||||
|
||||
def test_high_current_without_motion_stops_and_does_not_auto_restart(self):
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertIn('движение не подтверждается',result['outcome'])
|
||||
self.assertLess(result['samples'][-1]['at'],5)
|
||||
self.assertLess(max(amps for _,amps in self.currents),10)
|
||||
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_reported_speed_alone_cannot_reset_stall_without_tachometer_motion(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.currents:raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertIn('движение не подтверждается',result['outcome'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_target_configuration_limits_remain_binding(self):
|
||||
self.motor=configuration('motor',{'motor_type':2,'l_current_max':10,'l_in_current_max':10,'l_min_erpm':-2000,'l_max_erpm':2000,'l_max_duty':0.1})
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertIn('настроенный предел',result['error'])
|
||||
self.assertEqual(self.sent,[])
|
||||
limits=decode(self.motor,'motor')
|
||||
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||
for field,value in [('erpm',2001),('duty',0.101),('motor_current_a',10.1)]:
|
||||
with self.assertRaises(LimitExceeded):check_values({**baseline,field:value},moving=True,current_a=10,motor=limits)
|
||||
|
||||
def test_arbitrary_current_cannot_enter_motor_loop(self):
|
||||
for amps in (True,0,0.4,30.001,31,100,'5',float('nan'),float('inf')):
|
||||
command=self.command_for_pulse();command['parameters']['current_a']=amps
|
||||
with self.assertRaises(ValueError):self.service.execute(command)
|
||||
self.assertEqual(self.sent,[])
|
||||
for amps in (0.5,1.7,3,5,10,30):
|
||||
self.assertEqual(Decoder().feed(current_packet(amps)),[bytes([6])+struct.pack('>i',round(amps*1000))])
|
||||
for amps in (False,-1,0,30.001,float('nan'),float('inf')):
|
||||
with self.assertRaises(ValueError):current_packet(amps)
|
||||
|
||||
def test_limit_stop_keeps_triggering_sample_and_releases_every_controller(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.sent.count((0,'current'))==1:
|
||||
raw=raw[:21]+struct.pack('>h',260)+raw[23:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
result=self.service.execute(self.command_for_pulse())['result']
|
||||
self.assertIn('заполнение PWM 26 %',result['outcome'])
|
||||
self.assertEqual(result['limit_violation'],{'field':'duty','value':0.26,'minimum':-0.25,'maximum':0.25})
|
||||
self.assertEqual(result['samples'][-1]['devices'][self.devices[0].id]['duty'],0.26)
|
||||
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_each_telemetry_limit_reports_its_own_measured_value(self):
|
||||
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||
for field,actual in [('fault_code',1),('input_voltage_v',61),('mos_temperature_c',66),('motor_current_a',-8.1),('erpm',6001),('duty',-0.251)]:
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaises(LimitExceeded) as raised:check_values({**baseline,field:actual},moving=True,current_a=5)
|
||||
self.assertEqual(raised.exception.violation['field'],field)
|
||||
self.assertEqual(raised.exception.violation['value'],actual)
|
||||
|
||||
# Parent ServiceTests use the generic firmware/read-only fake.
|
||||
test_two_attachments_promote_independently = None
|
||||
test_uuid_collision_never_aliases_a_controller = None
|
||||
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||
test_receipt_replay_never_repeats_serial_query = None
|
||||
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Faults at the actual subprocess/RPC boundary, with no serial hardware."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.native_link import NativeLink
|
||||
|
||||
|
||||
class NativeBoundaryTests(unittest.TestCase):
|
||||
def link(self, handler):
|
||||
value = object.__new__(NativeLink)
|
||||
value.buffer = b""; value.sequence = 0; value.hall_pending = False
|
||||
value.check = lambda:None
|
||||
script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n'
|
||||
value.process = subprocess.Popen([sys.executable, '-u', '-c', script],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0)
|
||||
self.addCleanup(value.close)
|
||||
return value
|
||||
|
||||
def test_success_returns_upstream_payload_and_keeps_one_process(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{"payload":"BA=="}}),flush=True)')
|
||||
pid = link.process.pid
|
||||
self.assertEqual(link.query(4),b'\x04')
|
||||
self.assertEqual(link.query(4),b'\x04')
|
||||
self.assertEqual(link.process.pid,pid)
|
||||
self.assertEqual(link.sequence,2)
|
||||
trace=link.last_rpc
|
||||
self.assertEqual(trace["stage"],"complete")
|
||||
stages=[trace[k] for k in ("attachment_before_ms","request_write_ms","native_response_ms","attachment_after_ms")]
|
||||
self.assertTrue(all(value>=0 for value in stages))
|
||||
self.assertLessEqual(sum(stages),trace["elapsed_ms"]+.1)
|
||||
|
||||
def test_wrong_identity_closes_stream_and_cannot_replay_motor_command(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"]+1,"ok":True,"result":{}}),flush=True)')
|
||||
with self.assertRaises(ValueError):link.test_current(5)
|
||||
self.assertFalse(link.alive)
|
||||
with self.assertRaises(OSError):link.test_current(5)
|
||||
|
||||
def test_missing_ack_terminates_process_without_automatic_retry(self):
|
||||
link = self.link('time.sleep(5)')
|
||||
process = link.process
|
||||
with self.assertRaises(TimeoutError):link.rpc('lease',timeout=.03)
|
||||
self.assertFalse(link.alive)
|
||||
self.assertIsNotNone(process.poll())
|
||||
self.assertEqual(link.sequence,1)
|
||||
|
||||
def test_attachment_change_closes_owner_before_sending(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{}}),flush=True)')
|
||||
link.check = lambda: (_ for _ in ()).throw(OSError('changed attachment'))
|
||||
with self.assertRaises(OSError):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_native_error_and_malformed_result_close_owner(self):
|
||||
for response in ('{"id":r["id"],"ok":False,"error":"disconnected"}',
|
||||
'{"id":r["id"],"ok":True,"result":[]}'):
|
||||
with self.subTest(response=response):
|
||||
link=self.link('print(json.dumps('+response+'),flush=True)')
|
||||
with self.assertRaises((OSError,ValueError)):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_blocked_pipe_closes_owner(self):
|
||||
link=self.link('time.sleep(5)')
|
||||
with patch('runtime.native_link.select.select',return_value=([],[],[])):
|
||||
with self.assertRaises(TimeoutError):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_timeout_retains_command_budget_and_attachment_state(self):
|
||||
link=self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected"}),flush=True)')
|
||||
with self.assertRaises(OSError) as caught: link.query(31,timeout=.06)
|
||||
trace=caught.exception.native_rpc
|
||||
self.assertEqual(trace['command'],31)
|
||||
self.assertEqual(trace['timeout_ms'],60)
|
||||
self.assertTrue(trace['process_alive'])
|
||||
self.assertTrue(trace['attachment_present'])
|
||||
self.assertGreater(trace['elapsed_ms'],0)
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_native_packet_diagnostics_survive_fail_closed_boundary(self):
|
||||
detail = {"request_emitted": True, "packets_sent": 1, "packets_received": 0,
|
||||
"serial_bytes_written": 6, "port_connected": True, "serial_error": 0,
|
||||
"events": [{"event": "serial_written", "bytes": 6, "at_ms": 0.2}]}
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected","diagnostics":'+repr(detail)+'}),flush=True)')
|
||||
with self.assertRaises(OSError) as caught: link.query(31, timeout=.06)
|
||||
self.assertEqual(caught.exception.native_rpc['transport'], detail)
|
||||
self.assertEqual(link.sequence, 1)
|
||||
self.assertFalse(link.alive)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Synthetic protocol and ownership tests; never opens a real serial port."""
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "src"))
|
||||
from runtime import SCHEMA
|
||||
from runtime.protocol import Decoder, firmware, request, values
|
||||
from runtime.serial import Attachment
|
||||
from runtime.service import Service
|
||||
|
||||
|
||||
def frame(payload):
|
||||
header = bytes([2, len(payload)]) if len(payload) < 256 else b"\x03" + len(payload).to_bytes(2, "big")
|
||||
return header + payload + binascii.crc_hqx(payload, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
def fw(index=1):
|
||||
return b"\x00\x06\x06SYNTHETIC\x00" + bytes([index]) * 12 + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def telemetry():
|
||||
return b"\x04" + struct.pack(">hhiiiihihiiiiiiB", 215, 190, -123, 456, 0, 0, 123, 600, 481,
|
||||
100, 0, 200, 0, 30, 30, 0)
|
||||
|
||||
|
||||
class ProtocolTests(unittest.TestCase):
|
||||
def test_transmit_has_no_motor_or_write_escape_hatch(self):
|
||||
for code in range(256):
|
||||
if code in (0, 4, 14, 17, 31, 62):
|
||||
self.assertEqual(Decoder().feed(request(code)), [bytes([code])])
|
||||
else:
|
||||
with self.assertRaises(ValueError): request(code)
|
||||
for value in (True, b"\0", "0", -1, 256):
|
||||
with self.assertRaises(ValueError): request(value)
|
||||
|
||||
def test_fragmented_and_combined_frames(self):
|
||||
samples = [fw(), bytes([14]) + bytes(range(256)) * 4, telemetry()]
|
||||
decoder = Decoder(); actual = []
|
||||
for byte in b"noise" + b"".join(map(frame, samples)):
|
||||
actual += decoder.feed(bytes([byte]))
|
||||
self.assertEqual(actual, samples)
|
||||
|
||||
def test_crc_and_bounds(self):
|
||||
bad = bytearray(frame(fw())); bad[-2] ^= 1
|
||||
self.assertEqual(Decoder().feed(bad), [])
|
||||
self.assertEqual(Decoder().feed(b"\x04\xff\xff\xff" + frame(fw())), [fw()])
|
||||
with self.assertRaises(ValueError): Decoder().feed(bytes(30000))
|
||||
|
||||
def test_identity_requires_uuid_and_bounded_name(self):
|
||||
self.assertEqual(firmware(fw())["uuid"], "01" * 12)
|
||||
for data in (b"", fw()[:5], fw()[:20], b"\0\6\6" + b"x" * 150, fw(0)):
|
||||
with self.assertRaises(ValueError): firmware(data)
|
||||
|
||||
def test_telemetry_scales_signed_values_and_truncation(self):
|
||||
result = values(telemetry())
|
||||
self.assertEqual(result["motor_current_a"], -1.23)
|
||||
self.assertEqual(result["input_voltage_v"], 48.1)
|
||||
self.assertEqual(result["erpm"], 600)
|
||||
self.assertNotIn("timeout", result)
|
||||
for length in range(54):
|
||||
with self.assertRaises(ValueError): values(telemetry()[:length])
|
||||
|
||||
|
||||
class FakeLink:
|
||||
instances = []
|
||||
identities = {}
|
||||
|
||||
def __init__(self, attachment):
|
||||
self.attachment = attachment; self.commands = []; self.closed = False
|
||||
self.instances.append(self)
|
||||
|
||||
def query(self, code, timeout=2):
|
||||
request(code)
|
||||
self.commands.append(code)
|
||||
if code == 0: return fw(self.identities.get(self.attachment.usb, int(self.attachment.address)))
|
||||
if code == 4: return telemetry()
|
||||
return bytes([code]) + b"\x12\x34\x56\x78opaque-config"
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.attachments = [Attachment("1-2", "1", "ttyACM0", "12"), Attachment("1-3", "2", "ttyACM1", "12")]
|
||||
FakeLink.instances = []; FakeLink.identities = {}
|
||||
self.service = Service(self.temp.name, lambda: self.attachments, FakeLink)
|
||||
self.service.scan()
|
||||
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def command(self, item=None, action="vesc.telemetry.read"):
|
||||
item = item or self.service.inventory("node_synthetic")["items"][0]
|
||||
now = datetime.now(timezone.utc); identifier = "op_" + uuid.uuid4().hex
|
||||
return {"api_version": SCHEMA, "kind": "OperationRequest", "operation_id": identifier,
|
||||
"idempotency_key": identifier, "session": {"session_id": item["snapshot"]["context"]["session_id"], "device_id": item["id"]},
|
||||
"requested_at": now.isoformat(), "deadline_at": (now + timedelta(seconds=60)).isoformat(),
|
||||
"action_id": action, "parameters": {}}
|
||||
|
||||
def test_two_attachments_promote_independently(self):
|
||||
items = self.service.inventory("node_synthetic")["items"]
|
||||
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||
self.assertTrue(all(i["id"] != i["attachment_id"] and i["verified"] for i in items))
|
||||
result = self.service.execute(self.command(items[1]))
|
||||
self.assertEqual(result["state"], "complete")
|
||||
self.assertEqual(FakeLink.instances[0].commands, [0])
|
||||
self.assertEqual(FakeLink.instances[1].commands, [0, 0, 4])
|
||||
|
||||
def test_uuid_collision_never_aliases_a_controller(self):
|
||||
FakeLink.identities = {"1-2": 1, "1-3": 1}
|
||||
for device in self.service.devices.values(): device.close()
|
||||
self.service.scan()
|
||||
items = self.service.inventory("node_synthetic")["items"]
|
||||
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||
self.assertTrue(all(not i["verified"] for i in items))
|
||||
with self.assertRaises(ValueError): self.service.execute(self.command(items[0]))
|
||||
|
||||
def test_reconnect_invalidates_old_session_but_retains_uuid(self):
|
||||
old = self.service.inventory("node_synthetic")["items"][0]; command = self.command(old)
|
||||
self.attachments = self.attachments[1:]; self.service.scan()
|
||||
self.assertTrue(FakeLink.instances[0].closed)
|
||||
FakeLink.identities["1-2"] = 1
|
||||
self.attachments.append(Attachment("1-2", "3", "ttyACM4", "12")); self.service.scan()
|
||||
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_receipt_replay_never_repeats_serial_query(self):
|
||||
command = self.command(); first = self.service.execute(command)
|
||||
before = list(FakeLink.instances[0].commands)
|
||||
self.assertEqual(self.service.execute(command), first)
|
||||
self.assertEqual(FakeLink.instances[0].commands, before)
|
||||
command["action_id"] = "vesc.config.backup"
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_native_process_exit_invalidates_session_before_reconnection(self):
|
||||
old = self.service.inventory("node_synthetic")["items"][0]
|
||||
command = self.command(old)
|
||||
process = FakeLink.instances[0]
|
||||
process.alive = False
|
||||
self.service.scan()
|
||||
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||
self.assertTrue(process.closed)
|
||||
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_backup_is_raw_hashed_and_bound_to_identity(self):
|
||||
result = self.service.execute(self.command(action="vesc.config.backup"))
|
||||
self.assertEqual(result["state"], "complete")
|
||||
value = result["result"]
|
||||
self.assertFalse(value["decoded"])
|
||||
self.assertEqual(base64.b64decode(value["configs"]["motor"]["payload"])[0], 14)
|
||||
backups = list(Path(self.temp.name).glob("backup_*.json"))
|
||||
self.assertEqual(json.loads(backups[0].read_text()), value)
|
||||
self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(FakeLink.instances[0].commands, [0, 0, 14, 17, 0])
|
||||
|
||||
def test_arbitrary_writes_parameters_and_expired_requests_rejected(self):
|
||||
for action in ("start", "stop", "settings.apply", "raw", "firmware.write"):
|
||||
with self.assertRaises(ValueError): self.service.execute(self.command(action=action))
|
||||
command = self.command(); command["parameters"] = {"packet": "anything"}
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
command = self.command(); command["deadline_at"] = command["requested_at"]
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
self.assertTrue(all(link.commands == [0] for link in FakeLink.instances))
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Fault injection for true hold time, volatile limits and native Hall lifecycle."""
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import test_motor_test
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
from runtime.protocol import Decoder, speed_packet, hall_packet
|
||||
from runtime.temporary_limits import FIELDS, packet
|
||||
from runtime.speed_hold import SpeedHold
|
||||
from runtime.hall_detection import parse_result
|
||||
|
||||
|
||||
class RunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
c = self.case
|
||||
self.configs = {}
|
||||
self.speeds = []
|
||||
self.moving = True
|
||||
self.tacho = 0
|
||||
self.hall_started = None
|
||||
self.sets = []
|
||||
for device in c.devices:
|
||||
self.configs[device.id] = configuration('motor', {**decode(c.motor,'motor'),
|
||||
'l_current_min':-60,'l_current_max_scale':1,'l_current_min_scale':1})
|
||||
original = device.link.query
|
||||
def query(code,timeout=2,original=original,device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
raw = original(code,timeout)
|
||||
if code == 4 and device is c.devices[0]:
|
||||
if self.hall_started is not None and c.clock.now - self.hall_started >= 12:
|
||||
device.link.hall_result = bytes([28,255,1,34,67,100,134,167,255,0])
|
||||
if self.moving and self.speeds:
|
||||
self.tacho += 6
|
||||
rpm = round(self.speeds[-1][1])
|
||||
raw = raw[:23]+struct.pack('>i',rpm)+raw[27:]
|
||||
raw = raw[:45]+struct.pack('>ii',self.tacho,self.tacho)+raw[53:]
|
||||
return raw
|
||||
device.link.query = query
|
||||
def set_limits(config,device=device):
|
||||
self.sets.append((device.id,config))
|
||||
old = decode(self.configs[device.id],'motor')
|
||||
self.configs[device.id] = configuration('motor',{**old,**{k:config[k] for k in FIELDS}})
|
||||
device.link.set_temporary_limits = set_limits
|
||||
device.link.test_speed = lambda speed,device=device:self.speeds.append((device.id,speed))
|
||||
device.link.hall_result = None
|
||||
device.link.hall_pending = False
|
||||
def hall(device=device):
|
||||
self.hall_started = c.clock.now
|
||||
device.link.hall_pending = True
|
||||
device.link.detect_hall = hall
|
||||
self.originals = dict(self.configs)
|
||||
|
||||
def run_command(self):
|
||||
command = self.case.command_for_pulse()
|
||||
command['action_id'] = 'vesc.motor.run'
|
||||
command['parameters'].update(erpm=1200,current_a=30,duration_s=30)
|
||||
from datetime import datetime,timedelta
|
||||
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
|
||||
return command
|
||||
|
||||
def test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits(self):
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(result['outcome'],'duration',result['outcome'])
|
||||
self.assertGreaterEqual(result['rotation_s'],30)
|
||||
self.assertLess(result['rotation_s'],30.1)
|
||||
self.assertGreater(result['samples'][-1]['at'],32)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual({device for device,_ in self.speeds},{self.case.devices[0].id})
|
||||
applied=self.sets[0][1]
|
||||
self.assertAlmostEqual(applied['l_current_max']*applied['l_current_max_scale'],30)
|
||||
self.assertAlmostEqual(applied['l_current_min']*applied['l_current_min_scale'],-30)
|
||||
self.assertFalse(list(self.case.service.root.glob('limits_*.json')))
|
||||
|
||||
def test_stationary_motor_never_counts_command_time_as_rotation(self):
|
||||
self.moving=False
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(result['rotation_s'],0)
|
||||
self.assertIn('не вышел',result['outcome'])
|
||||
self.assertGreaterEqual(result['samples'][-1]['at'],15)
|
||||
self.assertTrue(result['limits_restored'])
|
||||
|
||||
def test_lost_ack_after_limit_write_restores_before_any_speed_command(self):
|
||||
target=self.case.devices[0]
|
||||
original=target.link.set_temporary_limits
|
||||
def send(config):
|
||||
original(config)
|
||||
if len(self.sets)==1:raise TimeoutError('lost ACK after applying')
|
||||
target.link.set_temporary_limits=send
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
|
||||
def test_pending_limits_recovered_after_interruption_without_motor_command(self):
|
||||
target=self.case.devices[0]
|
||||
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertFalse(self.case.service.motor.limits.pending(target))
|
||||
|
||||
def test_external_configuration_change_never_overwritten_by_recovery(self):
|
||||
target=self.case.devices[0]
|
||||
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||
self.configs[target.id]=configuration('motor',{**decode(self.configs[target.id],'motor'),'foc_motor_r':0.1})
|
||||
before=self.configs[target.id]
|
||||
self.case.service.scan()
|
||||
self.assertEqual(before,self.configs[target.id])
|
||||
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||
self.assertEqual(self.speeds,[])
|
||||
|
||||
def test_native_hall_returns_measured_table_without_applying_it(self):
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
receipt=self.case.service.execute(command)
|
||||
self.assertEqual(receipt['state'],'complete',receipt)
|
||||
result=receipt['result']
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertTrue(result['measurement']['valid_six_states'])
|
||||
self.assertTrue(result['configuration_restored'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertFalse(result['configuration_written'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertFalse((self.case.service.root/'hall-pending.json').exists())
|
||||
started=self.hall_started
|
||||
self.assertEqual(self.case.service.execute(command),receipt)
|
||||
self.assertEqual(started,self.hall_started)
|
||||
|
||||
def test_native_hall_requires_its_own_explicit_confirmation(self):
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':False,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
with self.assertRaises(ValueError):self.case.service.execute(command)
|
||||
self.assertIsNone(self.hall_started)
|
||||
|
||||
def test_rc_preemption_releases_speed_control_and_restores_limits(self):
|
||||
original=self.case.devices[0].link.test_speed
|
||||
def speed(value):
|
||||
original(value)
|
||||
self.case.changed=True
|
||||
self.case.devices[0].link.test_speed=speed
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(len(self.speeds),1)
|
||||
self.assertEqual(result['rotation_s'],0)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
# Restoring while a receiver actively commands would enlarge RC torque;
|
||||
# keep the lower limits until neutral, then recover without auto-start.
|
||||
self.assertFalse(result['limits_restored'])
|
||||
self.case.changed=False
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertEqual(len(self.speeds),1)
|
||||
|
||||
def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self):
|
||||
self.case.devices[0].link.detect_hall=lambda:None
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
result=self.case.service.execute(command)['result']
|
||||
self.assertFalse(result['completed'])
|
||||
self.assertTrue((self.case.service.root/'hall-pending.json').exists())
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||
|
||||
|
||||
class HoldClockTests(unittest.TestCase):
|
||||
def test_interrupted_motion_and_usb_gap_are_excluded(self):
|
||||
hold=SpeedHold(1200,30,0)
|
||||
for i in range(40):hold.update(i/10,{'erpm':1200,'tachometer':i})
|
||||
before=hold.rotation_s
|
||||
hold.update(4.5,{'erpm':1200,'tachometer':50})
|
||||
self.assertEqual(hold.rotation_s,before)
|
||||
hold.update(4.6,{'erpm':0,'tachometer':50})
|
||||
self.assertEqual(hold.rotation_s,before)
|
||||
_,_,error=hold.update(6.7,{'erpm':0,'tachometer':50})
|
||||
self.assertIn('перестал удерживать',error)
|
||||
|
||||
def test_speed_without_changing_tachometer_never_starts_timer(self):
|
||||
hold=SpeedHold(1200,30,0)
|
||||
for i in range(151):_,_,error=hold.update(i/10,{'erpm':1200,'tachometer':0})
|
||||
self.assertEqual(hold.rotation_s,0)
|
||||
self.assertIsNone(hold.hold_started)
|
||||
self.assertIn('не вышел',error)
|
||||
|
||||
def test_exact_native_wire_no_flash_no_can_and_hall_failure(self):
|
||||
self.assertEqual(Decoder().feed(speed_packet(1200)),[bytes([8])+struct.pack('>i',1200)])
|
||||
self.assertEqual(Decoder().feed(hall_packet()),[bytes([28])+struct.pack('>i',5000)])
|
||||
wire=Decoder().feed(packet(dict.fromkeys(FIELDS,1.0)))[0]
|
||||
self.assertEqual(wire[:5],bytes([48,0,0,1,0]))
|
||||
self.assertEqual(len(wire),45)
|
||||
failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1]))
|
||||
self.assertFalse(failure['valid_six_states'])
|
||||
self.assertEqual(failure['observed_states'],[3])
|
||||
for value in (float('nan'),float('inf'),-1,3001,True):
|
||||
with self.assertRaises(ValueError):speed_packet(value)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Regression for deterministic package mtimes and root-created Python caches."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import py_compile
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class UpgradeTests(unittest.TestCase):
|
||||
def test_same_size_source_with_old_pyc_loads_new_version_after_installer_step(self):
|
||||
clear = runpy.run_path(str(Path(__file__).parents[1] / "packaging/clear_runtime_cache.py"))["clear"]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); package = root / "runtime"; package.mkdir()
|
||||
init = package / "__init__.py"; init.write_text('VERSION="0.1.0"\n'); os.utime(init,(0,0))
|
||||
py_compile.compile(str(init),doraise=True)
|
||||
init.write_text('VERSION="0.2.0"\n');os.utime(init,(0,0))
|
||||
backup=root/'backup.json';backup.write_text('preserve')
|
||||
code='import sys;sys.path.insert(0,sys.argv[1]);import runtime;print(runtime.VERSION)'
|
||||
def version():return subprocess.check_output([sys.executable,'-I','-B','-c',code,str(root)],text=True).strip()
|
||||
self.assertEqual(version(),'0.1.0')
|
||||
clear(root);clear(root)
|
||||
self.assertEqual(version(),'0.2.0')
|
||||
self.assertEqual(backup.read_text(),'preserve')
|
||||
Reference in New Issue
Block a user