Add DSLogic capture, signal conversion and DSView decoders
This commit is contained in:
59
python/tests/test_dslogic_trigger.py
Normal file
59
python/tests/test_dslogic_trigger.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from logic_analyzer.dslogic import DSLogicSettings
|
||||
from logic_analyzer.dslogic_trigger import program_trigger, validate_trigger
|
||||
|
||||
|
||||
class TriggerTests(unittest.TestCase):
|
||||
def test_invalid_modes_and_disabled_conditions(self):
|
||||
base = DSLogicSettings('dsview:2.6')
|
||||
for changes in (dict(acquisition_mode='bad'), dict(acquisition_mode='buffer', duration=0),
|
||||
dict(acquisition_mode='internal', trigger=dict(kind='simple', pattern='X'*15+'R')),
|
||||
dict(trigger=dict(kind='stages', stages=[])),
|
||||
dict(trigger=dict(kind='simple', pattern='R'+'X'*15)),
|
||||
dict(trigger=dict(kind='simple', pattern='X'*16))):
|
||||
with self.subTest(changes=changes), self.assertRaises(ValueError):
|
||||
replace(base, **changes).validate()
|
||||
|
||||
def test_simple_maps_physical_channels_and_stream_position(self):
|
||||
lib = Mock()
|
||||
program_trigger(lib, dict(kind='simple', pattern='F'+'X'*14+'R', position=80), 'stream', lambda *_: None)
|
||||
lib.ds_trigger_set_pos.assert_called_once_with(1)
|
||||
lib.ds_trigger_probe_set.assert_any_call(0, ord('R'), ord('X'))
|
||||
lib.ds_trigger_probe_set.assert_any_call(15, ord('F'), ord('X'))
|
||||
|
||||
def test_stage_count_logic_and_masks_match_driver_abi(self):
|
||||
trigger = dict(kind='stages', position=75, stages=[
|
||||
dict(a='X'*15+'0', b='X'*14+'1X', logic='or', count=9, inv_a=True, contiguous=True),
|
||||
dict(a='X'*15+'R', b='X'*16)])
|
||||
validate_trigger(trigger, (0,1), 'buffer')
|
||||
lib = Mock()
|
||||
program_trigger(lib, trigger, 'buffer', lambda *_: None)
|
||||
lib.ds_trigger_set_stage.assert_called_once_with(1)
|
||||
lib.ds_trigger_set_pos.assert_called_once_with(75)
|
||||
lib.ds_trigger_stage_set_logic.assert_any_call(0, 16, 2)
|
||||
lib.ds_trigger_stage_set_inv.assert_any_call(0, 16, 1, 0)
|
||||
lib.ds_trigger_stage_set_count.assert_any_call(0, 16, 9, 0)
|
||||
lib.ds_trigger_stage_set_value.assert_any_call(1, 16, b'X '*15+b'R', b'X '*15+b'X')
|
||||
|
||||
def test_serial_mapping_and_data_channel_validation(self):
|
||||
trigger = dict(kind='serial', start='X'*16, stop='X'*16, clock='X'*14+'RX',
|
||||
data_channel=0, bits=8, value='X'*8+'10101010')
|
||||
validate_trigger(trigger, (0,1), 'buffer')
|
||||
lib = Mock()
|
||||
program_trigger(lib, trigger, 'buffer', lambda *_: None)
|
||||
lib.ds_trigger_set_mode.assert_called_once_with(2)
|
||||
lib.ds_trigger_set_stage.assert_called_once_with(3)
|
||||
lib.ds_trigger_stage_set_value.assert_any_call(2, 16, b'X '*15+b'0', b'X '*15+b'X')
|
||||
lib.ds_trigger_stage_set_count.assert_any_call(3, 16, 7, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
validate_trigger(dict(trigger, data_channel=16), (0,1,16), 'buffer')
|
||||
|
||||
def test_driver_errors_propagate(self):
|
||||
lib = Mock()
|
||||
def fail(*args):
|
||||
raise RuntimeError('driver failure')
|
||||
with self.assertRaisesRegex(RuntimeError, 'driver failure'):
|
||||
program_trigger(lib, None, 'stream', fail)
|
||||
@@ -71,7 +71,7 @@ class LogicAnalysisTests(unittest.TestCase):
|
||||
self.assertTrue(result.events)
|
||||
self.assertTrue(all(e.kind == 'error' for e in result.events))
|
||||
result = analyze_capture(uart_capture(b'\xa5', bad_stop=True), dict(mode='UART'))
|
||||
self.assertTrue(any('стопового' in e.text for e in result.events))
|
||||
self.assertTrue(any('стопового' in e.text for e in result.events))
|
||||
|
||||
def test_set_ping_bridge_crc_and_resynchronization(self):
|
||||
damaged = bytearray(PING)
|
||||
@@ -146,7 +146,7 @@ class LogicAnalysisTests(unittest.TestCase):
|
||||
def test_can_incomplete_end(self):
|
||||
bits = [1] * 10 + can_packet(0x123, b'\x55', extended=False)[:-8]
|
||||
events = analyze_capture(capture_bits(bits, 1000000), dict(mode='CAN')).events
|
||||
self.assertTrue(any('Неполный' in e.text for e in events))
|
||||
self.assertTrue(any('Неполный' in e.text for e in events))
|
||||
self.assertFalse(any(e.kind == 'can' for e in events))
|
||||
|
||||
def test_gate_profiles_and_truncated_window(self):
|
||||
@@ -162,6 +162,24 @@ class LogicAnalysisTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
analyze_capture(cap, dict(mode='1SP0635', status_channel=0))
|
||||
|
||||
def test_gate_pair_is_opt_in(self):
|
||||
vin1 = DigitalChannel('Vin1', 0, array('d', [1e-6, 2e-6, 4e-6]))
|
||||
status = DigitalChannel('Vstat', 0, array('d'))
|
||||
vin2 = DigitalChannel('Vin2', 0, array('d', [2.2e-6, 3.2e-6]))
|
||||
cap = DigitalCapture('test', 'test', [vin1, status, vin2], 0, 6e-6)
|
||||
options = dict(mode='1SP0635', vin2_channel=2, vin2_mintime_ns=1500)
|
||||
self.assertFalse(any(e.kind == 'deadtime' for e in analyze_capture(cap, options).events))
|
||||
options['pair_analysis'] = True
|
||||
with self.assertRaises(ValueError):
|
||||
analyze_capture(cap, options)
|
||||
options['mode'] = 'Transistor pair'
|
||||
events = analyze_capture(cap, options).events
|
||||
self.assertEqual(sum(e.kind == 'deadtime' for e in events), 2)
|
||||
self.assertEqual(sum(e.kind == 'mintime_fail' for e in events), 1)
|
||||
options['vin2_channel'] = 0
|
||||
with self.assertRaises(ValueError):
|
||||
analyze_capture(cap, options)
|
||||
|
||||
def test_measurements_cycle_and_clipped_pulse(self):
|
||||
ch = DigitalChannel('D0', 0, array('d', [1e-6, 2e-6, 5e-6, 6e-6]))
|
||||
measured = pulse_measurements(ch, 1.5e-6, 0, 8e-6)
|
||||
|
||||
104
python/tests/test_logic_analyzer_dslogic.py
Normal file
104
python/tests/test_logic_analyzer_dslogic.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from dataclasses import replace
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from logic_analyzer import dslogic
|
||||
from logic_analyzer.files import ImportCancelled
|
||||
|
||||
|
||||
class DSLogicCoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.options = dslogic.DSLogicSettings('dsview:2.6', channels=(0, 9), sample_rate=1000000)
|
||||
self.metadata = dict(channels=[0,9], samples=70, sample_rate=1000000, format='cross64-le')
|
||||
|
||||
def test_cross_words_preserve_physical_channels_boundary_edges_and_tail(self):
|
||||
raw = struct.pack('<QQQQ', 1 << 63, (1 << 64)-1, 1, 0)
|
||||
capture = dslogic.read_cross(BytesIO(raw), self.metadata, self.options)
|
||||
self.assertEqual(capture.end, 70 / 1000000)
|
||||
self.assertEqual([c.name for c in capture.channels], ['D0', 'D9'])
|
||||
self.assertEqual([c.initial for c in capture.channels], [0, 1])
|
||||
self.assertEqual([list(c.edges) for c in capture.channels], [[63e-6, 65e-6], [64e-6]])
|
||||
|
||||
def test_padding_is_not_imported_as_samples(self):
|
||||
meta = dict(self.metadata, samples=3)
|
||||
capture = dslogic.read_cross(BytesIO(struct.pack('<QQ', 1<<63, 0)), meta, self.options)
|
||||
self.assertEqual(capture.end, 3e-6)
|
||||
self.assertEqual(len(capture.channels[0].edges), 0)
|
||||
|
||||
def test_empty_truncated_wrong_mapping_and_cancel(self):
|
||||
for raw in (b'', b'\0'*31):
|
||||
with self.assertRaises(ValueError):
|
||||
dslogic.read_cross(BytesIO(raw), self.metadata, self.options)
|
||||
with self.assertRaises(ValueError):
|
||||
dslogic.read_cross(BytesIO(b'\0'*32), dict(self.metadata, channels=[0,1]), self.options)
|
||||
with self.assertRaises(ImportCancelled):
|
||||
dslogic.read_cross(BytesIO(b'\0'*32), self.metadata, self.options, lambda: True)
|
||||
|
||||
def test_validation_rejects_old_driver_and_invalid_parameters(self):
|
||||
for changes in (dict(device_id='dreamsourcelab-dslogic'), dict(channels=()),
|
||||
dict(channels=(0,0)), dict(duration=float('nan')), dict(sample_rate=0)):
|
||||
with self.assertRaises(ValueError):
|
||||
replace(self.options, **changes).validate()
|
||||
|
||||
def test_host_does_not_inherit_frozen_gui_bootloader_state(self):
|
||||
with patch.dict(os.environ, {'_MEIPASS2': 'gui-runtime', '_PYI_APPLICATION_HOME_DIR': 'gui-runtime'}):
|
||||
with patch.object(dslogic.subprocess, 'Popen') as launch:
|
||||
dslogic._popen(['host', 'request'])
|
||||
environment = launch.call_args.kwargs['env']
|
||||
self.assertNotIn('_MEIPASS2', environment)
|
||||
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', environment)
|
||||
|
||||
def launch(self, code):
|
||||
self.processes = []
|
||||
def popen(arguments, **kwargs):
|
||||
process = subprocess.Popen([sys.executable, '-c', code, arguments[1]], **kwargs)
|
||||
self.processes.append(process)
|
||||
return process
|
||||
self.addCleanup(lambda: [p.kill() for p in self.processes if p.poll() is None])
|
||||
return patch.object(dslogic, '_popen', side_effect=popen)
|
||||
|
||||
def test_real_process_scan(self):
|
||||
code = "import sys,json; from pathlib import Path; p=Path(sys.argv[1]); (p/'result.json').write_text(json.dumps({'devices':[{'device_id':'dsview:2.6','name':'DSLogic Plus','channels':[0,9]}]}))"
|
||||
with self.launch(code):
|
||||
devices = dslogic.list_devices()
|
||||
self.assertEqual(devices[0].channels, (0,9))
|
||||
self.assertEqual(devices[0].device_id, 'dsview:2.6')
|
||||
|
||||
def test_stop_is_graceful_and_imports_result(self):
|
||||
code = '''import sys,time,json,struct
|
||||
from pathlib import Path
|
||||
p=Path(sys.argv[1]); (p/'ready').touch()
|
||||
while not (p/'stop').exists(): time.sleep(.01)
|
||||
(p/'capture.bin').write_bytes(struct.pack('<QQ',6,0))
|
||||
(p/'result.json').write_text(json.dumps(dict(channels=[0,9],samples=3,sample_rate=1000000,format='cross64-le')))
|
||||
'''
|
||||
with self.launch(code):
|
||||
capture = dslogic.acquire(self.options, lambda: True, lambda: False)
|
||||
self.assertEqual(capture.end, 3e-6)
|
||||
self.assertEqual(self.processes[0].returncode, 0)
|
||||
|
||||
def test_cancel_reaps_process_and_discards_result(self):
|
||||
start = time.monotonic()
|
||||
with self.launch('import time; time.sleep(30)'):
|
||||
with self.assertRaises(ImportCancelled):
|
||||
dslogic.acquire(self.options, lambda: False, lambda: time.monotonic()-start > .1)
|
||||
self.assertIsNotNone(self.processes[0].poll())
|
||||
|
||||
def test_native_failure_does_not_hang(self):
|
||||
with self.launch('import sys; sys.stderr.write("native failure"); sys.exit(7)'):
|
||||
with self.assertRaisesRegex(RuntimeError, '7'):
|
||||
dslogic.acquire(self.options, lambda: False, lambda: False)
|
||||
|
||||
def test_error_result_is_not_a_capture(self):
|
||||
code = "import sys,json; from pathlib import Path; (Path(sys.argv[1])/'result.json').write_text(json.dumps({'error':'USB disconnected'})); sys.exit(1)"
|
||||
with self.launch(code):
|
||||
with self.assertRaisesRegex(RuntimeError, 'USB disconnected'):
|
||||
dslogic.acquire(self.options, lambda: False, lambda: False)
|
||||
96
python/tests/test_logic_csv_import.py
Normal file
96
python/tests/test_logic_csv_import.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
from logic_analyzer.csv_import import convert_csv
|
||||
from logic_analyzer.files import ImportCancelled, read_capture
|
||||
|
||||
|
||||
class CsvImportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.source = Path(self.temp.name) / 'сигналы.csv'
|
||||
self.output = Path(self.temp.name) / 'сигналы.dsl'
|
||||
|
||||
def write(self, value):
|
||||
self.source.write_text(value, encoding='utf-8-sig')
|
||||
|
||||
def test_regular_samples_preserve_levels_and_names(self):
|
||||
self.write('Time [ns],Vin,Vstat\n0,0,1\n10,1,0\n20,0,1\n')
|
||||
original = self.source.read_bytes()
|
||||
info = convert_csv(self.source, self.output)
|
||||
result = read_capture(self.output)
|
||||
self.assertEqual(info['samplerate'], 100_000_000)
|
||||
self.assertEqual([c.name for c in result.channels], ['Vin', 'Vstat'])
|
||||
self.assertEqual(list(result.channels[0].edges), [1e-8, 2e-8])
|
||||
self.assertEqual([result.channels[1].level_at(t) for t in [0, 1e-8, 2e-8, 5e-7]], [1, 0, 1, 1])
|
||||
self.assertEqual(self.source.read_bytes(), original)
|
||||
|
||||
def test_sparse_transitions_preserve_short_pulses_across_blocks(self):
|
||||
self.write('Time [ns],A\n0,0\n10,1\n650,0\n680,1\n710,0\n')
|
||||
convert_csv(self.source, self.output, block_samples=64)
|
||||
capture = read_capture(self.output)
|
||||
self.assertEqual(list(capture.channels[0].edges), [1e-8, 6.5e-7, 6.8e-7, 7.1e-7])
|
||||
with zipfile.ZipFile(self.output) as archive:
|
||||
self.assertIn('L-0/1', archive.namelist())
|
||||
self.assertEqual(len(archive.read('L-0/0')), 8)
|
||||
|
||||
def test_initial_snapshot_need_not_be_on_edge_clock(self):
|
||||
self.write('Time [s];A\n127,632374014;0\n127,632482000;1\n127,632482400;0\n127,632483000;1\n')
|
||||
info = convert_csv(self.source, self.output)
|
||||
self.assertEqual(info['samplerate'], 5_000_000)
|
||||
self.assertEqual(Decimal(info['origin_seconds']), Decimal('127.632374'))
|
||||
capture = read_capture(self.output)
|
||||
edges = capture.channels[0].edges
|
||||
self.assertAlmostEqual(edges[1] - edges[0], 400e-9, places=14)
|
||||
self.assertAlmostEqual(edges[2] - edges[1], 600e-9, places=14)
|
||||
|
||||
def test_comments_tab_delimiter_and_negative_origin(self):
|
||||
self.write('; DSView export\n# data\nTime(us)\tКанал\n-1\t1\n0\t0\n1\t1\n')
|
||||
info = convert_csv(self.source, self.output)
|
||||
self.assertEqual(info['origin_seconds'], '-0.000001')
|
||||
self.assertEqual(read_capture(self.output).channels[0].name, 'Канал')
|
||||
|
||||
def test_bad_csv_leaves_no_output(self):
|
||||
for rows in ('0,0\n1,3.3', '0,0\n0,1', 'nan,0\n1,1', '0,0', '0,0,1\n1,1,0'):
|
||||
with self.subTest(rows=rows):
|
||||
self.write('Time [s],A\n' + rows + '\n')
|
||||
with self.assertRaises(ValueError):
|
||||
convert_csv(self.source, self.output)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_output_is_not_overwritten_and_size_limit_precedes_expansion(self):
|
||||
self.write('Time [ns],A\n0,0\n10,1\n100000,0\n')
|
||||
with self.assertRaises(ValueError):
|
||||
convert_csv(self.source, self.output, max_packed_bytes=64)
|
||||
self.output.write_bytes(b'old recording')
|
||||
with self.assertRaises(ValueError):
|
||||
convert_csv(self.source, self.output)
|
||||
self.assertEqual(self.output.read_bytes(), b'old recording')
|
||||
|
||||
def test_cancel_during_packing_cleans_temporary_output(self):
|
||||
self.write('Time [ns],A\n0,0\n10,1\n100000,0\n')
|
||||
cancelled = False
|
||||
def progress(value):
|
||||
nonlocal cancelled
|
||||
if value > 40:
|
||||
cancelled = True
|
||||
with self.assertRaises(ImportCancelled):
|
||||
convert_csv(self.source, self.output, progress, lambda: cancelled, block_samples=64)
|
||||
self.assertFalse(self.output.exists())
|
||||
self.assertEqual([p.name for p in Path(self.temp.name).iterdir()], [self.source.name])
|
||||
|
||||
def test_changed_source_rejected_before_publication(self):
|
||||
self.write('Time [ns],A\n0,0\n10,1\n20,0\n')
|
||||
changed = False
|
||||
def progress(value):
|
||||
nonlocal changed
|
||||
if value == 99 and not changed:
|
||||
changed = True
|
||||
self.source.write_text('changed', encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'изменился'):
|
||||
convert_csv(self.source, self.output, progress)
|
||||
self.assertFalse(self.output.exists())
|
||||
Reference in New Issue
Block a user