feat(vesc): integrate native calibration diagnostics and configuration archives

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:37:56 +03:00
parent 24bbaefb00
commit 45fb14b206
85 changed files with 17968 additions and 28 deletions
+50
View File
@@ -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))
+74
View File
@@ -0,0 +1,74 @@
"""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 test_change_layout_keeps_assignments_without_motor_commands(self):
left, right = list(self.service.devices.values())
self.assign(left, 'left.1')
self.assign(right, 'right.1')
command = self.command(action='vesc.drive.layout')
command['parameters'] = {'layout': '2x2', 'revision': 2}
result = self.service.execute(command)
self.assertEqual(result['state'], 'complete')
self.assertEqual(result['result']['layout'], '2x2')
self.assertEqual(result['result']['bindings']['left.1']['device_id'], left.id)
self.assertEqual(len(result['result']['bindings']), 2)
self.assertTrue(all(link.commands == [0] for link in test_reader.FakeLink.instances))
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
+180
View File
@@ -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'; release['parameters']['standstill_confirmed'] = True
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'; release['parameters']['standstill_confirmed'] = True
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'; release['parameters']['standstill_confirmed'] = True
self.assertEqual(self.case.service.execute(release)['state'], 'error')
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
+159
View File
@@ -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, standstill_confirmed=True,
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, [])
+108
View File
@@ -0,0 +1,108 @@
"""Sensorless idle drift must not become a general motion-check bypass."""
import math
import struct
import unittest
import test_speed_and_hall
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.motor_test import check_values, LimitExceeded
from runtime.protocol import values
class HallStandstillTests(unittest.TestCase):
def setUp(self):
self.rig = test_speed_and_hall.RunTests('test_native_hall_returns_measured_table_without_applying_it')
self.rig.setUp()
self.addCleanup(self.rig.doCleanups)
self.case = self.rig.case
def command(self):
command = self.case.command(action='vesc.hall.measure')
command['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True,
'standstill_confirmed': True, 'sessions': {d.id:d.session for d in self.case.devices}}
return command
def drift(self, device, mode=0, late_duty=False):
config = decode(self.rig.configs[device.id], 'motor')
self.rig.configs[device.id] = configuration('motor', {**config, 'foc_sensor_mode':mode})
query = device.link.query
reads = 0
def read(code, timeout=2):
nonlocal reads
raw = query(code, timeout)
if code == 4:
reads += 1
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
if late_duty and reads >= 6:
raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
return raw
device.link.query = read
def test_sensorless_peer_can_drift_but_raw_evidence_is_retained(self):
self.drift(self.case.devices[1])
result = self.case.service.execute(self.command())['result']
self.assertTrue(result['completed'])
self.assertTrue(result['configuration_restored'])
self.assertTrue(result['preflight']['standstill_confirmed'])
samples = result['preflight']['samples']
self.assertEqual(len(samples), 20)
peer = [s for s in samples if s['device_id'] == self.case.devices[1].id]
self.assertTrue(all(s['values']['erpm'] == -160 for s in peer))
self.assertGreaterEqual(peer[-1]['at'] - peer[0]['at'], 0.89)
def test_sensored_speed_still_blocks_before_any_write(self):
self.drift(self.case.devices[1], mode=2)
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertIsNone(self.rig.hall_started)
self.assertEqual(self.case.sent, [])
def test_modulation_above_one_wire_quantum_blocks_before_any_write(self):
self.drift(self.case.devices[1], late_duty=True)
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertIsNone(self.rig.hall_started)
self.assertEqual(self.case.sent, [])
def test_missing_false_and_nonboolean_observation_rejected(self):
for confirmation in (None, False, 1, 'true'):
command = self.command()
if confirmation is None: del command['parameters']['standstill_confirmed']
else: command['parameters']['standstill_confirmed'] = confirmation
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.case.sent, [])
def test_sensorless_drift_does_not_bypass_ordinary_motor_test(self):
self.drift(self.case.devices[1])
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
self.assertEqual(self.case.sent, [])
def test_rc_still_blocks_hall_before_any_write(self):
self.drift(self.case.devices[1])
self.case.changed = True
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertTrue(self.case.service.motor.latched)
self.assertEqual(self.case.sent, [])
def test_electrical_and_finite_guards_cannot_be_replaced_by_observation(self):
device = self.case.devices[0]
sample = values(device.link.query(4))
motor = decode(self.rig.configs[device.id], 'motor')
for field, value in (('erpm', math.nan), ('motor_current_a', 1.01),
('input_current_a', -1.01), ('duty', .002),
('fault_code', 1), ('mos_temperature_c', 66),
('input_voltage_v', 61)):
with self.subTest(field=field), self.assertRaises(LimitExceeded):
check_values({**sample, 'erpm':-160, field:value}, motor=motor,
standstill_confirmed=True)
def test_one_quantized_idle_modulation_step_requires_observed_standstill(self):
device = self.case.devices[0]
sample = values(device.link.query(4))
motor = {**decode(self.rig.configs[device.id], 'motor'), 'foc_sensor_mode':0}
for duty in (-.001, 0, .001):
idle = {**sample, 'erpm':-160, 'duty':duty}
check_values(idle, motor=motor, standstill_confirmed=True)
with self.assertRaises(LimitExceeded):
check_values(idle, motor=motor)
with self.assertRaises(LimitExceeded):
check_values({**sample, 'duty':-.002}, motor=motor, standstill_confirmed=True)
+34
View File
@@ -0,0 +1,34 @@
"""Native configuration reads never acquire motor control or change settings."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_reader
from runtime.limits_view import FIELDS, read_limits
class LimitsTests(unittest.TestCase):
def test_native_export_only_and_missing_or_nonfinite_values_rejected(self):
class Link:
def configuration(self):
return {"motor": {"parameters": [{"name": key, "value": 1.0} for key in FIELDS]}}
self.assertEqual(set(read_limits(Link())), FIELDS)
for bad in (float('nan'), True, '300'):
with patch.object(Link, 'configuration', return_value={"motor":{"parameters":[{"name":key,"value":bad} for key in FIELDS]}}):
with self.assertRaises(ValueError): read_limits(Link())
def test_service_read_is_bound_to_uuid_and_cannot_write(self):
fixture = test_reader.ServiceTests()
fixture.setUp()
try:
service = fixture.service
with patch.object(test_reader.FakeLink, 'configuration', create=True, return_value={"motor":{"parameters":[{"name":key,"value":1.0} for key in FIELDS]}}) as native:
response = service.execute(fixture.command(action='vesc.limits.read'))
self.assertEqual(response['state'], 'complete')
self.assertEqual(set(response['result']['parameters']), FIELDS)
native.assert_called_once()
self.assertTrue(all(set(link.commands) == {0} for link in test_reader.FakeLink.instances))
self.assertEqual(service.drive.value['revision'], 0)
finally:
fixture.tearDown()
+107
View File
@@ -0,0 +1,107 @@
"""No-power read measurements and failure attribution; no USB hardware."""
from unittest.mock import patch
import unittest
import struct
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),402)
self.assertEqual({r[1] for r in self.reads},{17,31,4})
self.assertTrue(all(r[2]==.5 for r in self.reads if r[1]!=17))
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]
original=device.link.query
def query(code,timeout=2):
if code==17:return original(code,timeout)
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},{17,31})
def test_receiver_offset_inside_configured_deadband_is_idle(self):
for device in self.case.devices:
original=device.link.query
device.link.query=lambda code,timeout=2,original=original: bytes([31])+struct.pack('>ii',-66000,1466000) if code==31 else original(code,timeout)
result=self.run_check()
self.assertEqual(result['outcome'],'complete')
self.assertTrue(all(abs(d['neutral_band']-.15)<1e-6 for d in result['devices'].values()))
self.assertEqual(self.case.sent,[])
def test_config_failure_is_reported_before_ppm_or_motor_commands(self):
self.case.devices[0].link.query=lambda *args,**kwargs: b'bad config'
result=self.run_check()
self.assertEqual(result['outcome'],'read_failed')
self.assertEqual(result['failure'][0]['command'],17)
self.assertEqual(self.case.sent,[])
self.assertFalse(any(r[1] in (31,4) for r in self.reads))
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),402)
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,[])
+352
View File
@@ -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()
+93
View File
@@ -0,0 +1,93 @@
"""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
from runtime.serial import Attachment
class NativeBoundaryTests(unittest.TestCase):
def link(self, handler):
value = object.__new__(NativeLink)
value.attachment = Attachment('2-1', '11', 'ttyACM0', '12')
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['attachment'], {'usb':'2-1','address':'11','tty':'ttyACM0'})
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)
+178
View File
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
"""Firmware neutral-band regression and retained native failure evidence."""
import json
import struct
import unittest
import test_motor_test
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.receiver import active, neutral_band
class ReceiverTests(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)
def offset(self, device, level, band=None):
original=device.link.query
def query(code,timeout=2):
if code==31:return bytes([31])+struct.pack('>ii',int(level*1e6),1466000)
raw=original(code,timeout)
if code==17 and band is not None:
return configuration('application',{**decode(raw,'application'),'app_ppm_conf.hyst':band})
return raw
device.link.query=query
def test_inside_deadband_allows_test_without_changing_ppm_configuration(self):
c=self.case
for device in c.devices:self.offset(device,-.066)
before=[d.link.query(17) for d in c.devices]
result=c.service.execute(c.command_for_pulse())
self.assertEqual(result['state'],'complete',result)
self.assertEqual(result['result']['outcome'],'duration')
self.assertFalse(c.service.motor.latched)
self.assertEqual(before,[d.link.query(17) for d in c.devices])
def test_each_peer_uses_its_own_fresh_band_and_active_input_blocks_motion(self):
c=self.case
for device in c.devices:self.offset(device,-.066)
self.assertEqual(c.service.execute(c.command_for_pulse())['state'],'complete')
c.sent.clear()
self.offset(c.devices[1],-.066,.05)
result=c.service.execute(c.command_for_pulse())
self.assertEqual(result['state'],'error')
self.assertTrue(c.service.motor.latched)
self.assertEqual(c.sent,[])
def test_unknown_band_and_invalid_input_do_not_assume_neutral(self):
with self.assertRaises(ValueError):self.case.service.motor.receiver_active('missing',0)
for level,band in [(float('nan'),.15),(0,float('nan')),(0,0),(0,.8)]:
with self.assertRaises(ValueError):active(level,band)
self.assertFalse(active(.15,.15))
self.assertTrue(active(-.150001,.15))
def test_native_read_and_preflight_failures_keep_transport_diagnostics(self):
c=self.case
for action,code in [('vesc.input.read',31),('vesc.motor.pulse',17)]:
with self.subTest(action=action):
device=c.devices[0]; original=device.link.query
trace={'command':code,'stage':'native_response','transport':{'request_emitted':True,'packets_received':0}}
def query(actual,timeout=2):
if actual==code:
error=OSError('native timeout');error.native_rpc=trace;error.native_history=[trace]
raise error
return original(actual,timeout)
device.link.query=query
command=c.command_for_pulse() if action=='vesc.motor.pulse' else c.command(action=action)
result=c.service.execute(command)
self.assertEqual(result['state'],'error')
self.assertEqual(result['result']['failure']['native_rpc'],trace)
saved=json.loads((c.service.root/(command['operation_id']+'.json')).read_text())
self.assertEqual(saved['receipt'],result)
self.assertEqual(c.service.execute(command),result)
self.assertEqual(c.sent,[])
device.link.query=original
+307
View File
@@ -0,0 +1,307 @@
import unittest
from runtime.remote_control import InputLease
def envelope(identifier='a'*32,seq=1,**kw):
return dict(id=identifier,sequence=seq,ttl_ms=300,left=1,right=-1,
settings=dict(standstill_confirmed=True,current_a=30,max_erpm=2000),**kw)
class LeaseTests(unittest.TestCase):
def setUp(self):
self.now=0
self.lease=InputLease(lambda:self.now)
def test_no_duplicate_renewal_no_late_resume(self):
self.assertTrue(self.lease.accept(envelope()))
self.now=.2;self.assertFalse(self.lease.accept(envelope()))
self.now=.301;self.assertFalse(self.lease.live())
self.assertFalse(self.lease.accept(envelope(seq=2)))
self.assertFalse(self.lease.live())
def test_stop_is_terminal_even_with_newer_frames(self):
self.lease.accept(envelope());self.lease.stop()
self.assertFalse(self.lease.accept(envelope(seq=20)))
self.assertEqual(self.lease.demand,(0,0))
def test_identity_change_stops_instead_of_stealing(self):
self.lease.accept(envelope())
self.assertFalse(self.lease.accept(envelope('b'*32)))
self.assertFalse(self.lease.live())
def test_invalid_limits_and_nan(self):
for key,value in [('ttl_ms',401),('ttl_ms',0),('left',float('nan')),('right',2),('sequence',True)]:
item=envelope();item[key]=value
with self.subTest(key=key,value=value),self.assertRaises(ValueError):self.lease.accept(item)
from types import SimpleNamespace
from unittest.mock import patch
import threading
from runtime.remote_control import ControlEnded, RemoteControl
class PreparationHandoffTests(unittest.TestCase):
"""The observer and control worker share one hardware owner, not a queue."""
def setUp(self):
self.now = [0.]
self.attempted = threading.Event()
self.operation = threading.Lock()
self.operation.acquire()
case = self
class ObservedLock:
def acquire(self, *args, **kwargs):
case.attempted.set()
return case.operation.acquire(*args, **kwargs)
def release(self):
case.operation.release()
self.calls = []
def run(*args, **kwargs):
self.calls.append('prepare')
raise ControlEnded('synthetic completion')
profile = {'layout': '1x1', 'revision': 1, 'bindings': {
'left.1': {'device_id': 'left'}, 'right.1': {'device_id': 'right'}}}
service = SimpleNamespace(operation_lock=ObservedLock(), lock=threading.Lock(),
motor=SimpleNamespace(run=run), drive=SimpleNamespace(value=profile),
devices={'left': SimpleNamespace(link=object()), 'right': SimpleNamespace(link=object())})
self.remote = RemoteControl(service)
self.remote.lease = InputLease(lambda: self.now[0])
self.relay = 'b' * 32
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
def start(self):
self.remote.feed({'watch': True, 'command': envelope(), 'relay_id': self.relay})
self.assertTrue(self.attempted.wait(1))
def finish(self):
self.operation.release()
self.remote.thread.join(1)
self.assertFalse(self.remote.thread.is_alive())
def tearDown(self):
self.remote.lease.stop()
if self.operation.locked():
self.operation.release()
if self.remote.thread:
self.remote.thread.join(1)
def test_existing_read_finishes_before_preparation(self):
self.start()
self.assertEqual(self.remote.state, 'preparing')
self.assertEqual(self.calls, [])
self.finish()
self.assertEqual(self.calls, ['prepare'])
self.assertEqual(self.remote.state, 'stopped')
self.assertFalse(self.operation.locked())
def test_stop_while_waiting_never_starts_preparation(self):
self.start()
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
self.finish()
self.assertEqual(self.calls, [])
self.assertEqual(self.remote.state, 'stopped')
def test_expired_input_does_not_start_when_lock_is_released(self):
self.start()
self.now[0] = .301
self.finish()
self.assertEqual(self.calls, [])
self.assertFalse(self.remote.lease.live())
def test_long_operation_does_not_queue_preparation(self):
self.start()
self.remote.thread.join(1)
self.assertFalse(self.remote.thread.is_alive())
self.assertEqual(self.calls, [])
self.assertEqual(self.remote.state, 'fault')
self.assertTrue(self.operation.locked())
def test_observer_yields_to_waiting_control_worker(self):
self.start()
self.attempted.clear()
self.remote.observe()
# The observer does not even try to reacquire ownership.
# Its test devices intentionally have no serial read methods.
self.assertEqual(self.calls, [])
self.finish()
self.assertEqual(self.calls, ['prepare'])
class DriveBoundaryTests(unittest.TestCase):
"""Synthetic two-controller run: verify the actual output loop and cleanup."""
def run_drive(self, scenario, *, broken_read=False, through_prepare=False, minimum_erpm=900):
now=[0.]; logs=[]; restored=[]
profile={'layout':'1x1','revision':1,'bindings':{
'left.1':{'device_id':'left','uuid':'left'},
'right.1':{'device_id':'right','uuid':'right'}}}
service=SimpleNamespace(drive=SimpleNamespace(value=profile))
remote=RemoteControl(service)
remote._publish=lambda *args:None
remote.lease=InputLease(lambda:now[0]); remote.lease.accept(envelope())
remote.lease.demand=(0,0)
def value():return dict(fault_code=0,input_voltage_v=48,mos_temperature_c=25,
motor_current_a=0,input_current_a=0,erpm=0,duty=0,tachometer=0)
class Link:
def __init__(self,name):self.name=name
def query(self,code,timeout):
if broken_read and self.name=='right' and now[0]>=.2:raise TimeoutError('lost right controller')
if code==4:
result=value()
if scenario=='coasting' and self.name=='right' and .4<=now[0]<.8:result['erpm']=1000
return result
return {'level':.8 if scenario=='receiver' and .2<=now[0]<.5 else 0}
def test_command(self,name):logs.append((now[0],self.name,name,0))
def test_speed(self,rpm):logs.append((now[0],self.name,'rpm',rpm))
devices=[SimpleNamespace(id=name,link=Link(name)) for name in ('left','right')]
owner=SimpleNamespace(stop=threading.Event(),latched=False,active=False,mode=None)
def state(s):owner.latched=s=='rc'
owner.state=state
owner.receiver_active=lambda _,level:abs(level)>.15
owner.limits=SimpleNamespace(apply=lambda d,*a:dict(l_current_max=35,l_max_erpm=100000,l_min_erpm=-100000,l_max_duty=.95,
s_pid_min_erpm=minimum_erpm[d.id] if isinstance(minimum_erpm,dict) else minimum_erpm),restore=lambda d:restored.append(d.id))
def sleep(delta):
now[0]+=delta
if now[0]>=2:owner.stop.set()
if remote.lease.live():
if scenario!='expire': remote.lease.until=now[0]+.4
remote.lease.demand=((-1,-1) if scenario=='reverse' and now[0]>=.4 else (1,1))
if scenario=='small': remote.lease.demand=(.2,-.2)
if scenario=='turn': remote.lease.demand=(-1,1)
if scenario=='release' and now[0]>=.4: remote.lease.demand=(0,0)
if scenario in ('turn-forward','coasting'):
remote.lease.demand=(-1,1) if now[0]<.4 else (1,1)
if scenario=='paused-reverse':
remote.lease.demand=(1,1) if now[0]<.4 else (0,0) if now[0]<1.2 else (-1,-1)
if scenario=='cancel-reverse':
remote.lease.demand=(-1,-1) if .4<=now[0]<.6 else (1,1)
owner.sleep=sleep
command={'parameters':{'current_a':30,'erpm':2000}}
originals={d.id:{'motor':b'config'} for d in devices}
service.motor=owner;service.operation_lock=threading.Lock();service.lock=threading.Lock()
service.devices={d.id:d for d in devices}
owner.run=lambda command,devices,target,remote:remote.drive(owner,command,devices,devices,originals,lambda:None)
error=None
with patch('runtime.remote_control.time.monotonic',lambda:now[0]),patch('runtime.remote_control.values',lambda v:v),patch('runtime.remote_control.ppm',lambda v:v):
try:
if through_prepare: remote._prepare(envelope())
else: remote.drive(owner,command,devices,devices,originals,lambda:None)
except (ValueError,TimeoutError) as e:error=e
return logs,restored,remote,owner,error
def test_start_enters_each_configured_pid_range_without_dead_ramp(self):
log,_,_,_,error=self.run_drive('forward',minimum_erpm={'left':900,'right':1100.1})
self.assertIsNone(error)
for name,minimum in [('left',900),('right',1101)]:
commands=[v for v in log if v[1]==name and v[2]=='rpm']
self.assertLessEqual(commands[0][0],.2)
self.assertEqual(commands[0][3],minimum)
self.assertTrue(all(minimum<=v[3]<=2000 for v in commands))
for before,after in zip(commands,commands[1:]):
self.assertLessEqual(after[3]-before[3],600*(after[0]-before[0])+1e-6)
def test_turn_starts_with_opposite_signs_at_pid_threshold(self):
log,_,_,_,error=self.run_drive('turn')
self.assertIsNone(error)
for name,sign in [('left',-1),('right',1)]:
first=next(v for v in log if v[1]==name and v[2]=='rpm')
self.assertEqual(first[3],sign*900)
def test_subthreshold_request_is_released_never_amplified(self):
log,_,_,_,error=self.run_drive('small')
self.assertIsNone(error)
self.assertFalse(any(v[2]=='rpm' for v in log))
def test_release_still_has_no_ramp_or_minimum_speed(self):
log,_,_,_,error=self.run_drive('release')
self.assertIsNone(error)
self.assertTrue(any(v[2]=='rpm' for v in log))
self.assertFalse(any(v[2]=='rpm' and v[0]>=.4 for v in log))
def test_invalid_or_unreachable_pid_threshold_never_claims_output(self):
for minimum in [-1,float('nan'),float('inf'),2000.1]:
with self.subTest(minimum=minimum):
log,_,_,_,error=self.run_drive('forward',minimum_erpm=minimum)
self.assertIsInstance(error,ValueError)
self.assertFalse(any(v[2] in ('rpm','claim') for v in log))
def test_turn_to_forward_releases_both_and_restarts_in_same_cycle(self):
log,_,_,_,error=self.run_drive('turn-forward')
self.assertIsNone(error)
restarts={name:next(v[0] for v in log if v[1]==name and v[2]=='rpm' and v[0]>=.4)
for name in ('left','right')}
self.assertEqual(restarts['left'],restarts['right'])
self.assertGreaterEqual(restarts['left'],.9)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<restarts['left'] for v in log))
self.assertTrue(all(v[3]>0 for v in log if v[2]=='rpm' and v[0]>=restarts['left']))
def test_shared_reversal_waits_for_every_motor_to_be_quiet(self):
log,_,_,_,error=self.run_drive('coasting')
self.assertIsNone(error)
first=next(v[0] for v in log if v[2]=='rpm' and v[0]>=.4)
self.assertGreaterEqual(first,1.3)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<1.3 for v in log))
def test_neutral_pause_counts_before_reversal_command(self):
log,_,_,_,error=self.run_drive('paused-reverse')
self.assertIsNone(error)
first=next(v[0] for v in log if v[2]=='rpm' and v[3]<0)
self.assertLess(first,1.4)
def test_cancelled_reversal_never_emits_old_direction(self):
log,_,_,_,error=self.run_drive('cancel-reverse')
self.assertIsNone(error)
self.assertFalse(any(v[2]=='rpm' and v[3]<0 for v in log))
self.assertTrue(any(v[2]=='rpm' and v[0]>=.6 for v in log))
def test_expired_browser_lease_releases_every_controller(self):
log,restored,remote,owner,error=self.run_drive('expire')
self.assertIsNotNone(error)
self.assertFalse(any(row[2]=='rpm' and row[0]>=.3 for row in log))
self.assertEqual(set(restored),{'left','right'})
self.assertTrue(remote.release_confirmed)
self.assertTrue(any(row[2]=='rpm' for row in log))
for name in ('left','right'):self.assertEqual([v[2] for v in log if v[1]==name][-1],'release')
def test_terminal_lease_reports_stopped_after_verified_cleanup(self):
log,restored,remote,owner,error=self.run_drive('expire',through_prepare=True)
self.assertIsNone(error)
self.assertEqual(remote.state,'stopped')
self.assertIsNone(remote.message)
self.assertTrue(remote.release_confirmed)
self.assertTrue(any(row[2]=='rpm' for row in log))
self.assertEqual(set(restored),{'left','right'})
self.assertFalse(remote.service.operation_lock.locked())
def test_session_wrapper_preserves_unconfirmed_release_fault(self):
_,_,remote,owner,error=self.run_drive('lost',broken_read=True,through_prepare=True)
self.assertEqual(remote.state,'fault')
self.assertFalse(remote.release_confirmed)
self.assertIn('не подтверждено',remote.message)
self.assertFalse(remote.service.operation_lock.locked())
def test_receiver_first_gesture_holds_zero_until_neutral(self):
log,restored,remote,owner,error=self.run_drive('receiver')
self.assertIsNone(error)
self.assertTrue(any(v[2]=='rpm' for v in log))
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
self.assertTrue(owner.latched)
self.assertEqual(remote.state,'receiver')
self.assertGreaterEqual(max(v[0] for v in log),.9)
def test_reversal_has_zero_interval(self):
log,_,_,_,error=self.run_drive('reverse')
self.assertIsNone(error)
first_negative=min(v[0] for v in log if v[2]=='rpm' and v[3]<0)
self.assertGreaterEqual(first_negative,.9)
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<.9 for v in log))
def test_one_port_failure_stops_both_and_does_not_claim_confirmed_release(self):
log,restored,remote,owner,error=self.run_drive('lost',broken_read=True)
self.assertIsInstance(error,TimeoutError)
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
self.assertFalse(remote.release_confirmed)
self.assertTrue(owner.latched)
self.assertEqual(remote.state,'fault')
self.assertIn('не подтверждено',remote.message)
self.assertEqual(set(restored),{'left','right'})
+136
View File
@@ -0,0 +1,136 @@
"""Signed upstream speed commands retain timing, preflight and RC interlocks."""
import struct
import unittest
import test_speed_and_hall
import test_group_test
from test_motor_test import configuration
from runtime.configuration import decode
from runtime.protocol import Decoder, speed_packet
from runtime.speed_hold import SpeedHold
class ReverseTests(unittest.TestCase):
def setUp(self):
self.rig = test_speed_and_hall.RunTests('test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits')
self.rig.setUp()
self.addCleanup(self.rig.doCleanups)
self.case = self.rig.case
def command(self):
command = self.rig.run_command()
command['parameters']['erpm'] = -3000
return command
def test_reverse_holds_thirty_seconds_with_signed_ramp_and_restores(self):
result = self.case.service.execute(self.command())['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertGreaterEqual(result['rotation_s'], 30)
self.assertGreater(result['samples'][-1]['at'], 35)
speeds = [v for _, v in self.rig.speeds]
self.assertEqual(speeds[0], -1)
self.assertEqual(speeds[-1], -3000)
self.assertTrue(all(-3000 <= v < 0 for v in speeds))
self.assertTrue(all(-31 <= b-a <= 0 for a, b in zip(speeds, speeds[1:])))
self.assertTrue(result['release_confirmed'])
self.assertTrue(result['limits_restored'])
self.assertEqual(self.rig.configs, self.rig.originals)
self.assertEqual(len(result['preflight']['samples']), 20)
def test_missing_false_or_nonboolean_stop_confirmation_never_writes(self):
for confirmation in (None, False, 1, 'true'):
command = self.command()
if confirmation is None: del command['parameters']['standstill_confirmed']
else: command['parameters']['standstill_confirmed'] = confirmation
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.rig.speeds, [])
self.assertEqual(self.case.sent, [])
def test_unsigned_size_bounds_are_checked_for_both_directions(self):
for erpm in (0, 299, -299, 3001, -3001, True, float('nan'), float('inf')):
command = self.command(); command['parameters']['erpm'] = erpm
with self.assertRaises(ValueError): self.case.service.execute(command)
self.assertEqual(self.rig.speeds, [])
def test_reverse_respects_configured_negative_limit(self):
target = self.case.devices[0]
motor = decode(self.rig.configs[target.id], 'motor')
self.rig.configs[target.id] = configuration('motor', {**motor, 'l_min_erpm': -1000})
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
self.assertEqual(self.rig.speeds, [])
def test_sensorless_idle_observation_rejects_modulation_above_one_quantum(self):
peer = self.case.devices[1]
motor = decode(self.rig.configs[peer.id], 'motor')
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
original = peer.link.query
reads = 0
def query(code, timeout=2):
nonlocal reads
raw = original(code, timeout)
if code == 4:
reads += 1
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
if reads >= 6: raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
return raw
peer.link.query = query
result = self.case.service.execute(self.command())
self.assertEqual(result['state'], 'error')
self.assertEqual(self.rig.speeds, [])
self.assertEqual(self.rig.sets, [])
def test_attended_sensorless_peer_drift_allows_test_and_explicit_release(self):
peer = self.case.devices[1]
motor = decode(self.rig.configs[peer.id], 'motor')
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
query = peer.link.query
def read(code, timeout=2):
raw = query(code, timeout)
return raw[:23] + struct.pack('>i', -160) + raw[27:] if code == 4 else raw
peer.link.query = read
self.case.service.motor.state('rc')
release = self.case.command_for_pulse()
release['action_id'] = 'vesc.control.release'
release['parameters']['standstill_confirmed'] = True
receipt = self.case.service.execute(release)
self.assertEqual(receipt['state'], 'complete', receipt)
self.assertFalse(self.case.service.motor.latched)
self.assertEqual(self.rig.speeds, [])
result = self.case.service.execute(self.command())['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertTrue(any(s['values']['erpm'] == -160 for s in result['preflight']['samples']))
def test_reverse_rc_preemption_latches_without_restart(self):
speed = self.case.devices[0].link.test_speed
def send(value):
speed(value)
self.case.changed = True
self.case.devices[0].link.test_speed = send
result = self.case.service.execute(self.command())['result']
self.assertEqual(len(self.rig.speeds), 1)
self.assertTrue(self.case.service.motor.latched)
self.assertEqual(result['rotation_s'], 0)
self.assertTrue(result['release_confirmed'])
class ReverseClockTests(unittest.TestCase):
def test_wrong_direction_never_counts_even_with_changing_tachometer(self):
hold = SpeedHold(-3000, 30, 0)
for i in range(151): _, _, error = hold.update(i/10, {'erpm':3000, 'tachometer':i})
self.assertEqual(hold.rotation_s, 0)
self.assertIsNone(hold.hold_started)
self.assertIn('не вышел', error)
def test_signed_wire_is_int32_not_absolute_value(self):
self.assertEqual(Decoder().feed(speed_packet(-3000)), [bytes([8])+struct.pack('>i', -3000)])
def test_common_reverse_holds_all_motors_and_restores(self):
rig = test_group_test.GroupTests('test_pair_and_four_motors_run_one_common_interval_and_restore')
rig.setUp(); self.addCleanup(rig.doCleanups)
command = rig.command(); command['parameters']['erpm'] = -3000
result = rig.case.service.execute(command)['result']
self.assertEqual(result['outcome'], 'duration', result)
self.assertGreaterEqual(result['rotation_s'], 3)
self.assertTrue(all(-3000 <= v < 0 for _, v in rig.commands))
self.assertTrue(result['limits_restored'])
self.assertEqual(rig.configs, rig.originals)
+206
View File
@@ -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,standstill_confirmed=True)
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,'standstill_confirmed':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,'standstill_confirmed':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,'standstill_confirmed':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'),-3001,3001,True):
with self.assertRaises(ValueError):speed_packet(value)
+26
View File
@@ -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')