Add DSLogic capture, signal conversion and DSView decoders

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent cc22c803d1
commit 513e79b127
44 changed files with 5982 additions and 36 deletions

View 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)

View File

@@ -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)

View 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)

View 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())