Add DSLogic capture, signal conversion and DSView decoders
This commit is contained in:
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
from bisect import bisect_left, bisect_right
|
||||
from dataclasses import dataclass
|
||||
import heapq
|
||||
from itertools import groupby
|
||||
import math
|
||||
|
||||
from .files import ImportCancelled
|
||||
from .decoders.gate_timing import TimingChecker
|
||||
from .decoders.transistor_pair import PairTimingChecker
|
||||
from .decoders.set_uart import StreamParser
|
||||
from .decoders.pm35_uart import PM35Parser
|
||||
from .decoders.set_can import legacy, Reassembler
|
||||
@@ -84,7 +86,7 @@ def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=Fals
|
||||
continue
|
||||
finish = time + bits * bit
|
||||
if finish > end + bit * 1e-6:
|
||||
yield time, end, None, 'Неполный UART-байт в конце диапазона'
|
||||
yield time, end, None, 'Неполный UART-байт в конце диапазона'
|
||||
break
|
||||
read = lambda pos: channel.level_at(time + pos * bit) ^ inverted
|
||||
if read(0.5):
|
||||
@@ -94,10 +96,10 @@ def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=Fals
|
||||
value = sum(v << i for i, v in enumerate(values))
|
||||
error = None
|
||||
if parity != 'none' and read(9.5) != ((sum(values) + (parity == 'odd')) & 1):
|
||||
error = 'Ошибка чётности UART'
|
||||
error = 'Ошибка чётности UART'
|
||||
stop_start = 9 + (parity != 'none')
|
||||
if any(read(stop_start + i + 0.5) != 1 for i in range(stops)):
|
||||
error = 'Ошибка стопового бита UART / BREAK'
|
||||
error = 'Ошибка стопового бита UART / BREAK'
|
||||
yield time, finish, value, error
|
||||
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
|
||||
|
||||
@@ -142,7 +144,7 @@ class _CanBits:
|
||||
break
|
||||
sample = boundary + self.sample_point * self.bit
|
||||
if sample >= self.end:
|
||||
raise EOFError('Неполный CAN-кадр в конце диапазона')
|
||||
raise EOFError('Неполный CAN-кадр в конце диапазона')
|
||||
self.last_sample = sample
|
||||
self.position += 1
|
||||
return self.channel.level_at(sample) ^ self.inverted
|
||||
@@ -151,7 +153,7 @@ class _CanBits:
|
||||
if self.run == 5:
|
||||
value = self.raw()
|
||||
if value == self.previous:
|
||||
raise ValueError('CAN: ошибка bit stuffing / error frame')
|
||||
raise ValueError('CAN: ошибка bit stuffing / error frame')
|
||||
self.previous, self.run = value, 1
|
||||
value = self.raw()
|
||||
self.run = self.run + 1 if value == self.previous else 1
|
||||
@@ -181,34 +183,34 @@ def can_frames(channel, start, end, bitrate, inverted=False, sample_point=.7, ca
|
||||
frame, error = None, None
|
||||
try:
|
||||
if reader.take(1):
|
||||
raise ValueError('CAN: неверный SOF')
|
||||
raise ValueError('CAN: неверный SOF')
|
||||
ident = reader.take(11)
|
||||
rtr, extended = reader.take(1), reader.take(1)
|
||||
if extended:
|
||||
if rtr != 1:
|
||||
raise ValueError('CAN: неверный SRR')
|
||||
raise ValueError('CAN: неверный SRR')
|
||||
ident = (ident << 18) | reader.take(18)
|
||||
rtr = reader.take(1)
|
||||
if reader.take(2):
|
||||
raise ValueError('CAN FD / reserved bits не поддерживаются')
|
||||
raise ValueError('CAN FD / reserved bits не поддерживаются')
|
||||
elif reader.take(1):
|
||||
raise ValueError('CAN FD / reserved bit не поддерживается')
|
||||
raise ValueError('CAN FD / reserved bit не поддерживается')
|
||||
dlc = reader.take(4)
|
||||
if dlc > 8:
|
||||
raise ValueError('CAN: поддерживается classic DLC 0…8')
|
||||
raise ValueError('CAN: поддерживается classic DLC 0…8')
|
||||
data = bytes(reader.take(8) for _ in range(0 if rtr else dlc))
|
||||
expected = can_crc(reader.bits)
|
||||
received = reader.take(15)
|
||||
if reader.run == 5:
|
||||
if reader.raw() == reader.previous:
|
||||
raise ValueError('CAN: неверный последний stuff bit')
|
||||
raise ValueError('CAN: неверный последний stuff bit')
|
||||
if reader.raw() != 1:
|
||||
raise ValueError('CAN: неверный CRC delimiter')
|
||||
raise ValueError('CAN: неверный CRC delimiter')
|
||||
ack = reader.raw() == 0
|
||||
if reader.raw() != 1 or any(reader.raw() != 1 for _ in range(7)):
|
||||
raise ValueError('CAN: неверный ACK delimiter / EOF')
|
||||
raise ValueError('CAN: неверный ACK delimiter / EOF')
|
||||
if received != expected:
|
||||
raise ValueError('CAN CRC15: получено %04X, ожидается %04X' % (received, expected))
|
||||
raise ValueError('CAN CRC15: получено %04X, ожидается %04X' % (received, expected))
|
||||
frame = dict(ident=ident, extended=bool(extended), remote=bool(rtr),
|
||||
data=data, dlc=dlc, ack=ack)
|
||||
except (ValueError, EOFError) as exc:
|
||||
@@ -222,14 +224,14 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
start = capture.start if start is None else max(capture.start, start)
|
||||
end = capture.end if end is None else min(capture.end, end)
|
||||
if end <= start:
|
||||
raise ValueError('Выберите непустой интервал анализа.')
|
||||
raise ValueError('Выберите непустой интервал анализа.')
|
||||
mode = options['mode']
|
||||
channel = capture.channels[options.get('channel', 0)]
|
||||
events = []
|
||||
last_progress = -1
|
||||
limit = options.get('max_events', 50000)
|
||||
if limit <= 0:
|
||||
raise ValueError('Лимит результатов должен быть положительным.')
|
||||
raise ValueError('Лимит результатов должен быть положительным.')
|
||||
|
||||
def emit(a, b, kind, text, details=''):
|
||||
if len(events) >= limit:
|
||||
@@ -260,8 +262,10 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
if mode in ('1SP0635', '1SD536F2'):
|
||||
other = options.get('status_channel', 1)
|
||||
if other == options.get('channel', 0):
|
||||
raise ValueError('Vin и Vstat должны быть разными каналами.')
|
||||
raise ValueError('Vin1 и Vstat должны быть разными каналами.')
|
||||
status = capture.channels[other]
|
||||
if options.get('pair_analysis'):
|
||||
raise ValueError('Use the Transistor pair analyzer for Vin1/Vin2 timing.')
|
||||
checker = TimingChecker(1e9, mode, options.get('tolerance_ns', 100),
|
||||
not options.get('vin_low', False), not options.get('status_low', False))
|
||||
# The pure checker works in integer ticks. Offset before rounding
|
||||
@@ -272,7 +276,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
for item in items:
|
||||
text = item['text']
|
||||
if item['kind'] == 'fault':
|
||||
text += ' · аварийная обратная связь; превышение тока не подтверждено'
|
||||
text += ' · аварийная обратная связь; превышение тока не подтверждено'
|
||||
emit(start + item['start'] / 1e9, start + item['end'] / 1e9, item['kind'], text)
|
||||
|
||||
def transitions(ch, which):
|
||||
@@ -281,21 +285,48 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
yield ch.edges[i], which, ch.initial ^ ((i + 1) & 1)
|
||||
|
||||
if bool(status.level_at(start)) == checker.vstat_active_high:
|
||||
emit(start, start, 'orphan', 'Vstat активен в начале диапазона: начало импульса не записано')
|
||||
for time, which, level in heapq.merge(transitions(channel, 0), transitions(status, 1)):
|
||||
emit(start, start, 'orphan', 'Vstat активен в начале диапазона: начало импульса не записано')
|
||||
streams = [transitions(channel, 0), transitions(status, 1)]
|
||||
for time, which, level in heapq.merge(*streams):
|
||||
report(time)
|
||||
timing(checker.expire(tick(time)))
|
||||
timing(checker.on_control_edge(tick(time), level) if which == 0 else checker.on_status_edge(tick(time), level))
|
||||
timing(checker.on_control_edge(tick(time), level) if which == 0
|
||||
else checker.on_status_edge(tick(time), level))
|
||||
timing(checker.expire(tick(end)))
|
||||
if checker.status_start is not None:
|
||||
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
|
||||
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
|
||||
for pending in checker.pending:
|
||||
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin: диапазон закончился до тайм-аута ACK')
|
||||
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin1: диапазон закончился до тайм-аута ACK')
|
||||
elif mode == 'Transistor pair':
|
||||
second = options.get('vin2_channel', 1)
|
||||
if second == options.get('channel', 0):
|
||||
raise ValueError('Vin1 and Vin2 must be different channels.')
|
||||
vin2 = capture.channels[second]
|
||||
numeric = {name: options.get(name, 0) for name in (
|
||||
'vin1_mintime_ns', 'vin2_mintime_ns', 'vin1_minoff_ns', 'vin2_minoff_ns',
|
||||
'deadtime_12_ns', 'deadtime_21_ns')}
|
||||
checker = PairTimingChecker(1e9, not options.get('vin_low', False),
|
||||
not options.get('vin2_low', False), **numeric)
|
||||
levels = [channel.level_at(start), vin2.level_at(start)]
|
||||
checker.update(0, *levels)
|
||||
def edges(ch, which):
|
||||
for index in range(bisect_right(ch.edges, start), bisect_right(ch.edges, end)):
|
||||
yield ch.edges[index], which, ch.initial ^ ((index + 1) & 1)
|
||||
def emit_pair(items):
|
||||
for item in items:
|
||||
emit(start + item['start'] / 1e9, start + item['end'] / 1e9,
|
||||
item['kind'], item['text'])
|
||||
for time, group in groupby(heapq.merge(edges(channel, 0), edges(vin2, 1)), key=lambda e: e[0]):
|
||||
report(time)
|
||||
for _, which, level in group:
|
||||
levels[which] = level
|
||||
emit_pair(checker.update(round((time - start) * 1e9), *levels))
|
||||
emit_pair(checker.finish(round((end - start) * 1e9)))
|
||||
elif mode in ('UART', 'SET UART', 'PM35 UART'):
|
||||
baud = options.get('baudrate', 115200)
|
||||
parity, stops = options.get('parity', 'none'), options.get('stops', 1)
|
||||
if not math.isfinite(baud) or baud <= 0 or parity not in ('none', 'even', 'odd') or stops not in (1, 2):
|
||||
raise ValueError('Некорректные настройки UART.')
|
||||
raise ValueError('Некорректные настройки UART.')
|
||||
parser = (StreamParser(options.get('protocol', 'auto')) if mode == 'SET UART' else
|
||||
PM35Parser(options.get('role', 'response')) if mode == 'PM35 UART' else None)
|
||||
gap = (3.5 * (9 + (parity != 'none') + stops) / baud if mode == 'PM35 UART'
|
||||
@@ -320,7 +351,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
bitrate = options.get('baudrate', 1000000)
|
||||
sample_point = options.get('sample_point', 70) / 100
|
||||
if not math.isfinite(bitrate) or bitrate <= 0 or not .1 <= sample_point <= .95:
|
||||
raise ValueError('Некорректный битрейт / точка выборки CAN.')
|
||||
raise ValueError('Некорректный битрейт / точка выборки CAN.')
|
||||
reassembler = Reassembler()
|
||||
for a, b, frame, error in can_frames(channel, start, end, bitrate, options.get('inverted', False), sample_point, cancel):
|
||||
report(a)
|
||||
@@ -328,7 +359,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
emit(a, b, 'error', error)
|
||||
# Never bridge an invalid/missing physical frame.
|
||||
for pending in reassembler.pending.values():
|
||||
emit(pending['start'], b, 'incomplete', 'SET CAN: сборка прервана ошибкой шины')
|
||||
emit(pending['start'], b, 'incomplete', 'SET CAN: сборка прервана ошибкой шины')
|
||||
reassembler.pending.clear()
|
||||
continue
|
||||
ident, data = frame['ident'], frame['data']
|
||||
@@ -347,9 +378,9 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
|
||||
except ValueError as exc:
|
||||
emit(a, b, 'error', str(exc))
|
||||
for pending in reassembler.pending.values():
|
||||
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
|
||||
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
|
||||
else:
|
||||
raise ValueError('Неизвестный анализатор: ' + mode)
|
||||
raise ValueError('Неизвестный анализатор: ' + mode)
|
||||
except OverflowError:
|
||||
truncated = True
|
||||
_cancel(cancel)
|
||||
|
||||
255
python/logic_analyzer/conversion.py
Normal file
255
python/logic_analyzer/conversion.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Digital CSV/JSON/SAL/DSL conversion using the shared capture readers.
|
||||
|
||||
SAL v3 encoding follows DSLogic_Logic_2/dslogic_script/tests/generate_test_sal.py.
|
||||
Only digital signals are exported; analyzer settings and analog data are not copied.
|
||||
"""
|
||||
import csv
|
||||
import heapq
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from .files import read_capture, ImportCancelled
|
||||
from .csv_import import convert_csv, _ones, BLOCK_SAMPLES, MAX_PACKED_BYTES
|
||||
from .sal_metadata import metadata
|
||||
|
||||
FORMATS = {'csv': 'CSV — цифровые переходы', 'json': 'JSON — цифровые переходы', 'dsl': 'DSL — DSView',
|
||||
'sal': 'SAL — Logic 2 (v3)'}
|
||||
|
||||
|
||||
def _check(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
def _csv(capture, path, progress, cancel):
|
||||
with path.open('w', encoding='utf-8', newline='') as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(['Time [s]', *[c.name for c in capture.channels]])
|
||||
events = heapq.merge([capture.start, capture.end],
|
||||
*(c.edges for c in capture.channels))
|
||||
for index, (time, _) in enumerate(itertools.groupby(events)):
|
||||
if index % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30)))
|
||||
writer.writerow([repr(time), *[c.level_at(time) for c in capture.channels]])
|
||||
|
||||
|
||||
def _json(capture, path, progress, cancel):
|
||||
names = ['Time [s]', *[c.name for c in capture.channels]]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError('Для JSON имена каналов должны быть уникальны и отличаться от Time [s].')
|
||||
with path.open('w', encoding='utf-8', newline='') as stream:
|
||||
stream.write('[\n')
|
||||
events = heapq.merge([capture.start, capture.end], *(c.edges for c in capture.channels))
|
||||
for index, (time, _) in enumerate(itertools.groupby(events)):
|
||||
if index % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30)))
|
||||
if index:
|
||||
stream.write(',\n')
|
||||
row = dict(zip(names, [time, *[c.level_at(time) for c in capture.channels]]))
|
||||
stream.write(' ' + json.dumps(row, ensure_ascii=False, allow_nan=False))
|
||||
stream.write('\n]\n')
|
||||
|
||||
|
||||
# Времена переводятся в целые отсчёты одинаково для SAL и DSL.
|
||||
# Округление допускается лишь в пределах погрешности, без ресемплинга.
|
||||
def _grid(capture, rate, cancel):
|
||||
rate = rate or capture.sample_rate
|
||||
if not math.isfinite(rate) or rate <= 0:
|
||||
raise ValueError('Для экспорта в SAL/DSL укажите частоту дискретизации в Гц.')
|
||||
def sample(time):
|
||||
value = (time - capture.start) * rate
|
||||
rounded = round(value)
|
||||
if not math.isclose(value, rounded, rel_tol=0, abs_tol=1e-5):
|
||||
raise ValueError('Временные метки не попадают на сетку выбранной частоты. Увеличьте частоту.')
|
||||
return rounded
|
||||
count = sample(capture.end)
|
||||
# CSV final row is a sample, binary capture end is exclusive.
|
||||
if capture.format.endswith('CSV') or capture.format == 'JSON':
|
||||
count += 1
|
||||
if not 0 < count < 2**53:
|
||||
raise ValueError('Недопустимая длительность записи.')
|
||||
edges = []
|
||||
for channel in capture.channels:
|
||||
result = []
|
||||
previous = 0
|
||||
for i, time in enumerate(channel.edges):
|
||||
if i % 8192 == 0:
|
||||
_check(cancel)
|
||||
value = sample(time)
|
||||
if not previous < value < count:
|
||||
raise ValueError('Частота не позволяет сохранить все фронты.')
|
||||
result.append(value)
|
||||
previous = value
|
||||
edges.append(result)
|
||||
return rate, count, edges
|
||||
|
||||
|
||||
def _run(length):
|
||||
value = length - 1
|
||||
if value < 64:
|
||||
return bytes([value])
|
||||
shift = 7
|
||||
while value >> shift >= 64:
|
||||
shift += 7
|
||||
out = bytearray([64 | (value >> shift)])
|
||||
for bit in range(shift - 7, -1, -7):
|
||||
out.append(((value >> bit) & 127) | (128 if bit else 0))
|
||||
return out
|
||||
|
||||
|
||||
# SAL хранит длины серий уровней. Метаданные описывают цифровые каналы;
|
||||
# настройки анализаторов исходной записи сюда не переносятся.
|
||||
def _sal(capture, path, rate, count, edges, progress, cancel):
|
||||
if len(capture.channels) > 16:
|
||||
raise ValueError('Экспорт SAL поддерживает до 16 цифровых каналов.')
|
||||
meta = metadata()
|
||||
data = meta['data']
|
||||
data['name'] = path.stem
|
||||
data['captureStartTime'] = dict(unixTimeMilliseconds=0, fractionalMilliseconds=0)
|
||||
data['captureNotes'] = 'Converted digital signals; source time origin: %s s' % capture.start
|
||||
data['renderViewState'] = dict(type='PanAndZoom', leftEdgeTimeSec=0,
|
||||
timeScaleSeconds=count/rate/10)
|
||||
data['captureSettings']['timerModeSettings']['stopAfterSeconds'] = count/rate
|
||||
data['legacySettings']['sampleRate'] = {'digital': rate}
|
||||
data['legacySettings']['enabledChannels'] = [dict(type='Digital', index=i)
|
||||
for i in range(len(edges))]
|
||||
data['rowsSettings'] = [dict(id=str(uuid.uuid4()), height=100, isMarkedHidden=False,
|
||||
type='channel', name=c.name,
|
||||
channel=dict(category='legacy', type='Digital', deviceChannel=i))
|
||||
for i, c in enumerate(capture.channels)]
|
||||
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('meta.json', json.dumps(meta, ensure_ascii=False))
|
||||
for i, (channel, transitions) in enumerate(zip(capture.channels, edges)):
|
||||
encoded = bytearray()
|
||||
previous = 0
|
||||
for j, boundary in enumerate(itertools.chain(transitions, [count])):
|
||||
if j % 8192 == 0:
|
||||
_check(cancel)
|
||||
encoded.extend(_run(boundary - previous))
|
||||
previous = boundary
|
||||
header = b'<SALEAE>' + struct.pack('<II', 3, 100)
|
||||
header += struct.pack('<BdQdBBQ', 1, rate, 0, 0, 0, 0, 1)
|
||||
header += struct.pack('<QQBBQ', 0, count, channel.initial, 0, len(encoded))
|
||||
archive.writestr('digital-%d.bin' % i, header + encoded)
|
||||
progress((i+1)*99//len(edges))
|
||||
archive.writestr('trigger-store.bin', b'<SALEAE>' + struct.pack('<IIIIQ', 3, 103, 1, 0, 0))
|
||||
|
||||
|
||||
# DSL упаковывает каждый канал по блокам. Дополнение до 64 отсчётов
|
||||
# удерживает последний уровень, не создавая ложный фронт.
|
||||
def _dsl(capture, path, rate, count, edges, progress, cancel):
|
||||
names = [c.name for c in capture.channels]
|
||||
if any(any(ch in name for ch in '\r\n=[]') for name in names):
|
||||
raise ValueError('Для DSL имена каналов не должны содержать =, [, ] и переносы строк.')
|
||||
padded = (count+63)//64*64
|
||||
if padded//8*len(names) > MAX_PACKED_BYTES:
|
||||
raise ValueError('Запись DSL превышает ограничение 2 ГиБ распакованных данных.')
|
||||
blocks = (padded+BLOCK_SAMPLES-1)//BLOCK_SAMPLES
|
||||
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
|
||||
'device mode = 0\ncapturefile = data\n'
|
||||
f'total samples = {padded}\ntotal probes = {len(names)}\ntotal blocks = {blocks}\n'
|
||||
f'samplerate = {rate:.12f} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||||
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
|
||||
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
|
||||
**{'Sample count': str(padded), 'Sample rate': str(rate), 'Max Height': '1X'},
|
||||
decoder=[], channel=[dict(colour='default', enabled=True, index=i, name=name,
|
||||
strigger=0, type=10000, view_index=i)
|
||||
for i, name in enumerate(names)])
|
||||
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive:
|
||||
archive.writestr('header', header)
|
||||
archive.writestr('session', json.dumps(session, ensure_ascii=False))
|
||||
archive.writestr('decoders', '[]')
|
||||
for i, (channel, transitions) in enumerate(zip(capture.channels, edges)):
|
||||
boundaries = iter(itertools.chain(transitions, [padded]))
|
||||
boundary = next(boundaries)
|
||||
level = channel.initial
|
||||
for block in range(blocks):
|
||||
_check(cancel)
|
||||
start = block*BLOCK_SAMPLES
|
||||
end = min(padded, start+BLOCK_SAMPLES)
|
||||
buffer = bytearray((end-start)//8)
|
||||
position = start
|
||||
while position < end:
|
||||
stop = min(end, boundary)
|
||||
if level:
|
||||
_ones(buffer, position-start, stop-start)
|
||||
position = stop
|
||||
if position == boundary:
|
||||
level ^= 1
|
||||
boundary = next(boundaries, padded)
|
||||
_check(cancel)
|
||||
archive.writestr(f'L-{i}/{block}', buffer)
|
||||
progress((i*blocks+block+1)*99//(len(names)*blocks))
|
||||
return padded-count
|
||||
|
||||
|
||||
def convert_capture(source, destination, progress=lambda p: None, cancel=lambda: False,
|
||||
*, sample_rate=0, csv_options=None):
|
||||
"""Export to a new file; cancel/failure never leaves a partial destination."""
|
||||
source, destination = Path(source), Path(destination)
|
||||
suffix = destination.suffix.lower().lstrip('.')
|
||||
if suffix not in FORMATS or source.suffix.lower().lstrip('.') not in FORMATS:
|
||||
raise ValueError('Поддерживаются цифровые CSV, JSON, SAL и DSL.')
|
||||
if source.resolve() == destination.resolve() or destination.exists():
|
||||
raise ValueError('Выберите новый выходной файл: существующие файлы не перезаписываются.')
|
||||
_check(cancel)
|
||||
# Нормализация расширенного входа получает первые 25% прогресса.
|
||||
# В рекурсивный вызов csv_options не передаётся: повторного прохода нет.
|
||||
if source.suffix.lower() == '.csv' and csv_options is not None:
|
||||
from .csv_options import normalize_csv
|
||||
with tempfile.TemporaryDirectory(prefix='.csv-options-', dir=destination.parent) as directory:
|
||||
normalized = Path(directory)/'normalized.csv'
|
||||
normalize_csv(source, normalized, csv_options, sample_rate,
|
||||
lambda p: progress(p*25//100), cancel)
|
||||
return convert_capture(normalized, destination, lambda p: progress(25+p*75//100),
|
||||
cancel, sample_rate=sample_rate)
|
||||
# Автоматическая сетка использует точный потоковый CSV-импортёр.
|
||||
# Явно заданную частоту проверяет _grid, её нельзя молча заменить.
|
||||
if source.suffix.lower() == '.csv' and suffix == 'dsl' and not sample_rate:
|
||||
result = convert_csv(source, destination, progress, cancel)
|
||||
return 'Готово: %s\nDSL: %s Гц; дополнение %s отсчётов; начало %s с.' % (
|
||||
destination, result['samplerate'], result['padding'], result['origin_seconds'])
|
||||
capture = read_capture(source, lambda p: progress(p*35//100), cancel)
|
||||
padding = 0
|
||||
# Сначала собираем временный архив рядом с назначением. Его владелец —
|
||||
# этот контекст: ошибка и отмена тоже удаляют промежуточный результат.
|
||||
with tempfile.TemporaryDirectory(prefix='.convert-', dir=destination.parent) as directory:
|
||||
temporary = Path(directory)/destination.name
|
||||
report = lambda p: progress(35+p*60//100)
|
||||
if suffix == 'csv':
|
||||
_csv(capture, temporary, report, cancel)
|
||||
elif suffix == 'json':
|
||||
_json(capture, temporary, report, cancel)
|
||||
else:
|
||||
rate, count, edges = _grid(capture, sample_rate, cancel)
|
||||
if suffix == 'sal':
|
||||
_sal(capture, temporary, rate, count, edges, report, cancel)
|
||||
else:
|
||||
padding = _dsl(capture, temporary, rate, count, edges, report, cancel)
|
||||
_check(cancel)
|
||||
# Режим xb защищает от гонки с другим процессом после проверки пути.
|
||||
# При ошибке удаляем только файл, который успели создать сами.
|
||||
created = False
|
||||
try:
|
||||
with destination.open('xb') as output:
|
||||
created = True
|
||||
with temporary.open('rb') as stream:
|
||||
while chunk := stream.read(1024*1024):
|
||||
_check(cancel)
|
||||
output.write(chunk)
|
||||
_check(cancel)
|
||||
except BaseException:
|
||||
if created:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
progress(100)
|
||||
return 'Готово: %s\nКаналов: %d. Дополнение DSL: %d отсчётов.' % (
|
||||
destination, len(capture.channels), padding)
|
||||
211
python/logic_analyzer/csv_import.py
Normal file
211
python/logic_analyzer/csv_import.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Stream digital CSV into DSView v3, preserving intervals between transitions."""
|
||||
from contextlib import contextmanager
|
||||
import csv
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
from math import gcd
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from .files import ImportCancelled
|
||||
|
||||
TICKS = 10**12 # exact decimal picoseconds, independent of float rounding
|
||||
BLOCK_SAMPLES = 8 * 1024 * 1024
|
||||
MAX_PACKED_BYTES = 2 * 1024**3
|
||||
TIME_HEADER = re.compile(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', re.I)
|
||||
|
||||
|
||||
def _check(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _reader(path, progress, cancel):
|
||||
size = max(1, path.stat().st_size)
|
||||
with path.open(encoding='utf-8-sig', newline='') as stream:
|
||||
consumed = 0
|
||||
|
||||
def lines():
|
||||
nonlocal consumed
|
||||
for index, line in enumerate(stream):
|
||||
consumed += len(line)
|
||||
if index % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(min(99, consumed * 100 // size))
|
||||
if line.strip() and not line.lstrip().startswith(('#', ';')):
|
||||
yield line
|
||||
|
||||
source = lines()
|
||||
header = next(source, '')
|
||||
if not header:
|
||||
raise ValueError('CSV не содержит данных.')
|
||||
try:
|
||||
delimiter = csv.Sniffer().sniff(header, delimiters=',;\t').delimiter
|
||||
except csv.Error as error:
|
||||
raise ValueError('Не удалось определить разделитель CSV: используйте запятую, ; или табуляцию.') from error
|
||||
names = [value.strip() for value in next(csv.reader([header], delimiter=delimiter))]
|
||||
match = TIME_HEADER.fullmatch(names[0])
|
||||
if not match or not 2 <= len(names) <= 65:
|
||||
raise ValueError('Ожидается CSV: Time [s] (или ms/us/ns), затем цифровые каналы 0/1.')
|
||||
channels = names[1:]
|
||||
if len(set(names)) != len(names) or any(not n or any(c in n for c in '\r\n=[]') for n in channels):
|
||||
raise ValueError('Имена каналов должны быть уникальными и не содержать =, [, ] или переносы строк.')
|
||||
unit = (match[1] or 's').lower()
|
||||
scale = {'s': TICKS, 'ms': 10**9, 'us': 10**6, 'µs': 10**6, 'ns': 1000}[unit]
|
||||
|
||||
def rows():
|
||||
previous = None
|
||||
for index, row in enumerate(csv.reader(source, delimiter=delimiter), 2):
|
||||
try:
|
||||
value = Decimal(row[0].strip().replace(',', '.')) * scale
|
||||
if not value.is_finite() or value != value.to_integral_value():
|
||||
raise ValueError('время должно быть конечным, с точностью не выше 1 пс')
|
||||
timestamp = int(value)
|
||||
if previous is not None and timestamp <= previous:
|
||||
raise ValueError('время должно строго возрастать')
|
||||
levels = tuple(item.strip() for item in row[1:])
|
||||
if len(levels) != len(channels) or any(item not in ('0', '1') for item in levels):
|
||||
raise ValueError('ожидаются цифровые уровни 0 или 1 для каждого канала')
|
||||
previous = timestamp
|
||||
yield timestamp, levels
|
||||
except (ValueError, InvalidOperation, IndexError) as error:
|
||||
raise ValueError('CSV, строка данных %d: %s' % (index, error)) from error
|
||||
|
||||
yield channels, rows()
|
||||
|
||||
|
||||
def _ones(block, start, end):
|
||||
"""Set a half-open run without iterating over every sample."""
|
||||
if end <= start:
|
||||
return
|
||||
first, bit = divmod(start, 8)
|
||||
last, stop = divmod(end, 8)
|
||||
if first == last:
|
||||
block[first] |= ((1 << (end - start)) - 1) << bit
|
||||
return
|
||||
if bit:
|
||||
block[first] |= (255 << bit) & 255
|
||||
first += 1
|
||||
block[first:last] = b'\xff' * (last - first)
|
||||
if stop:
|
||||
block[last] |= (1 << stop) - 1
|
||||
|
||||
|
||||
def convert_csv(source, destination, progress=lambda value: None, cancel=lambda: False,
|
||||
*, max_packed_bytes=MAX_PACKED_BYTES, block_samples=BLOCK_SAMPLES):
|
||||
"""Two bounded-memory passes; input is untouched, output created exclusively.
|
||||
|
||||
The first row establishes initial levels, not an edge. Its timestamp may
|
||||
precede the clock grid by a fraction of one period. All subsequent times
|
||||
and transition intervals are represented exactly; the origin rounds down
|
||||
at most one period. The final level is held to the 64-sample DSL boundary.
|
||||
"""
|
||||
source, destination = Path(source), Path(destination)
|
||||
if source.resolve() == destination.resolve() or destination.exists():
|
||||
raise ValueError('Для импорта нужен новый выходной файл.')
|
||||
if block_samples < 64 or block_samples % 64:
|
||||
raise ValueError('Размер блока должен быть кратен 64 отсчётам.')
|
||||
stamp = (source.stat().st_size, source.stat().st_mtime_ns)
|
||||
first = previous = anchor = None
|
||||
step = count = 0
|
||||
with _reader(source, lambda p: progress(p * 40 // 100), cancel) as (channels, rows):
|
||||
for time, _ in rows:
|
||||
if count == 0:
|
||||
first = time
|
||||
elif count == 1:
|
||||
anchor = time
|
||||
else:
|
||||
step = gcd(step, time - previous)
|
||||
previous = time
|
||||
count += 1
|
||||
if count < 2:
|
||||
raise ValueError('В CSV нужны минимум две строки с разным временем.')
|
||||
if count == 2:
|
||||
step = anchor - first
|
||||
period = gcd(step, TICKS)
|
||||
rate = TICKS // period
|
||||
origin = anchor - ((anchor - first + period - 1) // period) * period
|
||||
samples = (previous - origin) // period + 1
|
||||
padded = (samples + 63) // 64 * 64
|
||||
if padded // 8 * len(channels) > max_packed_bytes:
|
||||
raise ValueError('Запись слишком велика для DSView при сохранении точности времени. Выберите меньший интервал CSV.')
|
||||
blocks_count = (padded + block_samples - 1) // block_samples
|
||||
result = dict(channels=channels, samplerate=rate, samples=samples, padding=padded - samples,
|
||||
origin_seconds=str(Decimal(origin) / TICKS), source=str(source))
|
||||
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
|
||||
'device mode = 0\ncapturefile = data\n'
|
||||
f'total samples = {padded}\ntotal probes = {len(channels)}\ntotal blocks = {blocks_count}\n'
|
||||
f'samplerate = {rate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||||
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(channels)))
|
||||
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
|
||||
**{'Sample count': str(padded), 'Sample rate': str(rate), 'Max Height': '1X'},
|
||||
decoder=[], channel=[dict(colour='default', enabled=True, index=i, name=name,
|
||||
strigger=0, type=10000, view_index=i)
|
||||
for i, name in enumerate(channels)])
|
||||
fd, temporary = tempfile.mkstemp(prefix='.csv-', suffix='.dsl', dir=destination.parent)
|
||||
os.close(fd)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive:
|
||||
archive.writestr('header', header)
|
||||
archive.writestr('session', json.dumps(session, ensure_ascii=False))
|
||||
archive.writestr('decoders', '[]')
|
||||
archive.writestr('csv_import.json', json.dumps(result, ensure_ascii=False))
|
||||
if stamp != (source.stat().st_size, source.stat().st_mtime_ns):
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
with _reader(source, lambda p: None, cancel) as (names, rows):
|
||||
if names != channels:
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
current = next(rows)
|
||||
if current[0] != first:
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
following = next(rows, None)
|
||||
block_start = 0
|
||||
row_count = 1
|
||||
for block in range(blocks_count):
|
||||
_check(cancel)
|
||||
block_end = min(padded, block_start + block_samples)
|
||||
buffers = [bytearray((block_end - block_start) // 8) for _ in channels]
|
||||
position = block_start
|
||||
while position < block_end:
|
||||
boundary = (following[0] - origin) // period if following else padded
|
||||
if boundary < position or (following and (following[0] - anchor) % period):
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
end = min(boundary, block_end)
|
||||
for buffer, level in zip(buffers, current[1]):
|
||||
if level == '1':
|
||||
_ones(buffer, position - block_start, end - block_start)
|
||||
position = end
|
||||
if following and position == boundary:
|
||||
current, following = following, next(rows, None)
|
||||
row_count += 1
|
||||
for index, buffer in enumerate(buffers):
|
||||
_check(cancel)
|
||||
archive.writestr(f'L-{index}/{block}', buffer)
|
||||
block_start = block_end
|
||||
progress(40 + (block + 1) * 59 // blocks_count)
|
||||
if following is not None or row_count != count:
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
_check(cancel)
|
||||
if stamp != (source.stat().st_size, source.stat().st_mtime_ns):
|
||||
raise ValueError('CSV изменился во время чтения.')
|
||||
# Never overwrite an existing user file, including one created mid-import.
|
||||
created = False
|
||||
try:
|
||||
with destination.open('xb') as output:
|
||||
created = True
|
||||
with open(temporary, 'rb') as data:
|
||||
while chunk := data.read(1024 * 1024):
|
||||
_check(cancel)
|
||||
output.write(chunk)
|
||||
except BaseException:
|
||||
if created:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
progress(100)
|
||||
return result
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
132
python/logic_analyzer/csv_options.py
Normal file
132
python/logic_analyzer/csv_options.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Streaming normalization of configurable digital CSV input."""
|
||||
import csv
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import itertools
|
||||
import re
|
||||
|
||||
from .files import ImportCancelled
|
||||
|
||||
|
||||
def normalize_csv(source, destination, options, sample_rate, progress, cancel):
|
||||
"""Write canonical CSV without expanding transition-only input into samples."""
|
||||
# Нормализация отделена от упаковки SAL/DSL: каждый выходной формат
|
||||
# получает один и тот же CSV с временем в секундах и выбранными каналами.
|
||||
pattern = re.compile(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', re.I)
|
||||
# Decimal сохраняет десятичную сетку входа при смене единиц времени.
|
||||
# str() не переносит в Decimal двоичную погрешность float из Qt.
|
||||
rate = Decimal(str(sample_rate))
|
||||
if not rate.is_finite() or rate < 0:
|
||||
raise ValueError('Частота должна быть конечной и неотрицательной.')
|
||||
mode = options.get('mode', 'auto')
|
||||
duration = options.get('duration')
|
||||
if duration is not None and (mode != 'events' or not rate):
|
||||
raise ValueError('Длительность требует режима переходов и заданной частоты.')
|
||||
# Оба потока закрываются и при отмене. Временным файлом владеет вызывающий
|
||||
# convert_capture: он удалит его вместе с временным каталогом.
|
||||
with source.open(encoding='utf-8-sig', newline='') as stream, destination.open('w', encoding='utf-8', newline='') as output:
|
||||
size = max(1, source.stat().st_size)
|
||||
consumed = 0
|
||||
|
||||
# Читаем последовательно, не разворачивая событийную запись в отсчёты.
|
||||
# Проверка отмены ограничивает задержку реакции на кнопку в интерфейсе.
|
||||
def lines():
|
||||
nonlocal consumed
|
||||
for index, line in enumerate(stream):
|
||||
consumed += len(line)
|
||||
if index % 4096 == 0:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
progress(min(99, consumed * 100 // size))
|
||||
if line.strip() and not line.lstrip().startswith(('#', ';')):
|
||||
yield line
|
||||
|
||||
source_lines = lines()
|
||||
first = next(source_lines, '')
|
||||
if not first:
|
||||
raise ValueError('CSV не содержит данных.')
|
||||
# Явный разделитель имеет приоритет. Авто оценивает первую строку;
|
||||
# для неоднозначного CSV оператор может выбрать разделитель вручную.
|
||||
delimiter = options.get('delimiter') or max((',', ';', '\t'), key=first.count)
|
||||
first_row = next(csv.reader([first], delimiter=delimiter))
|
||||
no_header = options.get('no_header', False)
|
||||
# При отсутствии заголовка первая строка остаётся данными. D0/D1 —
|
||||
# имена исходных колонок, поэтому время тоже может называться D0.
|
||||
names = ['D%d' % i for i in range(len(first_row))] if no_header else [x.strip() for x in first_row]
|
||||
if len(set(names)) != len(names) or any(not n for n in names):
|
||||
raise ValueError('Имена колонок должны быть непустыми и уникальными.')
|
||||
time_column = options.get('time_column', 'auto')
|
||||
# Автоматически выбираем только узнаваемое имя времени. При нескольких
|
||||
# кандидатах нельзя молча взять первый: это изменило бы шкалу записи.
|
||||
if time_column == 'auto':
|
||||
candidates = [n for n in names if pattern.fullmatch(n)]
|
||||
if len(candidates) > 1:
|
||||
raise ValueError('Найдено несколько колонок времени; выберите одну.')
|
||||
time_column = candidates[0] if candidates else None
|
||||
elif time_column == 'none':
|
||||
time_column = None
|
||||
if time_column is not None and time_column not in names:
|
||||
raise ValueError('Колонка времени не найдена: ' + time_column)
|
||||
if time_column is None and (not rate or mode == 'events'):
|
||||
raise ValueError('Без колонки времени задайте частоту; режим переходов требует времени.')
|
||||
# Порядок списка задаёт выходные номера каналов. Служебные колонки
|
||||
# не обязаны быть цифровыми, если пользователь исключил их из списка.
|
||||
channels = options.get('channels') or [n for n in names if n != time_column]
|
||||
if not 1 <= len(channels) <= 64 or len(set(channels)) != len(channels):
|
||||
raise ValueError('Выберите от 1 до 64 различных цифровых каналов.')
|
||||
if any(n not in names or n == time_column for n in channels):
|
||||
raise ValueError('Канал отсутствует или совпадает с колонкой времени.')
|
||||
# Индексы вычисляются один раз, а не поиском имён для каждой строки.
|
||||
indices = [names.index(n) for n in channels]
|
||||
time_index = names.index(time_column) if time_column is not None else None
|
||||
unit = options.get('time_unit', 'auto')
|
||||
# Явная единица позволяет читать нестандартную колонку t. Без суффикса
|
||||
# auto означает секунды, как и обычный импортёр цифровых записей.
|
||||
if unit == 'auto':
|
||||
match = re.search(r'[\[(](s|ms|us|µs|ns)[\])]$', time_column or '', re.I)
|
||||
unit = match[1].lower() if match else 's'
|
||||
scale = Decimal({'s': '1', 'ms': '.001', 'us': '.000001', 'µs': '.000001', 'ns': '.000000001'}[unit])
|
||||
rows = csv.reader(source_lines, delimiter=delimiter)
|
||||
# Возвращаем уже прочитанную первую строку в ленивый итератор.
|
||||
if no_header:
|
||||
rows = itertools.chain([first_row], rows)
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Time [s]'] + channels)
|
||||
previous = origin = None
|
||||
for index, row in enumerate(rows):
|
||||
try:
|
||||
if len(row) != len(names):
|
||||
raise ValueError('число колонок отличается от заголовка')
|
||||
# Без времени строки — последовательные отсчёты с нуля.
|
||||
# При наличии времени сохраняем исходное начало, даже отрицательное.
|
||||
timestamp = Decimal(row[time_index].strip().replace(',', '.')) * scale if time_index is not None else Decimal(index) / rate
|
||||
if not timestamp.is_finite() or (previous is not None and timestamp <= previous):
|
||||
raise ValueError('время должно быть конечным и строго возрастать')
|
||||
if origin is None:
|
||||
origin = timestamp
|
||||
# Проверка равномерности относится только к отсчётам с заданной
|
||||
# частотой; событийная запись вправе иметь длинные паузы.
|
||||
if mode == 'samples' and rate and abs((timestamp-origin)*rate-index) > Decimal('.00001'):
|
||||
raise ValueError('временные метки не соответствуют частоте отсчётов')
|
||||
levels = [row[i].strip() for i in indices]
|
||||
if any(v not in ('0', '1') for v in levels):
|
||||
raise ValueError('выбранные каналы должны содержать 0 или 1')
|
||||
writer.writerow([str(timestamp)] + levels)
|
||||
previous = timestamp
|
||||
except (ValueError, InvalidOperation) as error:
|
||||
raise ValueError('CSV, строка данных %d: %s' % (index+1, error)) from error
|
||||
if previous is None:
|
||||
raise ValueError('CSV не содержит данных.')
|
||||
# Длительность задаёт исключительную границу: N отсчётов занимают
|
||||
# N/rate секунд, но последний находится в (N-1)/rate. Дописываем
|
||||
# удержание последнего уровня, не создавая нового фронта.
|
||||
if duration is not None:
|
||||
count = Decimal(str(duration)) * rate
|
||||
if not count.is_finite() or count <= 0 or abs(count-count.to_integral_value()) > Decimal('.00001'):
|
||||
raise ValueError('Длительность должна быть положительной и попадать на сетку частоты.')
|
||||
last_sample = origin + (count.to_integral_value()-1)/rate
|
||||
if previous > last_sample:
|
||||
raise ValueError('Длительность заканчивается до последнего отсчёта записи.')
|
||||
if previous < last_sample:
|
||||
writer.writerow([str(last_sample)] + levels)
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
@@ -7,6 +7,7 @@ therefore checked as PASS/FAIL.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
import math
|
||||
|
||||
|
||||
PROFILES = {
|
||||
@@ -43,7 +44,7 @@ class TimingChecker(object):
|
||||
|
||||
def __init__(self, samplerate, profile='1SP0635', delay_tolerance_ns=100.0,
|
||||
vin_active_high=True, vstat_active_high=True,
|
||||
custom=None):
|
||||
custom=None, orphan_min_width_ns=0.0, cycle_results=False):
|
||||
if not samplerate:
|
||||
raise ValueError('samplerate is required')
|
||||
if profile == 'custom':
|
||||
@@ -55,8 +56,13 @@ class TimingChecker(object):
|
||||
self.profile = profile
|
||||
self.samplerate = float(samplerate)
|
||||
self.delay_tolerance_ns = float(delay_tolerance_ns)
|
||||
self.orphan_min_width_ns = float(orphan_min_width_ns)
|
||||
if not math.isfinite(self.orphan_min_width_ns) or self.orphan_min_width_ns < 0:
|
||||
raise ValueError('ORPHAN minimum pulse width must be finite and non-negative')
|
||||
self.vin_active_high = bool(vin_active_high)
|
||||
self.vstat_active_high = bool(vstat_active_high)
|
||||
self.cycle_results = cycle_results
|
||||
self.cycle = None
|
||||
self.pending = deque()
|
||||
self.status_start = None
|
||||
self.status_control = None
|
||||
@@ -85,12 +91,55 @@ class TimingChecker(object):
|
||||
'level': int(level),
|
||||
'edge': 'ON' if state_on else 'OFF',
|
||||
}
|
||||
events = []
|
||||
if self.cycle_results:
|
||||
if state_on:
|
||||
if self.cycle is not None and not self.cycle['emitted']:
|
||||
events += self._finish_cycle(self.cycle, sample, incomplete=True)
|
||||
self.cycle = dict(start=int(sample), severity=0, reasons=[], resolved=set(), emitted=False)
|
||||
item['cycle'] = self.cycle
|
||||
self.pending.append(item)
|
||||
return [{
|
||||
return events + [{
|
||||
'kind': 'control', 'start': int(sample), 'end': int(sample),
|
||||
'text': 'Vin %s' % item['edge'], 'short': item['edge'],
|
||||
'text': 'Vin1 %s' % item['edge'], 'short': item['edge'],
|
||||
}]
|
||||
|
||||
def _finish_cycle(self, cycle, sample, incomplete=False):
|
||||
if cycle['emitted']:
|
||||
return []
|
||||
cycle['emitted'] = True
|
||||
if incomplete:
|
||||
cycle['severity'] = max(1, cycle['severity'])
|
||||
cycle['reasons'].append('incomplete cycle')
|
||||
verdict = ('OK', 'WARNING', 'FAULT')[cycle['severity']]
|
||||
detail = ', '.join(dict.fromkeys(cycle['reasons']))
|
||||
return [dict(kind='cycle_' + verdict.lower(), start=cycle['start'], end=int(sample),
|
||||
text=verdict + ': Vin1 ON / ACK / OFF / ACK' + (' - ' + detail if detail else ''),
|
||||
short=verdict)]
|
||||
|
||||
def _cycle_note(self, cycle, kind):
|
||||
if cycle is None or cycle['emitted']:
|
||||
return
|
||||
severity = 2 if kind in ('missing', 'width_fail', 'fault') else 1 if kind in ('delay_warn', 'orphan') else 0
|
||||
cycle['severity'] = max(cycle['severity'], severity)
|
||||
if severity:
|
||||
cycle['reasons'].append(kind)
|
||||
|
||||
def _resolve_cycle(self, item, sample, kind):
|
||||
cycle = item.get('cycle') if item else None
|
||||
if cycle is None or cycle['emitted']:
|
||||
return []
|
||||
self._cycle_note(cycle, kind)
|
||||
cycle['resolved'].add(item['edge'])
|
||||
if cycle['resolved'] == {'ON', 'OFF'}:
|
||||
return self._finish_cycle(cycle, sample)
|
||||
return []
|
||||
|
||||
def finish_cycles(self, sample):
|
||||
if self.cycle is not None and not self.cycle['emitted']:
|
||||
return self._finish_cycle(self.cycle, sample, incomplete=True)
|
||||
return []
|
||||
|
||||
def expire(self, sample):
|
||||
events = []
|
||||
timeout_samples = self.ns_to_samples(self.ack_timeout_ns)
|
||||
@@ -99,10 +148,11 @@ class TimingChecker(object):
|
||||
end = item['sample'] + timeout_samples
|
||||
events.append({
|
||||
'kind': 'missing', 'start': item['sample'], 'end': end,
|
||||
'text': 'FAIL: no Vstat ACK after Vin %s (timeout %s)' %
|
||||
'text': 'FAIL: no Vstat ACK after Vin1 %s (timeout %s)' %
|
||||
(item['edge'], format_ns(self.ack_timeout_ns)),
|
||||
'short': 'NO ACK',
|
||||
})
|
||||
events.extend(self._resolve_cycle(item, end, 'missing'))
|
||||
return events
|
||||
|
||||
def on_status_edge(self, sample, level):
|
||||
@@ -121,6 +171,7 @@ class TimingChecker(object):
|
||||
typ_ns = self.spec['ack_delay_typ_ns']
|
||||
delta_ns = delay_ns - typ_ns
|
||||
in_window = abs(delta_ns) <= self.delay_tolerance_ns
|
||||
self._cycle_note(self.status_control.get('cycle'), 'delay_ok' if in_window else 'delay_warn')
|
||||
events.append({
|
||||
'kind': 'delay_ok' if in_window else 'delay_warn',
|
||||
'start': self.status_control['sample'], 'end': sample,
|
||||
@@ -134,6 +185,7 @@ class TimingChecker(object):
|
||||
return events
|
||||
|
||||
if self.status_start is None:
|
||||
self._cycle_note(self.cycle, 'orphan')
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': sample, 'end': sample,
|
||||
'text': 'Unexpected inactive Vstat edge', 'short': 'Vstat?',
|
||||
@@ -164,10 +216,14 @@ class TimingChecker(object):
|
||||
'short': 'FAULT %s' % format_ns(width_ns),
|
||||
})
|
||||
elif control is None:
|
||||
# This is an annotation filter, not a signal debounce: leave ACK
|
||||
# matching and fault detection intact. Equality passes the filter.
|
||||
if width_ns < self.orphan_min_width_ns:
|
||||
return events
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'Unexpected Vstat pulse %s (no Vin edge)' % format_ns(width_ns),
|
||||
'text': 'Unexpected Vstat pulse %s (no Vin1 edge)' % format_ns(width_ns),
|
||||
'short': 'ORPHAN %s' % format_ns(width_ns),
|
||||
})
|
||||
else:
|
||||
@@ -178,4 +234,87 @@ class TimingChecker(object):
|
||||
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
|
||||
'short': 'BAD ACK %s' % format_ns(width_ns),
|
||||
})
|
||||
kind = events[-1]['kind']
|
||||
if control is None:
|
||||
self._cycle_note(self.cycle, kind)
|
||||
events.extend(self._resolve_cycle(control, sample, kind))
|
||||
return events
|
||||
|
||||
|
||||
class InputTimingChecker(object):
|
||||
"""Measure complete active pulses and OFF->ON handovers of two inputs.
|
||||
|
||||
Feed all input levels at a sample together, including the initial sample.
|
||||
Unknown pulse starts at the capture boundary are never measured.
|
||||
"""
|
||||
|
||||
def __init__(self, samplerate, vin1_active_high=True, vin2_active_high=True,
|
||||
vin1_mintime_ns=0, vin2_mintime_ns=0):
|
||||
self.samplerate = float(samplerate)
|
||||
self.minimum = (float(vin1_mintime_ns), float(vin2_mintime_ns))
|
||||
if not math.isfinite(self.samplerate) or self.samplerate <= 0:
|
||||
raise ValueError('samplerate must be finite and positive')
|
||||
if any(not math.isfinite(v) or v < 0 for v in self.minimum):
|
||||
raise ValueError('Vin mintime must be finite and non-negative')
|
||||
self.polarity = (bool(vin1_active_high), bool(vin2_active_high))
|
||||
self.levels = None
|
||||
self.starts = [None, None]
|
||||
self.off = [None, None]
|
||||
self.overlap_start = None
|
||||
|
||||
def _event(self, kind, start, end, label, **values):
|
||||
duration = (end - start) * 1e9 / self.samplerate
|
||||
text = '%s: %s' % (label, format_ns(duration))
|
||||
result = dict(kind=kind, start=start, end=end, text=text, short=text,
|
||||
duration_ns=duration)
|
||||
result.update(values)
|
||||
return result
|
||||
|
||||
def update(self, sample, vin1, vin2=None):
|
||||
sample = int(sample)
|
||||
levels = [bool(vin1) == self.polarity[0],
|
||||
None if vin2 is None else bool(vin2) == self.polarity[1]]
|
||||
if self.levels is None:
|
||||
self.levels = levels
|
||||
if all(levels):
|
||||
self.overlap_start = sample
|
||||
return []
|
||||
events = []
|
||||
previous = self.levels
|
||||
# Record OFF edges first so simultaneous handovers measure zero.
|
||||
for i in range(2):
|
||||
if previous[i] is True and levels[i] is False:
|
||||
self.off[i] = sample
|
||||
if self.starts[i] is not None:
|
||||
duration = (sample - self.starts[i]) * 1e9 / self.samplerate
|
||||
failed = duration < self.minimum[i]
|
||||
events.append(self._event(
|
||||
'mintime_fail' if failed else 'mintime_ok', self.starts[i], sample,
|
||||
'%s: Vin%d active (mintime %s)' %
|
||||
('FAIL' if failed else 'PASS', i + 1, format_ns(self.minimum[i])),
|
||||
channel=i + 1))
|
||||
self.starts[i] = None
|
||||
for i in range(2):
|
||||
if previous[i] is False and levels[i] is True:
|
||||
self.starts[i] = sample
|
||||
other = 1 - i
|
||||
if levels[other] is False and self.off[other] is not None:
|
||||
events.append(self._event('deadtime', self.off[other], sample,
|
||||
'Deadtime Vin%d -> Vin%d' % (other + 1, i + 1),
|
||||
from_channel=other+1, to_channel=i+1))
|
||||
# A turn-on consumes the preceding turn-off of either input.
|
||||
self.off = [None, None]
|
||||
if all(levels) and not all(previous):
|
||||
self.overlap_start = sample
|
||||
elif all(previous) and not all(levels):
|
||||
events.append(self._event('overlap', self.overlap_start, sample,
|
||||
'FAIL: Vin1/Vin2 overlap'))
|
||||
self.overlap_start = None
|
||||
self.levels = levels
|
||||
return events
|
||||
|
||||
def finish(self, sample):
|
||||
if self.overlap_start is not None:
|
||||
return [self._event('overlap', self.overlap_start, int(sample),
|
||||
'FAIL: Vin1/Vin2 overlap (continues at capture end)')]
|
||||
return []
|
||||
|
||||
56
python/logic_analyzer/decoders/transistor_pair.py
Normal file
56
python/logic_analyzer/decoders/transistor_pair.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Independent complementary transistor input timing; no ACK/Vstat dependency."""
|
||||
import math
|
||||
from .gate_timing import InputTimingChecker, format_ns
|
||||
|
||||
|
||||
class PairTimingChecker(InputTimingChecker):
|
||||
def __init__(self, samplerate, vin1_active_high=True, vin2_active_high=True,
|
||||
vin1_mintime_ns=0, vin2_mintime_ns=0,
|
||||
deadtime_12_ns=0, deadtime_21_ns=0,
|
||||
vin1_minoff_ns=0, vin2_minoff_ns=0):
|
||||
super().__init__(samplerate, vin1_active_high, vin2_active_high,
|
||||
vin1_mintime_ns, vin2_mintime_ns)
|
||||
self.deadtime_min = (float(deadtime_12_ns), float(deadtime_21_ns))
|
||||
self.minoff = (float(vin1_minoff_ns), float(vin2_minoff_ns))
|
||||
if any(not math.isfinite(v) or v < 0 for v in self.deadtime_min + self.minoff):
|
||||
raise ValueError('Timing limits must be finite and non-negative')
|
||||
self.last_on = [None, None]
|
||||
self.last_off = [None, None]
|
||||
|
||||
def update(self, sample, vin1, vin2):
|
||||
sample = int(sample)
|
||||
previous = self.levels[:] if self.levels is not None else None
|
||||
events = super().update(sample, vin1, vin2)
|
||||
for event in events:
|
||||
if event['kind'] == 'deadtime':
|
||||
source = event['from_channel'] - 1
|
||||
limit = self.deadtime_min[source]
|
||||
if event['duration_ns'] < limit:
|
||||
event['kind'] = 'deadtime_fail'
|
||||
event['text'] = 'FAULT: ' + event['text'] + ' < minimum ' + format_ns(limit)
|
||||
event['short'] = 'FAULT: deadtime'
|
||||
if previous is None:
|
||||
return events
|
||||
for i in range(2):
|
||||
if previous[i] and not self.levels[i]:
|
||||
self.last_off[i] = sample
|
||||
elif not previous[i] and self.levels[i]:
|
||||
off = self.last_off[i]
|
||||
on = self.last_on[i]
|
||||
if off is not None:
|
||||
duration = (sample - off) * 1e9 / self.samplerate
|
||||
failed = duration < self.minoff[i]
|
||||
events.append(self._event('off_fail' if failed else 'off_time', off, sample,
|
||||
'%s: Vin%d OFF (minimum %s)' %
|
||||
('FAULT' if failed else 'OK', i + 1, format_ns(self.minoff[i])), channel=i+1))
|
||||
if on is not None and off is not None and on < off < sample:
|
||||
period = sample - on
|
||||
frequency = self.samplerate / period
|
||||
duty = 100.0 * (off - on) / period
|
||||
text = 'Vin%d: period %s, %.3f Hz, duty %.2f%%' % (
|
||||
i + 1, format_ns(period * 1e9 / self.samplerate), frequency, duty)
|
||||
events.append(dict(kind='period', start=on, end=sample, text=text,
|
||||
short='%.3f Hz / %.2f%%' % (frequency, duty),
|
||||
channel=i+1, frequency_hz=frequency, duty_percent=duty))
|
||||
self.last_on[i] = sample
|
||||
return events
|
||||
205
python/logic_analyzer/dslogic.py
Normal file
205
python/logic_analyzer/dslogic.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""DSLogic ONLINE through an isolated DSView/libsigrok4DSL host."""
|
||||
from array import array
|
||||
from dataclasses import dataclass, asdict
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from .files import DigitalCapture, DigitalChannel, ImportCancelled
|
||||
from .dslogic_trigger import validate_trigger
|
||||
|
||||
DRIVER = 'dsview'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DSLogicDevice:
|
||||
device_id: str
|
||||
name: str
|
||||
channels: tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DSLogicSettings:
|
||||
device_id: str
|
||||
executable: str = 'setgui-dslogic'
|
||||
channels: tuple = (0, 1)
|
||||
sample_rate: int = 1000000
|
||||
duration: float = 1.0
|
||||
threshold: float = None
|
||||
buffer_mb: int = 256
|
||||
acquisition_mode: str = 'stream'
|
||||
trigger: dict = None
|
||||
|
||||
def validate(self):
|
||||
if not re.fullmatch(r'dsview:\d+\.\d+', self.device_id):
|
||||
raise ValueError('Выберите устройство DSLogic; повторите поиск устройств.')
|
||||
if not self.executable.strip():
|
||||
raise ValueError('Не найден модуль DSView. Пересоберите или переустановите SETGUI.')
|
||||
if (not self.channels or len(set(self.channels)) != len(self.channels)
|
||||
or any(type(c) is not int or not 0 <= c < 32 for c in self.channels)):
|
||||
raise ValueError('Выберите цифровые каналы без повторений.')
|
||||
if type(self.sample_rate) is not int or not 1 <= self.sample_rate <= 400000000:
|
||||
raise ValueError('Частота должна быть от 1 до 400000000 S/s.')
|
||||
if not math.isfinite(self.duration) or not 0 <= self.duration <= 3600:
|
||||
raise ValueError('Длительность должна быть от 0 до 3600 с.')
|
||||
if self.threshold is not None and (not math.isfinite(self.threshold) or not 0 <= self.threshold <= 5):
|
||||
raise ValueError('Порог должен быть от 0 до 5 V.')
|
||||
if not 16 <= self.buffer_mb <= 4096:
|
||||
raise ValueError('Лимит записи должен быть от 16 до 4096 MB.')
|
||||
if self.acquisition_mode not in ('buffer', 'stream', 'internal'):
|
||||
raise ValueError('Неизвестный режим прибора.')
|
||||
if self.duration == 0 and self.acquisition_mode != 'stream':
|
||||
raise ValueError('Режим «До остановки» доступен только для потокового захвата.')
|
||||
validate_trigger(self.trigger, self.channels, self.acquisition_mode)
|
||||
|
||||
|
||||
def _popen(arguments, **kwargs):
|
||||
try:
|
||||
# A frozen GUI must not pass its extraction directory to the separate
|
||||
# PyInstaller host (especially PyInstaller 5's _MEIPASS2 protocol).
|
||||
environment = {k: v for k, v in os.environ.items()
|
||||
if k != '_MEIPASS2' and not k.startswith('_PYI_')}
|
||||
kwargs.setdefault('env', environment)
|
||||
return subprocess.Popen(arguments, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), **kwargs)
|
||||
except OSError as exc:
|
||||
raise RuntimeError('Не удалось запустить модуль DSView. Нужен setgui-dslogic.exe из сборки SETGUI. %s' % exc) from exc
|
||||
|
||||
|
||||
def _terminate(process):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=3)
|
||||
|
||||
|
||||
def _request(executable, request, directory, stop, cancel, status):
|
||||
directory = Path(directory)
|
||||
(directory / 'request.json').write_text(json.dumps(request), encoding='utf-8')
|
||||
with tempfile.TemporaryFile() as log:
|
||||
process = _popen([executable, str(directory)], stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||
try:
|
||||
startup_deadline = time.monotonic() + 30
|
||||
capture_deadline = None
|
||||
stop_deadline = None
|
||||
ready = False
|
||||
waiting_trigger = False
|
||||
while process.poll() is None:
|
||||
now = time.monotonic()
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
if request['action'] == 'capture' and not ready and (directory / 'ready').exists():
|
||||
ready = True
|
||||
duration = request['settings']['duration']
|
||||
trigger = request['settings'].get('trigger') or {'kind': 'none'}
|
||||
triggered = trigger.get('kind', 'none') != 'none'
|
||||
waiting_trigger = triggered
|
||||
timeout = trigger.get('timeout', 30)
|
||||
capture_deadline = (now + duration + timeout + 35 if timeout else None) if triggered else (now + duration + 35 if duration else None)
|
||||
status('Ожидание триггера DSLogic…' if triggered else 'Захват DSLogic…')
|
||||
if waiting_trigger and (directory / 'triggered').exists():
|
||||
waiting_trigger = False
|
||||
capture_deadline = now + duration + 35 if duration else None
|
||||
if stop_deadline is None:
|
||||
status('Триггер сработал. Получение записи DSLogic…')
|
||||
if stop() and stop_deadline is None:
|
||||
(directory / 'stop').touch()
|
||||
stop_deadline = now + 10
|
||||
status('Остановка DSView и получение записи…')
|
||||
if ((not ready and now > startup_deadline) or
|
||||
(capture_deadline is not None and now > capture_deadline) or
|
||||
(stop_deadline is not None and now > stop_deadline)):
|
||||
raise RuntimeError('Модуль DSView не отвечает. Проверьте USB и закройте DSView.')
|
||||
time.sleep(.02)
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
result_file = directory / 'result.json'
|
||||
if not result_file.exists():
|
||||
log.seek(0)
|
||||
detail = log.read(4096).decode('utf-8', 'replace')
|
||||
raise RuntimeError('Модуль DSView завершился с кодом %s. %s' % (process.returncode, detail))
|
||||
result = json.loads(result_file.read_text(encoding='utf-8'))
|
||||
if result.get('error'):
|
||||
raise RuntimeError(result['error'])
|
||||
if process.returncode:
|
||||
raise RuntimeError('Модуль DSView завершился с кодом %s.' % process.returncode)
|
||||
return result
|
||||
finally:
|
||||
_terminate(process)
|
||||
|
||||
|
||||
def list_devices(executable='setgui-dslogic', cancel=lambda: False):
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-dsview-') as directory:
|
||||
result = _request(executable, {'action': 'scan'}, directory, lambda: False, cancel, lambda _: None)
|
||||
return [DSLogicDevice(d['device_id'], d['name'], tuple(d['channels'])) for d in result['devices']]
|
||||
|
||||
|
||||
def read_cross(stream, metadata, settings, cancel=lambda: False):
|
||||
"""Convert channel-interleaved 64-bit words to edges without expanding samples."""
|
||||
bits = metadata['channels']
|
||||
samples, rate = metadata['samples'], metadata['sample_rate']
|
||||
if (metadata.get('format') != 'cross64-le' or bits != sorted(settings.channels)
|
||||
or type(samples) is not int or samples <= 0 or not math.isfinite(rate) or rate <= 0
|
||||
or (settings.acquisition_mode != 'internal' and rate != settings.sample_rate)):
|
||||
raise ValueError('Некорректные параметры записи DSView.')
|
||||
channels = [DigitalChannel('D%d' % bit, 0, array('d')) for bit in bits]
|
||||
group = struct.Struct('<' + 'Q' * len(bits))
|
||||
offset = edges = 0
|
||||
previous = [0] * len(bits)
|
||||
while offset < samples:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
groups = min(4096, (samples - offset + 63) // 64)
|
||||
raw = stream.read(groups * group.size)
|
||||
if len(raw) != groups * group.size:
|
||||
raise ValueError('Обрезанная запись DSView.')
|
||||
for words in group.iter_unpack(raw):
|
||||
length = min(64, samples - offset)
|
||||
mask = (1 << length) - 1
|
||||
for index, word in enumerate(words):
|
||||
channel = channels[index]
|
||||
if offset == 0:
|
||||
channel.initial = word & 1
|
||||
previous[index] = channel.initial
|
||||
changed = (word ^ ((word << 1) | previous[index])) & mask
|
||||
while changed:
|
||||
low_bit = changed & -changed
|
||||
channel.edges.append((offset + low_bit.bit_length() - 1) / rate)
|
||||
changed ^= low_bit
|
||||
edges += 1
|
||||
if edges * 8 > settings.buffer_mb * 1024 * 1024:
|
||||
raise ValueError('Превышен лимит памяти фронтов. Уменьшите длительность или число каналов.')
|
||||
previous[index] = (word >> (length - 1)) & 1
|
||||
offset += length
|
||||
source = metadata.get('name', 'DSLogic') + ' · %g MS/s' % (rate / 1e6)
|
||||
if metadata.get('limited'):
|
||||
source += ' · достигнут лимит записи'
|
||||
capture = DigitalCapture(source, 'DSLogic ONLINE · DSView', channels, 0, samples / rate, rate)
|
||||
trigger_sample = metadata.get('trigger_sample')
|
||||
if trigger_sample is not None and 0 <= trigger_sample < samples:
|
||||
capture.trigger_time = trigger_sample / rate
|
||||
return capture
|
||||
|
||||
|
||||
def acquire(settings, stop, cancel, status=lambda text: None):
|
||||
settings.validate()
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
status('Подключение к DSLogic через DSView…')
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-dsview-') as directory:
|
||||
result = _request(settings.executable, {'action': 'capture', 'settings': asdict(settings)},
|
||||
directory, stop, cancel, status)
|
||||
status('Построение цифровых каналов…')
|
||||
with (Path(directory) / 'capture.bin').open('rb') as stream:
|
||||
capture = read_cross(stream, result, settings, cancel)
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
return capture
|
||||
106
python/logic_analyzer/dslogic_trigger.py
Normal file
106
python/logic_analyzer/dslogic_trigger.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""DSView trigger validation and API programming, independent of Qt/cffi."""
|
||||
import math
|
||||
|
||||
|
||||
def pattern(value):
|
||||
return ''.join(str(value).upper().split())
|
||||
|
||||
|
||||
def validate_trigger(trigger, channels, acquisition):
|
||||
trigger = trigger or {'kind': 'none'}
|
||||
kind = trigger.get('kind', 'none')
|
||||
if kind not in ('none', 'simple', 'stages', 'serial'):
|
||||
raise ValueError('Неизвестный тип триггера.')
|
||||
if kind == 'none':
|
||||
return
|
||||
if acquisition == 'internal':
|
||||
raise ValueError('Внутренний тест работает без триггера.')
|
||||
if kind in ('stages', 'serial') and acquisition != 'buffer':
|
||||
raise ValueError('Расширенный и последовательный триггеры требуют буферного режима.')
|
||||
position = trigger.get('position', 50)
|
||||
timeout = trigger.get('timeout', 30)
|
||||
if type(position) is not int or not 0 <= position <= 90:
|
||||
raise ValueError('Позиция триггера: 0…90 %.')
|
||||
if not math.isfinite(timeout) or not 0 <= timeout <= 3600:
|
||||
raise ValueError('Ожидание триггера: 0…3600 с; 0 — без тайм-аута.')
|
||||
|
||||
def check(value, data=False):
|
||||
value = pattern(value)
|
||||
if len(value) != 16 or any(c not in ('01X' if data else '01XRFC') for c in value):
|
||||
raise ValueError('Условие должно содержать 16 символов: ' + ('0, 1, X.' if data else '0, 1, X, R, F, C.'))
|
||||
if not data and any(c != 'X' and 15-i not in channels for i, c in enumerate(value)):
|
||||
raise ValueError('В триггере указан выключенный канал.')
|
||||
return value
|
||||
|
||||
if kind == 'simple':
|
||||
value = check(trigger.get('pattern', 'X'*16))
|
||||
if set(value) == {'X'}:
|
||||
raise ValueError('Выберите хотя бы одно условие триггера.')
|
||||
elif kind == 'stages':
|
||||
stages = trigger.get('stages', [])
|
||||
if not 1 <= len(stages) <= 16:
|
||||
raise ValueError('Число ступеней: 1…16.')
|
||||
for stage in stages:
|
||||
a, b = check(stage['a']), check(stage['b'])
|
||||
if a == b == 'X'*16:
|
||||
raise ValueError('Задайте условие на каждой ступени.')
|
||||
if stage.get('logic', 'and') not in ('and', 'or'):
|
||||
raise ValueError('Логика ступени: И либо ИЛИ.')
|
||||
if type(stage.get('count', 1)) is not int or not 1 <= stage.get('count', 1) <= 2147483647:
|
||||
raise ValueError('Счётчик ступени: 1…2147483647.')
|
||||
else:
|
||||
check(trigger['start']); check(trigger['stop'])
|
||||
clock = check(trigger['clock'])
|
||||
check(trigger.get('compare', 'X'*16))
|
||||
if not any(c in 'RFC' for c in clock):
|
||||
raise ValueError('В условии такта нужен фронт R, F или C.')
|
||||
if type(trigger['data_channel']) is not int or not 0 <= trigger['data_channel'] <= 15:
|
||||
raise ValueError('Канал последовательных данных: D0…D15.')
|
||||
if trigger['data_channel'] not in channels:
|
||||
raise ValueError('Канал последовательных данных выключен.')
|
||||
if type(trigger['bits']) is not int or not 1 <= trigger['bits'] <= 16:
|
||||
raise ValueError('Число последовательных битов: 1…16.')
|
||||
check(trigger['value'], data=True)
|
||||
|
||||
|
||||
def program_trigger(lib, trigger, acquisition, check):
|
||||
"""Follow DSView TriggerDock::commit_trigger, including stage count - 1."""
|
||||
trigger = trigger or {'kind': 'none'}
|
||||
kind = trigger.get('kind', 'none')
|
||||
def call(name, *args):
|
||||
check(getattr(lib, name)(*args), name)
|
||||
call('ds_trigger_reset')
|
||||
call('ds_trigger_set_en', int(kind != 'none'))
|
||||
if kind == 'none':
|
||||
return
|
||||
call('ds_trigger_set_pos', 1 if acquisition == 'stream' else trigger.get('position', 50))
|
||||
call('ds_trigger_set_mode', {'simple': 0, 'stages': 1, 'serial': 2}[kind])
|
||||
if kind == 'simple':
|
||||
for ch, value in enumerate(reversed(pattern(trigger['pattern']))):
|
||||
call('ds_trigger_probe_set', ch, ord(value), ord('X'))
|
||||
return
|
||||
def values(index, a, b):
|
||||
call('ds_trigger_stage_set_value', index, 16,
|
||||
' '.join(pattern(a)).encode('ascii'), ' '.join(pattern(b)).encode('ascii'))
|
||||
if kind == 'stages':
|
||||
stages = trigger['stages']
|
||||
call('ds_trigger_set_stage', len(stages)-1)
|
||||
for index, stage in enumerate(stages):
|
||||
values(index, stage['a'], stage['b'])
|
||||
logic = int(stage.get('logic', 'and') == 'and') | (int(stage.get('contiguous', False)) << 1)
|
||||
call('ds_trigger_stage_set_logic', index, 16, logic)
|
||||
call('ds_trigger_stage_set_inv', index, 16, int(stage.get('inv_a', False)), int(stage.get('inv_b', False)))
|
||||
call('ds_trigger_stage_set_count', index, 16, stage.get('count', 1), 0)
|
||||
else:
|
||||
call('ds_trigger_set_stage', 3)
|
||||
values(0, trigger['start'], trigger['stop'])
|
||||
values(1, trigger['clock'], trigger.get('compare', 'X'*16))
|
||||
select = list('X'*16)
|
||||
select[15-trigger['data_channel']] = '0'
|
||||
values(2, ''.join(select), 'X'*16)
|
||||
values(3, trigger['value'], 'X'*16)
|
||||
for index in range(4):
|
||||
call('ds_trigger_stage_set_logic', index, 16, 1)
|
||||
call('ds_trigger_stage_set_inv', index, 16, 0, 0)
|
||||
call('ds_trigger_stage_set_count', 1, 16, 1, 0)
|
||||
call('ds_trigger_stage_set_count', 3, 16, trigger['bits']-1, 0)
|
||||
242
python/logic_analyzer/dsview_helper.py
Normal file
242
python/logic_analyzer/dsview_helper.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Isolated DSView driver host. JSON request/result files, raw CROSS_DATA spool.
|
||||
|
||||
Native callbacks and their references live until process exit. Never import
|
||||
this module's native dependencies into the Qt process.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def run(request, directory):
|
||||
import pydsview
|
||||
from pydsview import Config, DeviceType, DeviceMode
|
||||
from pydsview._binding import ffi, lib
|
||||
from pydsview._constants import PacketType, Event
|
||||
from pydsview.errors import check_sr
|
||||
|
||||
# The upstream library is a process-global singleton with async callbacks.
|
||||
# This process owns exactly one operation and explicitly closes the device.
|
||||
context = pydsview.DSContext()
|
||||
devices = []
|
||||
selected = None
|
||||
for info in context.list_devices():
|
||||
if 'DSLogic' not in info.name:
|
||||
continue
|
||||
device = context.activate_device(info.handle)
|
||||
if device.device_type != DeviceType.USB or device.mode != DeviceMode.LOGIC:
|
||||
continue
|
||||
connection = device.get_config(Config.CONN)
|
||||
ident = 'dsview:' + str(connection)
|
||||
item = dict(device_id=ident, name=device.name,
|
||||
channels=[c.index for c in device.channels])
|
||||
devices.append(item)
|
||||
if request['action'] == 'capture' and ident == request['settings']['device_id']:
|
||||
selected = device
|
||||
break
|
||||
if request['action'] == 'scan':
|
||||
lib.ds_close_all_device()
|
||||
return {'devices': devices}
|
||||
if selected is None:
|
||||
raise RuntimeError('Выбранный DSLogic отключён. Повторите поиск устройств.')
|
||||
|
||||
options = request['settings']
|
||||
from logic_analyzer.dslogic import DSLogicSettings
|
||||
from logic_analyzer.dslogic_trigger import program_trigger
|
||||
DSLogicSettings(**options).validate()
|
||||
acquisition = options.get('acquisition_mode', 'stream')
|
||||
selected.set_config(Config.OPERATION_MODE, {'buffer': 0, 'stream': 1, 'internal': 2}[acquisition])
|
||||
if acquisition != 'stream':
|
||||
selected.set_config(Config.BUFFER_OPTIONS, 1) # Upload on manual stop, as in DSView.
|
||||
trigger = options.get('trigger') or {'kind': 'none'}
|
||||
trigger_enabled = trigger.get('kind', 'none') != 'none'
|
||||
|
||||
# The driver accepts arbitrary samplerate values without validating them.
|
||||
# Select a supported channel mode and verify against its advertised rates.
|
||||
ffi.cdef('''
|
||||
struct setgui_list_item { int id; const char *name; };
|
||||
void *g_variant_lookup_value(void *, const char *, const void *);
|
||||
const void *g_variant_get_fixed_array(void *, size_t *, size_t);
|
||||
void g_variant_unref(void *);
|
||||
struct setgui_trigger_pos { uint32_t check_id, real_pos, ram_saddr, remain_cnt_l, remain_cnt_h, status; };
|
||||
|
||||
''')
|
||||
glib = ffi.dlopen(str(Path(pydsview.__file__).parent / '_libs/libglib-2.0-0.dll'))
|
||||
modes_variant = ffi.new('GVariant *[1]')
|
||||
check_sr(lib.ds_get_actived_device_config_list(ffi.NULL, Config.CHANNEL_MODE, modes_variant), 'Режимы каналов DSView')
|
||||
try:
|
||||
modes = ffi.cast('struct setgui_list_item *', lib.pyds_gvariant_get_uint64(modes_variant[0]))
|
||||
selected_mode = None
|
||||
for index in range(64):
|
||||
if modes[index].id == -1:
|
||||
break
|
||||
title = ffi.string(modes[index].name).decode()
|
||||
match = re.fullmatch(r'Use (\d+) Channels \(Max (\d+)(MHz|GHz)\)', title)
|
||||
buffered = re.fullmatch(r'Use Channels 0~(\d+) \(Max (\d+)(MHz|GHz)\)', title)
|
||||
capacity = int(match[1]) if match else int(buffered[1])+1 if buffered else 0
|
||||
detail = match or buffered
|
||||
maximum = int(detail[2]) * (1000000000 if detail[3] == 'GHz' else 1000000) if detail else 0
|
||||
fits = (len(options['channels']) <= capacity if match else max(options['channels']) < capacity)
|
||||
if fits and (acquisition == 'internal' or maximum >= options['sample_rate']):
|
||||
selected_mode = modes[index].id
|
||||
break
|
||||
if selected_mode is None:
|
||||
raise ValueError('Частота или число каналов не поддерживаются в выбранном режиме DSView.')
|
||||
selected.set_config(Config.CHANNEL_MODE, selected_mode)
|
||||
finally:
|
||||
lib.pyds_gvariant_unref(modes_variant[0])
|
||||
rates_variant = ffi.new('GVariant *[1]')
|
||||
check_sr(lib.ds_get_actived_device_config_list(ffi.NULL, Config.SAMPLERATE, rates_variant), 'Частоты DSView')
|
||||
rate_array = glib.g_variant_lookup_value(rates_variant[0], b'samplerates', ffi.NULL)
|
||||
try:
|
||||
if rate_array == ffi.NULL:
|
||||
raise ValueError('DSView не вернул доступные частоты.')
|
||||
count_rates = ffi.new('size_t *')
|
||||
values = ffi.cast('uint64_t *', glib.g_variant_get_fixed_array(rate_array, count_rates, 8))
|
||||
rates = [int(values[i]) for i in range(count_rates[0])]
|
||||
if acquisition != 'internal' and options['sample_rate'] not in rates:
|
||||
raise ValueError('Допустимые частоты, S/s: ' + ', '.join(map(str, rates)))
|
||||
finally:
|
||||
if rate_array != ffi.NULL:
|
||||
glib.g_variant_unref(rate_array)
|
||||
lib.pyds_gvariant_unref(rates_variant[0])
|
||||
available = {c.index for c in selected.channels}
|
||||
channels = sorted(options['channels'])
|
||||
if not set(channels).issubset(available):
|
||||
raise ValueError('Выбранные каналы недоступны в этом режиме.')
|
||||
for channel in selected.channels:
|
||||
selected.enable_channel(channel.index, channel.index in channels)
|
||||
if acquisition != 'internal':
|
||||
selected.samplerate = options['sample_rate']
|
||||
rate = selected.samplerate
|
||||
if acquisition != 'internal' and rate != options['sample_rate']:
|
||||
raise ValueError('Устройство изменило частоту. Выберите поддерживаемую частоту.')
|
||||
if options['threshold'] is not None:
|
||||
selected.set_config(Config.VTH, float(options['threshold']))
|
||||
# Round storage to complete groups: 64 samples per enabled channel.
|
||||
cap_groups = options['buffer_mb'] * 1024 * 1024 // (8 * len(channels))
|
||||
requested = max(1, round(options['duration'] * rate)) if options['duration'] else cap_groups * 64
|
||||
limit = min(requested, cap_groups * 64)
|
||||
if acquisition != 'stream':
|
||||
limit = min(limit, int(selected.get_config(Config.HW_DEPTH)))
|
||||
if limit < 1024:
|
||||
raise ValueError('Для буферного захвата нужно не менее 1024 выборок. Увеличьте длительность.')
|
||||
# Buffer hardware transfers whole 1024-sample blocks. Round the request
|
||||
# up, then trim transport padding back to the requested samples on import.
|
||||
selected.sample_count = ((limit + 1023) // 1024 * 1024) if acquisition != 'stream' else limit
|
||||
program_trigger(lib, trigger, acquisition, check_sr)
|
||||
|
||||
done = threading.Event()
|
||||
overflow = threading.Event()
|
||||
errors = []
|
||||
count = [0]
|
||||
trigger_sample = [None]
|
||||
trigger_seen = threading.Event()
|
||||
ceiling = ((limit + 63) // 64) * 8 * len(channels)
|
||||
stream = open(directory / 'capture.bin', 'wb')
|
||||
|
||||
@ffi.callback('void(const void*, const struct sr_datafeed_packet*)')
|
||||
def data_callback(_device, packet):
|
||||
try:
|
||||
if packet.status:
|
||||
errors.append('Ошибка пакета DSView: %d' % packet.status)
|
||||
overflow.set()
|
||||
if packet.type == PacketType.TRIGGER:
|
||||
info = ffi.cast('const struct setgui_trigger_pos *', packet.payload)
|
||||
if info.status & 1:
|
||||
trigger_sample[0] = int(info.real_pos)
|
||||
trigger_seen.set()
|
||||
(directory / 'triggered').touch()
|
||||
elif packet.type == PacketType.LOGIC:
|
||||
logic = ffi.cast('const struct sr_datafeed_logic *', packet.payload)
|
||||
if logic.format != 0 or logic.data_error:
|
||||
raise ValueError('Неподдерживаемый формат или ошибка данных DSView.')
|
||||
size = min(int(logic.length), ceiling - count[0])
|
||||
if size > 0:
|
||||
stream.write(ffi.buffer(ffi.cast('const char *', logic.data), size))
|
||||
count[0] += size
|
||||
if count[0] >= ceiling:
|
||||
overflow.set()
|
||||
elif packet.type == PacketType.OVERFLOW:
|
||||
errors.append('Переполнение USB-потока DSLogic. Уменьшите частоту.')
|
||||
overflow.set()
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
overflow.set()
|
||||
|
||||
@ffi.callback('void(int)')
|
||||
def event_callback(event):
|
||||
if event in (Event.COLLECT_TASK_END_BY_DETACHED, Event.COLLECT_TASK_END_BY_ERROR):
|
||||
errors.append('DSLogic отключён или захват завершился с ошибкой (%d).' % event)
|
||||
if event in (Event.COLLECT_TASK_END, Event.COLLECT_TASK_END_BY_DETACHED,
|
||||
Event.COLLECT_TASK_END_BY_ERROR):
|
||||
done.set()
|
||||
|
||||
lib.ds_set_datafeed_callback(data_callback)
|
||||
lib.ds_set_event_callback(event_callback)
|
||||
# Keep callbacks alive even if an exception unwinds this frame.
|
||||
global _callbacks
|
||||
_callbacks = (data_callback, event_callback)
|
||||
started = False
|
||||
try:
|
||||
check_sr(lib.ds_start_collect(), 'Не удалось начать захват DSView')
|
||||
started = True
|
||||
(directory / 'ready').touch()
|
||||
waiting = trigger_enabled
|
||||
timeout = trigger.get('timeout', 30)
|
||||
deadline = (time.monotonic() + timeout if timeout else None) if waiting else (time.monotonic() + limit / rate + 30 if options['duration'] else None)
|
||||
stopped = False
|
||||
while not done.wait(.02):
|
||||
if waiting and trigger_seen.is_set():
|
||||
waiting = False
|
||||
deadline = time.monotonic() + limit / rate + 30 if options['duration'] else None
|
||||
if (directory / 'stop').exists() or overflow.is_set() or (deadline and time.monotonic() > deadline):
|
||||
stopped = True
|
||||
check_sr(lib.ds_stop_collect(), 'Не удалось остановить DSView')
|
||||
if not done.wait(5):
|
||||
raise RuntimeError('DSView не подтвердил остановку захвата.')
|
||||
if deadline and time.monotonic() > deadline:
|
||||
raise RuntimeError('Истекло время ожидания триггера DSLogic.' if waiting else 'Истекло время ожидания данных DSLogic.')
|
||||
break
|
||||
if errors:
|
||||
raise RuntimeError(errors[0])
|
||||
stream.flush()
|
||||
group_bytes = 8 * len(channels)
|
||||
if count[0] % group_bytes:
|
||||
raise ValueError('Неполная группа выборок DSLogic.')
|
||||
samples = min(limit, count[0] // group_bytes * 64)
|
||||
if not samples:
|
||||
raise ValueError('DSLogic не вернул выборки.')
|
||||
return dict(channels=channels, sample_rate=rate, samples=samples,
|
||||
format='cross64-le', stopped=stopped, name=selected.name + ' · ' + acquisition,
|
||||
trigger_sample=trigger_sample[0] if trigger_enabled else None,
|
||||
limited=limit < requested or not options['duration'] and samples >= limit)
|
||||
finally:
|
||||
if started and lib.ds_is_collecting():
|
||||
lib.ds_stop_collect()
|
||||
done.wait(5)
|
||||
# Keep stream open for late callbacks until OS process cleanup.
|
||||
lib.ds_close_all_device()
|
||||
|
||||
|
||||
def main():
|
||||
directory = Path(sys.argv[1]).resolve()
|
||||
code = 0
|
||||
try:
|
||||
request = json.loads((directory / 'request.json').read_text(encoding='utf-8'))
|
||||
result = run(request, directory)
|
||||
except BaseException as exc:
|
||||
result = {'error': str(exc)}
|
||||
code = 1
|
||||
(directory / 'result.json').write_text(json.dumps(result, ensure_ascii=False), encoding='utf-8')
|
||||
# DSView owns native threads. Interpreter finalization can invalidate cffi
|
||||
# callbacks before they exit. All device I/O was closed before this point.
|
||||
os._exit(code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
87
python/logic_analyzer/dsview_host.py
Normal file
87
python/logic_analyzer/dsview_host.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Process boundary for the patched DSView GUI (host protocol 1, Windows).
|
||||
|
||||
The host owns only its child process/window. Qt 5, Python and libusb remain
|
||||
inside DSView; they must never be loaded into the hosting application's Qt.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def resolve_runtime(bundled, executable=None, frozen=None):
|
||||
"""A dsview folder beside the GUI EXE overrides its bundled runtime."""
|
||||
frozen = getattr(sys, 'frozen', False) if frozen is None else frozen
|
||||
if frozen:
|
||||
external = Path(executable or sys.executable).resolve().parent / 'dsview'
|
||||
if external.exists():
|
||||
# An incomplete external install should be reported, not silently
|
||||
# replaced with an older bundled component.
|
||||
return external
|
||||
return Path(bundled)
|
||||
|
||||
|
||||
def child_environment(runtime, stylesheet, capture_directory, environment=None):
|
||||
"""Remove Python/Qt/PyInstaller overrides before starting the private runtime."""
|
||||
env = dict(os.environ if environment is None else environment)
|
||||
for key in list(env):
|
||||
if key.upper().startswith(('PYTHON', 'QT_', 'QML', 'PYSIDE', '_PYI', '_MEIPASS')):
|
||||
env.pop(key)
|
||||
windows = Path(env.get('SystemRoot', r'C:\Windows'))
|
||||
env['PATH'] = os.pathsep.join(map(str, (Path(runtime), windows / 'System32', windows)))
|
||||
env['DSVIEW_HOST_STYLE'] = str(stylesheet)
|
||||
env['DSVIEW_CAPTURE_DIR'] = str(capture_directory)
|
||||
env['PYTHONDONTWRITEBYTECODE'] = '1'
|
||||
return env
|
||||
|
||||
|
||||
def ready_window(line):
|
||||
"""Only accept the versioned child's explicit ready message."""
|
||||
fields = line.strip().split()
|
||||
if len(fields) == 2 and fields[0] == 'DSVIEW_READY':
|
||||
try:
|
||||
value = int(fields[1])
|
||||
return value if value > 0 else None
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def window_api():
|
||||
from ctypes import wintypes
|
||||
api = ctypes.WinDLL('user32', use_last_error=True)
|
||||
api.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
||||
api.GetWindowThreadProcessId.restype = wintypes.DWORD
|
||||
api.GetParent.argtypes = [wintypes.HWND]
|
||||
api.GetParent.restype = wintypes.HWND
|
||||
return api
|
||||
|
||||
|
||||
def owns_window(window, pid, parent=None):
|
||||
if sys.platform != 'win32' or not window or not pid:
|
||||
return False
|
||||
from ctypes import wintypes
|
||||
api = window_api()
|
||||
owner = wintypes.DWORD()
|
||||
api.GetWindowThreadProcessId(window, ctypes.byref(owner))
|
||||
return owner.value == pid and (parent is None or api.GetParent(window) == parent)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def independent_dll_directory():
|
||||
"""Do not inherit PyInstaller's Qt DLL search directory into DSView."""
|
||||
if sys.platform != 'win32' or not getattr(sys, 'frozen', False):
|
||||
yield
|
||||
return
|
||||
api = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
api.GetDllDirectoryW.argtypes = [ctypes.c_uint32, ctypes.c_wchar_p]
|
||||
api.SetDllDirectoryW.argtypes = [ctypes.c_wchar_p]
|
||||
buffer = ctypes.create_unicode_buffer(32768)
|
||||
api.GetDllDirectoryW(len(buffer), buffer)
|
||||
if not api.SetDllDirectoryW(None):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
api.SetDllDirectoryW(buffer.value or None)
|
||||
@@ -42,6 +42,7 @@ class DigitalCapture:
|
||||
start: float
|
||||
end: float
|
||||
sample_rate: float = 0
|
||||
trigger_time: float = None
|
||||
|
||||
|
||||
def _check(cancel):
|
||||
@@ -54,12 +55,14 @@ def read_capture(path, progress=lambda value: None, cancel=lambda: False):
|
||||
try:
|
||||
if path.suffix.lower() == '.csv':
|
||||
result = _csv(path, progress, cancel)
|
||||
elif path.suffix.lower() == '.json':
|
||||
result = _json(path, progress, cancel)
|
||||
elif path.suffix.lower() in ('.sal', '.dsl'):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
result = (_sal if path.suffix.lower() == '.sal' else _dsl)(
|
||||
path, archive, progress, cancel)
|
||||
else:
|
||||
raise ValueError('Выберите цифровую запись CSV, SAL или DSL.')
|
||||
raise ValueError('Выберите цифровую запись CSV, JSON, SAL или DSL.')
|
||||
except (KeyError, struct.error, zipfile.BadZipFile, EOFError,
|
||||
configparser.Error, UnicodeError) as exc:
|
||||
raise ValueError('Повреждённый или неподдерживаемый файл: %s' % exc) from exc
|
||||
@@ -70,6 +73,58 @@ def read_capture(path, progress=lambda value: None, cancel=lambda: False):
|
||||
return result
|
||||
|
||||
|
||||
def _json(path, progress, cancel):
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError('Повторяющееся поле JSON: %s.' % key)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
_check(cancel)
|
||||
with path.open(encoding='utf-8-sig') as stream:
|
||||
rows = json.load(stream, object_pairs_hook=unique_object)
|
||||
_check(cancel)
|
||||
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
|
||||
raise ValueError('Ожидается JSON: непустой массив объектов с Time [s] и каналами 0/1.')
|
||||
names = list(rows[0])
|
||||
if ('Time [s]' not in names or not 2 <= len(names) <= 65
|
||||
or any(not name.strip() for name in names)):
|
||||
raise ValueError('JSON должен содержать Time [s] и от 1 до 64 цифровых каналов.')
|
||||
channels = [DigitalChannel(name, 0, array('d')) for name in names if name != 'Time [s]']
|
||||
fields = set(names)
|
||||
start = end = None
|
||||
previous = []
|
||||
for index, row in enumerate(rows):
|
||||
if index % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(index * 99 // len(rows))
|
||||
try:
|
||||
if not isinstance(row, dict) or set(row) != fields:
|
||||
raise ValueError('набор полей должен быть одинаковым во всех строках')
|
||||
time = row['Time [s]']
|
||||
if (type(time) not in (int, float) or not math.isfinite(time)
|
||||
or (end is not None and time <= end)):
|
||||
raise ValueError('время должно быть числом, строго возрастать и быть конечным')
|
||||
values = [row[c.name] for c in channels]
|
||||
if any(type(v) not in (int, float, bool) or v not in (0, 1) for v in values):
|
||||
raise ValueError('цифровые уровни должны быть 0 или 1')
|
||||
if start is None:
|
||||
start = time
|
||||
for channel, value in zip(channels, values):
|
||||
channel.initial = int(value)
|
||||
else:
|
||||
for channel, old, value in zip(channels, previous, values):
|
||||
if old != value:
|
||||
channel.edges.append(time)
|
||||
previous = values
|
||||
end = time
|
||||
except (ValueError, OverflowError) as exc:
|
||||
raise ValueError('JSON, строка %d: %s' % (index + 1, exc)) from exc
|
||||
return DigitalCapture(str(path), 'JSON', channels, start, end)
|
||||
|
||||
|
||||
def _csv(path, progress, cancel):
|
||||
size = max(1, path.stat().st_size)
|
||||
consumed = 0
|
||||
|
||||
187
python/logic_analyzer/sal_metadata.py
Normal file
187
python/logic_analyzer/sal_metadata.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Empty Logic 2 metadata based on the DSLogic_Logic_2 SAL fixture (schema 22)."""
|
||||
from copy import deepcopy
|
||||
|
||||
_TEMPLATE = {'version': 22,
|
||||
'data': {'renderViewState': {'type': 'PanAndZoom', 'leftEdgeTimeSec': 0, 'timeScaleSeconds': 0.001},
|
||||
'captureStartTime': {'unixTimeMilliseconds': 0, 'fractionalMilliseconds': 0},
|
||||
'timingMarkers': {'markers': {}, 'pairs': {}},
|
||||
'measurements': [],
|
||||
'highLevelAnalyzers': [],
|
||||
'analyzers': [],
|
||||
'rowsSettings': [],
|
||||
'captureSettings': {'bufferSizeMb': 3072,
|
||||
'timerModeSettings': {'stopAfterSeconds': 5e-05},
|
||||
'commonCaptureSettings': {'trimAfterCapture': False, 'trimTimeSeconds': 0},
|
||||
'triggerSettings': {'eventChannel': {'category': 'legacy',
|
||||
'type': 'Digital',
|
||||
'deviceChannel': 0},
|
||||
'triggerSourceGeneration': 0,
|
||||
'scopeEventType': 'Rising',
|
||||
'scopeThreshold': 1,
|
||||
'scopeHysteresisPercentage': 0.02,
|
||||
'digitalEventType': 'Rising',
|
||||
'digitalLinkedChannels': [],
|
||||
'digitalLegacyPostTriggerBufferSeconds': 1,
|
||||
'mode': 'Auto',
|
||||
'holdOffSeconds': 0.001,
|
||||
'pulseDuration': {'min': 0.001, 'max': 0.01},
|
||||
'realTriggerTimeoutViewRatio': 4,
|
||||
'minRealTriggerTimeoutSeconds': 1,
|
||||
'autoTriggerTimeoutViewRatio': 2},
|
||||
'captureMode': 'Timer',
|
||||
'captureTriggerType': 'Signal'},
|
||||
'legacyDevice': {'deviceId': '1000001',
|
||||
'name': 'Logic Pro 16',
|
||||
'deviceType': 'LogicPro16',
|
||||
'isSimulation': True,
|
||||
'capabilities': {'channelCapabilities': [{'type': 'Digital',
|
||||
'index': 0,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 0,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 1,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 1,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 2,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 2,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 3,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 3,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 4,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 4,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 5,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 5,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 6,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 6,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 7,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 7,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 8,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 8,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 9,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 9,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 10,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 10,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 11,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 11,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 12,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 12,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 13,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 13,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 14,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 14,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Digital',
|
||||
'index': 15,
|
||||
'capability': 'Toggleable'},
|
||||
{'type': 'Analog',
|
||||
'index': 15,
|
||||
'capability': 'Toggleable'}],
|
||||
'sampleRateOptions': [{'digital': 500000000},
|
||||
{'digital': 250000000},
|
||||
{'digital': 125000000},
|
||||
{'digital': 100000000},
|
||||
{'digital': 50000000},
|
||||
{'digital': 25000000},
|
||||
{'digital': 20000000},
|
||||
{'digital': 12500000},
|
||||
{'digital': 10000000},
|
||||
{'digital': 6250000},
|
||||
{'digital': 5000000},
|
||||
{'digital': 4000000},
|
||||
{'digital': 2500000},
|
||||
{'digital': 2000000},
|
||||
{'digital': 1000000}],
|
||||
'digitalThresholdOptions': [{'description': '1.2 Volts'},
|
||||
{'description': '1.8 Volts'},
|
||||
{'description': '3.3+ Volts'}],
|
||||
'isPhysicalDevice': False}},
|
||||
'legacySettings': {'enabledChannels': [{'type': 'Digital', 'index': 0},
|
||||
{'type': 'Digital', 'index': 1},
|
||||
{'type': 'Digital', 'index': 2},
|
||||
{'type': 'Digital', 'index': 3}],
|
||||
'sampleRate': {'digital': 100000000},
|
||||
'digitalThreshold': {'description': '1.2 Volts'},
|
||||
'glitchFilter': {'enabled': False, 'channels': []}},
|
||||
'digitalTriggerTime': -1,
|
||||
'name': 'Converted capture',
|
||||
'dataTable': {'columns': {}},
|
||||
'analyzerTrigger': {'settings': {'searchQuery': '', 'holdoffSeconds': 0.2}},
|
||||
'timeManager': {'t0': {'type': 'startOfCapture'}},
|
||||
'captureNotes': ''},
|
||||
'binData': [{'dataId': {'value': 'LogicPro16/Digital/0'},
|
||||
'category': 'legacy',
|
||||
'type': 'Digital',
|
||||
'deviceChannel': 0,
|
||||
'file': './digital-0.bin'},
|
||||
{'dataId': {'value': 'LogicPro16/Digital/1'},
|
||||
'category': 'legacy',
|
||||
'type': 'Digital',
|
||||
'deviceChannel': 1,
|
||||
'file': './digital-1.bin'},
|
||||
{'dataId': {'value': 'LogicPro16/Digital/2'},
|
||||
'category': 'legacy',
|
||||
'type': 'Digital',
|
||||
'deviceChannel': 2,
|
||||
'file': './digital-2.bin'},
|
||||
{'dataId': {'value': 'LogicPro16/Digital/3'},
|
||||
'category': 'legacy',
|
||||
'type': 'Digital',
|
||||
'deviceChannel': 3,
|
||||
'file': './digital-3.bin'}]}
|
||||
|
||||
def metadata():
|
||||
return deepcopy(_TEMPLATE)
|
||||
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())
|
||||
173
tools/dsview/README.md
Normal file
173
tools/dsview/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# DSView as an embedded Windows instrument panel
|
||||
|
||||
Upstream: https://github.com/DreamSourceLab/DSView, revision `2e9e2c8e`.
|
||||
The GPL-3.0-or-later application and its private Qt 5/Python runtime stay in
|
||||
a separate process. `setgui-host.patch` adds the Windows host protocol and
|
||||
fixes the missing Windows CMake inputs. No DSView DLL is loaded into Qt 6.
|
||||
|
||||
`python/logic_analyzer/dsview_host.py` contains the reusable environment,
|
||||
process/window ownership and close-message helpers. The consuming GUI owns
|
||||
the stylesheet, window surface and lifecycle.
|
||||
|
||||
## Build
|
||||
|
||||
MSYS2 UCRT64 packages (install with `pacman -S --needed`):
|
||||
|
||||
```text
|
||||
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake
|
||||
mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-qt5-base mingw-w64-ucrt-x86_64-qt5-svg
|
||||
mingw-w64-ucrt-x86_64-qt5-winextras mingw-w64-ucrt-x86_64-glib2
|
||||
mingw-w64-ucrt-x86_64-libusb mingw-w64-ucrt-x86_64-fftw
|
||||
mingw-w64-ucrt-x86_64-boost mingw-w64-ucrt-x86_64-python
|
||||
```
|
||||
|
||||
```powershell
|
||||
python tools/dsview/build.py --source C:/path/to/DSView --output C:/path/to/runtime --msys-root C:/msys64
|
||||
```
|
||||
|
||||
The builder checks the revision, applies the patch only after `git apply
|
||||
--check`, and accepts an already applied patch. It never resets the source
|
||||
checkout. Close the built DSView before rebuilding.
|
||||
|
||||
Successful builds write `host-build.json`, recording the recipe digest (patch,
|
||||
packager, adapters and shared cores) and the native EXE digest. Consumers can
|
||||
call `runtime_is_current(output)` before packaging to detect an old component
|
||||
even if the host-protocol version is unchanged. SETGUI's normal EXE builder
|
||||
automatically rebuilds an installed stale runtime and refuses to package it if
|
||||
the update fails. A runtime built before this stamp was added is rebuilt once.
|
||||
|
||||
## Host protocol 1
|
||||
|
||||
- Create a native child surface owned by the host process.
|
||||
- Start `DSView.exe --embed-parent <decimal HWND>` with its private runtime
|
||||
as working directory and a sanitized environment.
|
||||
- `DSVIEW_HOST_STYLE` points to the UTF-8 Qt stylesheet; `DSVIEW_CAPTURE_DIR`
|
||||
supplies the initial open/save/export directory.
|
||||
- Wait for `DSVIEW_READY <HWND>` on stdout, then validate both the window PID
|
||||
and its parent before accepting it.
|
||||
- The child fits the surface, inherits visibility, and exits if the parent
|
||||
window disappears. It uses separate settings under `SET/SETGUI-DSView`.
|
||||
- Write `CLOSE\n` to the child's stdin to close normally. Keep the host
|
||||
alive while DSView's save dialog is open. Forced termination is only a
|
||||
fallback for host shutdown or a failed start.
|
||||
- Write `ADD_DECODER <id>\n` to open the normal decoder configuration dialog.
|
||||
The host must be ready, in digital mode and not capturing. Stacked decoders
|
||||
automatically include the matching UART/CAN base decoder.
|
||||
- New hosts announce `DSVIEW_CAPABILITIES OPEN_FILE` after the ready message.
|
||||
`OPEN_FILE <base64 UTF-8 path>\n` opens a DSL recording without restarting
|
||||
the child. It returns `DSVIEW_FILE_RESULT ok|busy|save|error`. A capture,
|
||||
save operation or modal dialog prevents replacement. Unsaved hardware data
|
||||
retains the normal save prompt.
|
||||
- In embedded mode File в†’ Open accepts CSV and emits
|
||||
`DSVIEW_IMPORT_CSV <base64 UTF-8 path>` for conversion by SETGUI. The shared
|
||||
`python/logic_analyzer/csv_import.py` streams CSV into temporary DSL v3
|
||||
blocks in two passes, with progress/cancellation and bounded memory.
|
||||
Times are read as decimal picoseconds; front-to-front intervals are exact.
|
||||
The initial state may extend by less than one sample to align the grid;
|
||||
the final state pads to 64 samples. Original offset metadata is retained.
|
||||
- Embedded mode disables Qt's quit-on-last-window policy: the child is a
|
||||
`Qt::Tool`, so accepting/rejecting a modal dialog must not quit the process.
|
||||
- `--host-version` returns `DSView host protocol 1`. An optional
|
||||
`DSVIEW_HOST_SCREENSHOT` environment variable saves one render artifact
|
||||
after startup for native integration tests.
|
||||
|
||||
Tested SDK: GCC 16.2, Qt 5.15.19, Python 3.14.7, Windows x64. This runtime
|
||||
requires Windows 10/11; it does not add Windows 7 support.
|
||||
|
||||
## SET decoders
|
||||
|
||||
`decoders/` contains the sigrok front ends for `gate_driver_timing` (1SP0635 /
|
||||
1SD536F2), `set_uart` (SET v1/v2, ProtoCAN bridge), `set_can` (ProtoCAN, SET v2,
|
||||
Balsam), and `pm35_uart` (TMS320F28335 MODBUS 03/06).
|
||||
Their parsing cores are maintained only in `python/logic_analyzer/decoders`.
|
||||
The builder copies those files verbatim to `decoders/common/setgui_decoders`
|
||||
inside the runtime. No GUI imports or external Python installation are needed.
|
||||
The adapters originate from `DSLogic_Logic_2/dslogic_script/dsview_decoders`.
|
||||
|
||||
IGBT offers `ORPHAN minimum pulse width (ns, 0 = off)` for all profiles.
|
||||
Only uncorrelated pulses strictly shorter than this duration are omitted from
|
||||
annotations; zero preserves the original behavior. ACK measurements/errors,
|
||||
FAULT and lone edges with unknown pulse duration remain visible. This does
|
||||
not modify the captured signal or debounce Vin1/Vstat.
|
||||
|
||||
The independent **Transistor pair** decoder measures mintime, OFF time,
|
||||
deadtime in both directions, overlap, frequency and duty. It requires only
|
||||
Vin1/Vin2. **IGBT 1SP/1SD** retains Vin1/Vstat ACK and full-cycle checks.
|
||||
See [pair settings](decoders/transistor_pair/README.md) and
|
||||
[driver settings](decoders/gate_driver_timing/README.md).
|
||||
|
||||
Run `python -m unittest discover -s tools/dsview/tests` for packet, CRC,
|
||||
segmentation, timing-profile and sigrok API boundary tests. SETGUI's
|
||||
`--check-dsview` sets `DSVIEW_HOST_CHECK` to a report file and exercises native
|
||||
decoder dialogs, single/all removal with Yes/No, stacking and child lifetime.
|
||||
The check saves decoder dialog screenshots alongside the report.
|
||||
|
||||
## Annotation search
|
||||
|
||||
The patch searches every enabled result column of the selected decoder and
|
||||
every annotation label, including short waveform labels such as `ORPHAN`.
|
||||
Matching is case insensitive; warning columns are enabled by default. Explicitly
|
||||
hidden columns remain excluded until enabled in Decoding Results settings.
|
||||
Enter (including the Windows popup editor), the magnifier and the next arrow
|
||||
navigate forward; the previous arrow navigates backward. Matches are counted
|
||||
per annotation, ordered by sample time, and wrap around. Tooltips show all
|
||||
label variants. A nonempty query displays a compact match-only table: one
|
||||
annotation per row, with its row title and matching label. Clearing the query
|
||||
restores the source table; a query with no matches displays an empty list.
|
||||
Click/arrow navigation maps each result back to the original annotation.
|
||||
Numeric `AA-BB-CC` sequences are still supported; other hyphens
|
||||
are literal. Search runs in short GUI-thread timer steps, invalidates indices
|
||||
on model resets, and refreshes when decoding reaches 100 percent.
|
||||
|
||||
After rebuilding, verify with an IGBT recording containing an uncorrelated
|
||||
Vstat pulse: search `ORPHAN`, `orphan`, and `Unexpected Vstat`; each should
|
||||
select the same warning and show its waveform region. Also check Enter from
|
||||
the popup editor, magnifier/arrow wraparound, a missing query, clearing/changing
|
||||
the query during a long search, changing columns/decoders, and removing the
|
||||
decoder while searching. Confirm numeric sequences and literal `CAN-FD` labels
|
||||
still work. Source syntax checks do not replace this native GUI check.
|
||||
|
||||
## Install analyzers into standalone DSView (Windows)
|
||||
|
||||
The adapters in `tools/dsview/decoders` and portable cores in
|
||||
`python/logic_analyzer/decoders` are shared with the embedded build.
|
||||
No analyzer implementation is maintained in the consuming GUI.
|
||||
|
||||
```powershell
|
||||
.\tools\dsview\install_decoders.ps1 -DsViewPath 'C:\Program Files\DSView'
|
||||
```
|
||||
|
||||
Close DSView first. An elevated PowerShell is needed if the installation
|
||||
folder is protected. Add `-CheckOnly` to validate source files and the target
|
||||
layout without writing. Python/MSYS2 and a DSView rebuild are not required.
|
||||
The target must contain DSView.exe and decoders/common.
|
||||
|
||||
Installs gate_driver_timing, set_uart, set_can and pm35_uart, with unchanged
|
||||
cores under decoders/common/setgui_decoders. Existing unrelated files are
|
||||
preserved. Replaced files are backed up under set-decoder-backups next to
|
||||
DSView.exe; failed copies trigger rollback. Keep backups for manual recovery.
|
||||
To restore, close DSView and copy the backup contents over decoders.
|
||||
|
||||
Restart DSView, add the UART or CAN base decoder and stack SET/PM35 on top.
|
||||
Gate timing consumes logic channels directly. This installs decoders only;
|
||||
it does not install the SETGUI host patch, menus or CSV import.
|
||||
|
||||
Installer tests run under Windows PowerShell against temporary targets and
|
||||
check isolated imports, updates, backups, copy-failure rollback and validation:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tools/dsview/tests -p test_install_decoders.py -v
|
||||
```
|
||||
|
||||
## Decoder panel and selector
|
||||
|
||||
Embedded DSView does not restore standalone toolbar/dock layout. The Decoders
|
||||
panel can extend to the top row of the DSView controls; its upper splitter
|
||||
still allows reducing its height. SETGUI's own folder and tab rows remain
|
||||
outside the native component.
|
||||
|
||||
The decoder selector starts with **Пользовательские**: IGBT 1SP/1SD,
|
||||
Transistor pair, SET UART, SET CAN and PM35 UART. **Стандартные** contains the remaining
|
||||
decoders. Search matches names and IDs in both sections and hides empty
|
||||
section headings. The changes are packaged in `decoder-panel.patch`.
|
||||
137
tools/dsview/build.py
Normal file
137
tools/dsview/build.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Build and package DSView host protocol 1 using an existing MSYS2 UCRT64 SDK."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
REVISION = '2e9e2c8e'
|
||||
|
||||
|
||||
def recipe_digest():
|
||||
"""Identify the patch, packaging recipe, adapters and their shared cores."""
|
||||
recipe = Path(__file__).resolve().parent
|
||||
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
|
||||
inputs = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h']
|
||||
inputs += sorted((recipe / 'decoders').rglob('*.py'))
|
||||
inputs += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
|
||||
digest = hashlib.sha256()
|
||||
for item in inputs:
|
||||
digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0')
|
||||
digest.update(hashlib.sha256(item.read_bytes()).digest())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def runtime_is_current(output):
|
||||
output = Path(output)
|
||||
try:
|
||||
stamp = json.loads((output / 'host-build.json').read_text(encoding='utf-8'))
|
||||
return (stamp.get('recipe') == recipe_digest()
|
||||
and stamp.get('executable') == hashlib.sha256((output / 'DSView.exe').read_bytes()).hexdigest()
|
||||
and (output / 'host-protocol-1').read_text(encoding='utf-8').strip() == 'DSView host protocol 1')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def package_decoders(output):
|
||||
"""Stage sigrok adapters and the canonical parsing cores without forks."""
|
||||
recipe = Path(__file__).resolve().parent
|
||||
decoders = Path(output) / 'decoders'
|
||||
shutil.copytree(recipe / 'decoders', decoders, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns('__pycache__'))
|
||||
common = decoders / 'common/setgui_decoders'
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
(common / '__init__.py').write_text('', encoding='utf-8')
|
||||
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
shutil.copy2(core / (name + '.py'), common / (name + '.py'))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--msys-root', type=Path, default=Path(r'C:\setcorp\tools\msys64'))
|
||||
args = parser.parse_args()
|
||||
source, output = args.source.resolve(), args.output.resolve()
|
||||
prefix = args.msys_root.resolve() / 'ucrt64'
|
||||
if not (prefix / 'bin/cmake.exe').is_file():
|
||||
raise SystemExit('Install the MSYS2 UCRT64 dependencies listed in README.md first.')
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=source, text=True).strip()
|
||||
if not revision.startswith(REVISION):
|
||||
raise SystemExit('Expected DSView revision ' + REVISION + ', got ' + revision)
|
||||
for retired_name in ('driver-summary.patch', 'gate-pair-checkbox.patch'):
|
||||
retired = Path(__file__).with_name(retired_name)
|
||||
if subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(retired)],
|
||||
cwd=source, capture_output=True).returncode == 0:
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', str(retired)], cwd=source, check=True)
|
||||
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
|
||||
patch = Path(__file__).with_name(patch_name)
|
||||
applied = subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(patch)], cwd=source, capture_output=True)
|
||||
if applied.returncode:
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', '--check', str(patch)], cwd=source, check=True)
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', str(patch)], cwd=source, check=True)
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = os.pathsep.join([str(prefix/'bin'), str(args.msys_root/'usr/bin'), env.get('PATH', '')])
|
||||
env['PKG_CONFIG_PATH'] = str(prefix/'lib/pkgconfig')
|
||||
build = source/'build-win64'
|
||||
subprocess.run([str(prefix/'bin/cmake.exe'), '-S', str(source), '-B', str(build), '-G', 'Ninja',
|
||||
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5', '-DCMAKE_CXX_STANDARD=11',
|
||||
'-DCMAKE_POLICY_DEFAULT_CMP0167=OLD', '-DCMAKE_PREFIX_PATH='+prefix.as_posix()],
|
||||
env=env, check=True)
|
||||
subprocess.run([str(prefix/'bin/cmake.exe'), '--build', str(build), '--parallel', '6'], env=env, check=True)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source/'build.dir/DSView.exe', output/'DSView.exe')
|
||||
for relative in ('DSView/res', 'DSView/demo', 'libsigrokdecode4DSL/decoders', 'lang'):
|
||||
item = source/relative
|
||||
shutil.copytree(item, output/item.name, dirs_exist_ok=True, ignore=shutil.ignore_patterns('__pycache__'))
|
||||
package_decoders(output)
|
||||
for name in ('COPYING', 'NEWS25', 'NEWS31', 'ug25.pdf', 'ug31.pdf'):
|
||||
shutil.copy2(source/name, output/name)
|
||||
stdlib = sorted((prefix/'lib').glob('python3.*'))
|
||||
stdlib = [item for item in stdlib if (item/'encodings').is_dir()]
|
||||
if len(stdlib) != 1:
|
||||
raise RuntimeError('Expected exactly one Python standard library in UCRT64')
|
||||
shutil.copytree(stdlib[0], output/'lib'/stdlib[0].name, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns('__pycache__', 'site-packages', 'test', 'tests', 'idlelib', 'tkinter', 'ensurepip'))
|
||||
for name in ('platforms', 'imageformats', 'iconengines', 'styles'):
|
||||
shutil.copytree(prefix/'share/qt5/plugins'/name, output/name, dirs_exist_ok=True)
|
||||
shutil.copytree(prefix/'share/licenses', output/'licenses', dirs_exist_ok=True)
|
||||
(output/'qt.conf').write_text('[Paths]\nPrefix=.\nPlugins=.\n', encoding='utf-8')
|
||||
queue = [p for p in output.rglob('*') if p.suffix.lower() in ('.exe', '.dll', '.pyd')]
|
||||
seen = set()
|
||||
while queue:
|
||||
binary = queue.pop()
|
||||
if binary in seen:
|
||||
continue
|
||||
seen.add(binary)
|
||||
info = subprocess.check_output([str(prefix/'bin/objdump.exe'), '-p', str(binary)], env=env, text=True, errors='replace')
|
||||
for dll in re.findall(r'DLL Name:\s*(\S+)', info):
|
||||
dependency, target = prefix/'bin'/dll, output/dll
|
||||
if dependency.is_file():
|
||||
if target not in seen:
|
||||
shutil.copy2(dependency, target)
|
||||
queue.append(target)
|
||||
elif not (Path(os.environ['SystemRoot'])/'System32'/dll).is_file() and not dll.lower().startswith(('api-ms-', 'ext-ms-')):
|
||||
raise RuntimeError('Missing dependency: ' + dll)
|
||||
check_env = dict(os.environ)
|
||||
check_env['PATH'] = str(Path(os.environ['SystemRoot'])/'System32')
|
||||
version = subprocess.check_output([str(output/'DSView.exe'), '--host-version'], env=check_env, text=True).strip()
|
||||
if version != 'DSView host protocol 1':
|
||||
raise RuntimeError('Host protocol self-check failed: ' + version)
|
||||
(output/'host-protocol-1').write_text(version+'\n', encoding='utf-8')
|
||||
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
|
||||
shutil.copy2(Path(__file__).with_name(patch_name), output/patch_name)
|
||||
(output/'SOURCE.txt').write_text('https://github.com/DreamSourceLab/DSView\nCommit: '+revision+'\nPatches: setgui-host.patch, decoder-panel.patch, windows-usb-events.patch\nLicense: GPL-3.0-or-later (COPYING)\n', encoding='utf-8')
|
||||
(output/'host-build.json').write_text(json.dumps({
|
||||
'recipe': recipe_digest(),
|
||||
'executable': hashlib.sha256((output/'DSView.exe').read_bytes()).hexdigest(),
|
||||
}, indent=2) + '\n', encoding='utf-8')
|
||||
print('DSView runtime:', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
119
tools/dsview/decoder-panel.patch
Normal file
119
tools/dsview/decoder-panel.patch
Normal file
@@ -0,0 +1,119 @@
|
||||
--- a/DSView/pv/dock/searchcombobox.h
|
||||
+++ b/DSView/pv/dock/searchcombobox.h
|
||||
@@ -58,6 +58,7 @@
|
||||
public:
|
||||
QString _id;
|
||||
QString _name;
|
||||
+ QString _section;
|
||||
void *_data_handle;
|
||||
QWidget *_control;
|
||||
};
|
||||
@@ -74,7 +75,7 @@
|
||||
|
||||
void ShowDlg(QWidget *editline);
|
||||
|
||||
- void AddDataItem(QString id, QString name, void *data_handle);
|
||||
+ void AddDataItem(QString id, QString name, void *data_handle, QString section = QString());
|
||||
|
||||
inline void SetItemClickHandle(ISearchItemClick *click){
|
||||
_item_click = click;
|
||||
@@ -95,6 +96,7 @@
|
||||
std::vector<SearchDataItem*> _items;
|
||||
ISearchItemClick *_item_click;
|
||||
QScrollArea *_scroll;
|
||||
+ std::vector<std::pair<QString, QWidget*> > _sections;
|
||||
};
|
||||
|
||||
#endif // SEARCHCOMBOBOX_H
|
||||
--- a/DSView/pv/dock/searchcombobox.cpp
|
||||
+++ b/DSView/pv/dock/searchcombobox.cpp
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <QPoint>
|
||||
#include <QLineEdit>
|
||||
#include <QScrollBar>
|
||||
+#include <QLabel>
|
||||
#include "../config/appconfig.h"
|
||||
#include "../appcontrol.h"
|
||||
#include "../ui/fn.h"
|
||||
@@ -107,8 +108,17 @@
|
||||
QFont font = this->font();
|
||||
font.setPointSizeF(AppConfig::Instance().appOptions.fontSize);
|
||||
|
||||
+ QString section;
|
||||
for(auto o : _items)
|
||||
- {
|
||||
+ {
|
||||
+ if (!o->_section.isEmpty() && o->_section != section) {
|
||||
+ section = o->_section;
|
||||
+ auto heading = new QLabel(section, listPanel);
|
||||
+ heading->setFont(font);
|
||||
+ heading->setStyleSheet("font-weight: bold; padding: 8px 4px; border-top: 1px solid #344762;");
|
||||
+ listLay->addWidget(heading);
|
||||
+ _sections.push_back(std::make_pair(section, heading));
|
||||
+ }
|
||||
ComboButtonItem *bt = new ComboButtonItem(panel, this, o);
|
||||
bt->setText(o->_name);
|
||||
bt->setObjectName("flat");
|
||||
@@ -143,11 +153,12 @@
|
||||
this->show();
|
||||
}
|
||||
|
||||
-void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle)
|
||||
+void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle, QString section)
|
||||
{
|
||||
SearchDataItem *item = new SearchDataItem();
|
||||
item->_id = id;
|
||||
item->_name = name;
|
||||
+ item->_section = section;
|
||||
item->_data_handle = data_handle;
|
||||
this->_items.push_back(item);
|
||||
}
|
||||
@@ -197,5 +208,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
+ for (auto section : _sections) {
|
||||
+ bool visible = false;
|
||||
+ for (auto item : _items)
|
||||
+ if (item->_section == section.first && !item->_control->isHidden()) {
|
||||
+ visible = true;
|
||||
+ break;
|
||||
+ }
|
||||
+ section.second->setVisible(visible);
|
||||
+ }
|
||||
_scroll->verticalScrollBar()->setValue(0);
|
||||
}
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -1060,10 +1060,15 @@
|
||||
{
|
||||
SearchComboBox *panel = new SearchComboBox(this);
|
||||
|
||||
- for (auto info : _decoderInfoList)
|
||||
- {
|
||||
- srd_decoder *dec = (srd_decoder *)(info->_data_handle);
|
||||
- panel->AddDataItem(QString(dec->id), QString(dec->name), info);
|
||||
+ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart"};
|
||||
+ for (bool custom : {true, false}) {
|
||||
+ for (auto info : _decoderInfoList) {
|
||||
+ srd_decoder *dec = (srd_decoder *)(info->_data_handle);
|
||||
+ if (customIds.contains(QString(dec->id)) != custom)
|
||||
+ continue;
|
||||
+ panel->AddDataItem(QString(dec->id), QString(dec->name), info,
|
||||
+ custom ? QString::fromUtf8("Пользовательские") : QString::fromUtf8("Стандартные"));
|
||||
+ }
|
||||
}
|
||||
|
||||
QFont font = this->font();
|
||||
--- a/DSView/pv/mainwindow.cpp
|
||||
+++ b/DSView/pv/mainwindow.cpp
|
||||
@@ -1329,7 +1329,8 @@
|
||||
// default dockwidget size
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
QByteArray st = app.frameOptions.windowState;
|
||||
- if (!st.isEmpty())
|
||||
+ // Standalone toolbar rows must not reserve space above embedded docks.
|
||||
+ if (!qApp->property("embeddedHost").toBool() && !st.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
60
tools/dsview/decoders/gate_driver_timing/README.md
Normal file
60
tools/dsview/decoders/gate_driver_timing/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 1SP0635 / 1SD536F2 timing decoder for DSView
|
||||
|
||||
This decoder checks driver control and status responses:
|
||||
|
||||
- `Vin1`: optional control sent to the first gate driver (legacy channel ID `vin`);
|
||||
- `Vstat`: edge acknowledgement and fault feedback returned by the driver.
|
||||
|
||||
Built-in data-sheet profiles:
|
||||
|
||||
| Profile | ACK delay (typ.) | ACK width (specified) | Long pulse |
|
||||
|---|---:|---:|---:|
|
||||
| 1SP0635 | 250 ns | 400...1050 ns | fault above 1.5 us |
|
||||
| 1SD536F2 | 380 ns | 600...1800 ns | fault above 1.8 us* |
|
||||
|
||||
`*` The 1.8 us fault threshold is a project classification choice based on the
|
||||
data-sheet ACK maximum, not a verified universal manufacturer fault threshold.
|
||||
The previously stated 1.5 us manual threshold could not be verified.
|
||||
Correlated pulses within the ACK width limits pass. Uncorrelated pulses up to
|
||||
the fault threshold are warnings; pulses above it are faults. Width and fault
|
||||
annotations are emitted only when the pulse ends.
|
||||
|
||||
The data sheets specify only a *typical* ACK delay, not minimum and maximum
|
||||
limits. DSView therefore emits a warning outside the configurable tolerance
|
||||
(default +/-100 ns). The ACK-width check uses the published min/max values.
|
||||
|
||||
## Use in SETGUI
|
||||
|
||||
The DSView build recipe packages this adapter together with the canonical
|
||||
`python/logic_analyzer/decoders/gate_timing.py` core. In the DSView tab,
|
||||
choose **Анализаторы SET → IGBT · 1SP0635 / 1SD536F2** (or search Decode for
|
||||
`IGBT 1SP/1SD`). Assign Vstat and, if available, Vin1, choose the driver profile, and set `Vstat acknowledge/fault level`
|
||||
to match the receiver circuit. The diagrams in the application manual show
|
||||
the logical event; some optical receiver circuits invert it.
|
||||
|
||||
Use at least 50 MS/s; 100 MS/s or more is recommended. Never connect a DSLogic
|
||||
digital input directly to the IGBT gate (+15/-10 V) or to a power-stage node.
|
||||
Use the isolated optical receiver logic signals and a common logic ground.
|
||||
|
||||
## Separate transistor pair analyzer
|
||||
|
||||
This decoder analyzes only Vin1/Vstat driver responses. Vin1 is optional;
|
||||
Vstat alone retains pulse/fault classification, while Vin1 enables ACK timing
|
||||
and full-cycle results. Vstat is required. The channel ID `vin` remains stable.
|
||||
|
||||
Pair analysis was moved to **Transistor pair** in the **Пользовательские**
|
||||
section. Add that decoder separately and assign Vin1/Vin2. Old pair settings
|
||||
on IGBT instances must be recreated in the new decoder. The pair checkbox,
|
||||
Vin2 assignment and pulse limits are no longer part of the driver decoder.
|
||||
|
||||
## Full pulse cycle result
|
||||
|
||||
The **Vin1 / ACK full cycle** waveform row displays OK, WARNING or FAULT
|
||||
across each complete sequence **Vin1 ON -> ACK -> Vin1 OFF -> ACK**.
|
||||
The annotation starts at Vin1 ON and ends at the end of the OFF acknowledgment.
|
||||
It is emitted only after both ACKs have been classified. A delay warning makes
|
||||
that cycle WARNING; missing ACK, invalid width or fault feedback makes it FAULT.
|
||||
A later normal cycle can be OK independently of previous cycles.
|
||||
Unfinished cycles are never marked OK. Active-low Vin1 is supported.
|
||||
The results panel retains its original layout without a large summary banner.
|
||||
Deadtime and mintime are provided by the separate Transistor pair analyzer.
|
||||
3
tools/dsview/decoders/gate_driver_timing/__init__.py
Normal file
3
tools/dsview/decoders/gate_driver_timing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""DSView protocol decoder for SCALE gate-driver timing checks."""
|
||||
|
||||
from .pd import Decoder
|
||||
168
tools/dsview/decoders/gate_driver_timing/pd.py
Normal file
168
tools/dsview/decoders/gate_driver_timing/pd.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""DSView/libsigrokdecode front end for SCALE gate-driver timing checks."""
|
||||
|
||||
import sigrokdecode as srd
|
||||
|
||||
from common.setgui_decoders.gate_timing import TimingChecker
|
||||
|
||||
|
||||
class SamplerateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Ann(object):
|
||||
CONTROL = 0
|
||||
DELAY_OK = 1
|
||||
WIDTH_OK = 2
|
||||
WARNING = 3
|
||||
ERROR = 4
|
||||
FAULT = 5
|
||||
CYCLE_OK = 6
|
||||
CYCLE_WARNING = 7
|
||||
CYCLE_FAULT = 8
|
||||
|
||||
|
||||
ANN_FOR_KIND = {
|
||||
'control': Ann.CONTROL,
|
||||
'delay_ok': Ann.DELAY_OK,
|
||||
'width_ok': Ann.WIDTH_OK,
|
||||
'delay_warn': Ann.WARNING,
|
||||
'width_fail': Ann.ERROR,
|
||||
'missing': Ann.ERROR,
|
||||
'orphan': Ann.WARNING,
|
||||
'fault': Ann.FAULT,
|
||||
'cycle_ok': Ann.CYCLE_OK,
|
||||
'cycle_warning': Ann.CYCLE_WARNING,
|
||||
'cycle_fault': Ann.CYCLE_FAULT,
|
||||
}
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'gate_driver_timing'
|
||||
name = 'IGBT 1SP/1SD'
|
||||
longname = 'Power Integrations 1SP0635 / 1SD536F2 timing checker'
|
||||
desc = 'Checks driver Vin1/Vstat ACK timing, faults and full pulse cycles.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['logic']
|
||||
outputs = []
|
||||
tags = ['Clock/timing', 'Util']
|
||||
|
||||
channels = ()
|
||||
optional_channels = (
|
||||
{'id': 'vin', 'name': 'Vin1', 'desc': 'First driver control input'},
|
||||
{'id': 'vstat', 'name': 'Vstat', 'desc': 'Driver status/acknowledge output'},
|
||||
)
|
||||
|
||||
options = (
|
||||
{'id': 'profile', 'desc': 'Driver profile', 'default': '1SP0635',
|
||||
'values': ('1SP0635', '1SD536F2', 'custom')},
|
||||
{'id': 'vin_active', 'desc': 'Vin1 active level', 'default': 'high',
|
||||
'values': ('high', 'low')},
|
||||
{'id': 'vstat_active', 'desc': 'Vstat acknowledge/fault level', 'default': 'high',
|
||||
'values': ('high', 'low')},
|
||||
{'id': 'delay_tolerance_ns',
|
||||
'desc': 'Warning tolerance around typical ACK delay (ns)', 'default': 100},
|
||||
{'id': 'orphan_min_width_ns',
|
||||
'desc': 'ORPHAN minimum pulse width (ns, 0 = off)', 'default': 0},
|
||||
{'id': 'custom_delay_ns', 'desc': 'Custom typical ACK delay (ns)', 'default': 250},
|
||||
{'id': 'custom_width_min_ns', 'desc': 'Custom ACK width minimum (ns)', 'default': 400},
|
||||
{'id': 'custom_width_typ_ns', 'desc': 'Custom ACK width typical (ns)', 'default': 700},
|
||||
{'id': 'custom_width_max_ns', 'desc': 'Custom ACK width maximum (ns)', 'default': 1050},
|
||||
{'id': 'custom_fault_ns', 'desc': 'Custom fault pulse threshold (ns)', 'default': 1500},
|
||||
)
|
||||
|
||||
annotations = (
|
||||
('control', 'Vin1 edge'),
|
||||
('delay-ok', 'ACK delay pass'),
|
||||
('width-ok', 'ACK width pass'),
|
||||
('warning', 'Warning'),
|
||||
('error', 'Timing failure'),
|
||||
('fault', 'Driver fault'),
|
||||
('cycle-ok', 'Cycle OK'),
|
||||
('cycle-warning', 'Cycle WARNING'),
|
||||
('cycle-fault', 'Cycle FAULT'),
|
||||
)
|
||||
|
||||
annotation_rows = (
|
||||
('cycle', 'Vin1 / ACK full cycle', (Ann.CYCLE_OK, Ann.CYCLE_WARNING, Ann.CYCLE_FAULT)),
|
||||
('vin', 'Vin1', (Ann.CONTROL,)),
|
||||
('measurements', 'Measurements', (Ann.DELAY_OK, Ann.WIDTH_OK)),
|
||||
('checks', 'Warnings / failures', (Ann.WARNING, Ann.ERROR, Ann.FAULT)),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.samplerate = None
|
||||
self.checker = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Здесь регистрируются аннотации для дорожек измерений и предупреждений.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
|
||||
# Адаптер переводит настройки DSView в параметры общего TimingChecker.
|
||||
# Пороговые алгоритмы остаются в templates/python/logic_analyzer/decoders.
|
||||
def _make_checker(self):
|
||||
custom = {
|
||||
'ack_delay_typ_ns': float(self.options['custom_delay_ns']),
|
||||
'ack_width_min_ns': float(self.options['custom_width_min_ns']),
|
||||
'ack_width_typ_ns': float(self.options['custom_width_typ_ns']),
|
||||
'ack_width_max_ns': float(self.options['custom_width_max_ns']),
|
||||
'fault_threshold_ns': float(self.options['custom_fault_ns']),
|
||||
}
|
||||
return TimingChecker(
|
||||
self.samplerate,
|
||||
profile=self.options['profile'],
|
||||
delay_tolerance_ns=float(self.options['delay_tolerance_ns']),
|
||||
vin_active_high=self.options['vin_active'] == 'high',
|
||||
vstat_active_high=self.options['vstat_active'] == 'high',
|
||||
orphan_min_width_ns=self.options.get('orphan_min_width_ns', 0),
|
||||
custom=custom, cycle_results=True)
|
||||
|
||||
def _put_events(self, events):
|
||||
for event in events:
|
||||
ann = ANN_FOR_KIND[event['kind']]
|
||||
self.put(event['start'], max(event['start'], event['end']),
|
||||
self.out_ann, [ann, [event['text'], event['short']]])
|
||||
|
||||
def decode(self):
|
||||
if not self.samplerate:
|
||||
raise SamplerateError('Cannot decode without samplerate.')
|
||||
self.checker = self._make_checker()
|
||||
has_vin1 = self.has_channel(0)
|
||||
if not self.has_channel(1):
|
||||
raise ValueError('Connect Vstat for driver response analysis.')
|
||||
while True:
|
||||
conditions = [{0: 'e'}, {1: 'e'}] if has_vin1 else [{1: 'e'}]
|
||||
timer_bit = 1 << len(conditions)
|
||||
deadline = self.checker.next_deadline()
|
||||
if deadline is not None:
|
||||
conditions.append({'skip': max(1, deadline - self.samplenum)})
|
||||
|
||||
# DSView mutates and returns the same native tuple on every wait.
|
||||
# Release that tuple before waiting again (PyTuple_SetItem requires
|
||||
# exclusive ownership); tuple(...) would not make a copy.
|
||||
pins = list(self.wait(conditions))
|
||||
vin, vstat = pins[:2]
|
||||
now = self.samplenum
|
||||
# Both channels may transition at the same sample; preserve both.
|
||||
if has_vin1 and self.matched & 0b001:
|
||||
self._put_events(self.checker.on_control_edge(now, vin))
|
||||
if self.matched & (0b010 if has_vin1 else 0b001):
|
||||
self._put_events(self.checker.on_status_edge(now, vstat))
|
||||
if deadline is not None and self.matched & timer_bit:
|
||||
self._put_events(self.checker.expire(now))
|
||||
|
||||
def end(self):
|
||||
if self.checker is not None:
|
||||
self._put_events(self.checker.finish_cycles(self.samplenum))
|
||||
1
tools/dsview/decoders/pm35_uart/__init__.py
Normal file
1
tools/dsview/decoders/pm35_uart/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
82
tools/dsview/decoders/pm35_uart/pd.py
Normal file
82
tools/dsview/decoders/pm35_uart/pd.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.pm35_uart import PM35Parser
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'pm35_uart'
|
||||
name = 'PM35 UART'
|
||||
longname = 'PM35 / TMS320F28335 terminal'
|
||||
desc = 'MODBUS 03/06 register traffic and CRC16.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['uart']
|
||||
outputs = ['pm35']
|
||||
tags = ['Embedded/industrial']
|
||||
options = (
|
||||
{'id': 'rx_role', 'desc': 'RX carries', 'default': 'response', 'values': ('request', 'response', 'auto')},
|
||||
{'id': 'tx_role', 'desc': 'TX carries', 'default': 'request', 'values': ('request', 'response', 'auto')},
|
||||
{'id': 'baudrate', 'desc': 'UART baudrate (RTU gap detection)', 'default': 115200},
|
||||
{'id': 'bits_per_char', 'desc': 'Bits per UART character including start/stop/parity', 'default': 10},
|
||||
)
|
||||
annotations = (('frame', 'Frame'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('errors', 'Errors', (1,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.parsers = {}
|
||||
self.last = {}
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
if self.options['baudrate'] <= 0 or self.options['bits_per_char'] <= 0:
|
||||
raise ValueError('Baudrate and bits per character must be positive')
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def emit(self, events, direction):
|
||||
for ss, es, result, error in events:
|
||||
self.put(ss, es, self.out_ann, [1 if error else 0,
|
||||
[('RX', 'TX')[direction] + ' ' + (error or result['summary'])]])
|
||||
if result:
|
||||
result['direction'] = direction
|
||||
self.put(ss, es, self.out_python, ['FRAME', result])
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
kind, direction, payload = data
|
||||
if direction not in (0, 1):
|
||||
return
|
||||
parser = self.parsers.setdefault(direction, PM35Parser(self.options[('rx_role', 'tx_role')[direction]]))
|
||||
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, [kind]])
|
||||
return
|
||||
if kind != 'FRAME':
|
||||
return
|
||||
if not payload[1]:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, ['Invalid UART frame']])
|
||||
return
|
||||
# Граница MODBUS определяется паузой 3,5 символа. bits_per_char включает
|
||||
# все биты символа; эта настройка должна соответствовать базовому UART.
|
||||
if self.samplerate and direction in self.last:
|
||||
gap = self.samplerate * 3.5 * self.options['bits_per_char'] / self.options['baudrate']
|
||||
if ss - self.last[direction] >= gap:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.last[direction] = es
|
||||
if not 0 <= payload[0] <= 255:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, ['Configure UART for 8 data bits']])
|
||||
return
|
||||
self.emit(parser.feed(payload[0], ss, es), direction)
|
||||
1
tools/dsview/decoders/set_can/__init__.py
Normal file
1
tools/dsview/decoders/set_can/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
66
tools/dsview/decoders/set_can/pd.py
Normal file
66
tools/dsview/decoders/set_can/pd.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.set_can import Reassembler, legacy
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'set_can'
|
||||
name = 'SET CAN'
|
||||
longname = 'SET v2 CAN / ProtoCAN / Balsam'
|
||||
desc = 'Application fields and segmented SET packets over classic CAN.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['can']
|
||||
outputs = ['set']
|
||||
tags = ['Embedded/industrial']
|
||||
options = ({'id': 'protocol', 'desc': 'CAN protocol (select explicitly)',
|
||||
'default': 'protocan', 'values': ('protocan', 'set-v2', 'balsam')},)
|
||||
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.reassembler = Reassembler()
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
frame_type, ident, rtr, dlc, payload = data
|
||||
if frame_type != 'extended' or rtr == 'remote':
|
||||
return
|
||||
mode = self.options['protocol']
|
||||
if not 0 <= dlc <= 8 or len(payload) != dlc:
|
||||
events = [(ss, es, None, 'Invalid classic CAN DLC')]
|
||||
# Сегментированный SET v2 требует времени для тайм-аута сборки.
|
||||
# Идентификатор, порядок сегментов и CRC проверяет общее ядро.
|
||||
elif mode == 'set-v2':
|
||||
if not self.samplerate:
|
||||
raise ValueError('SET CAN requires samplerate for reassembly timeout')
|
||||
events = self.reassembler.feed(ident, payload, ss, es, es * 1000.0 / self.samplerate)
|
||||
else:
|
||||
try:
|
||||
result = legacy(ident, payload, mode)
|
||||
events = [(ss, es, result, None)] if result else []
|
||||
except ValueError as exc:
|
||||
events = [(ss, es, None, str(exc))]
|
||||
for start, end, result, error in events:
|
||||
self.put(start, end, self.out_ann, [2 if error else 0, [error or result['summary']]])
|
||||
if result:
|
||||
result['can_id'] = ident
|
||||
self.put(start, end, self.out_python, ['FRAME', result])
|
||||
if events and payload:
|
||||
self.put(ss, es, self.out_ann, [1, [' '.join('%02X' % b for b in payload)]])
|
||||
1
tools/dsview/decoders/set_uart/__init__.py
Normal file
1
tools/dsview/decoders/set_uart/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
89
tools/dsview/decoders/set_uart/pd.py
Normal file
89
tools/dsview/decoders/set_uart/pd.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.set_uart import StreamParser
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'set_uart'
|
||||
name = 'SET UART'
|
||||
longname = 'SET v2 / SETGUI v1 / ProtoCAN bridge'
|
||||
desc = 'SET frames, fields and CRC over UART.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['uart']
|
||||
outputs = ['set']
|
||||
tags = ['Embedded/industrial']
|
||||
options = (
|
||||
{'id': 'protocol', 'desc': 'Protocol', 'default': 'auto',
|
||||
'values': ('auto', 'v2', 'v1', 'bridge')},
|
||||
{'id': 'gap_ms', 'desc': 'Reset incomplete frame after gap (ms, 0 disables)', 'default': 100},
|
||||
)
|
||||
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.parsers = {}
|
||||
self.last = {}
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def emit(self, events, direction):
|
||||
for ss, es, frame, error in events:
|
||||
prefix = ('RX', 'TX')[direction] + ' '
|
||||
self.put(ss, es, self.out_ann, [2 if error else 0, [prefix + (error or frame['summary'])]])
|
||||
if frame:
|
||||
if frame['protocol'] == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
|
||||
# Imported only at decode time: both packages are installed together.
|
||||
from common.setgui_decoders.set_can import legacy
|
||||
try:
|
||||
application = legacy(frame['can_id'], frame['payload'])
|
||||
frame['application'] = application
|
||||
self.put(ss, es, self.out_ann, [0, [application['summary']]])
|
||||
except ValueError as exc:
|
||||
self.put(ss, es, self.out_ann, [2, [str(exc)]])
|
||||
frame['direction'] = direction
|
||||
self.put(ss, es, self.out_python, ['FRAME', frame])
|
||||
if frame['payload']:
|
||||
self.put(ss, es, self.out_ann, [1, [prefix + ' '.join('%02X' % b for b in frame['payload'])]])
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
kind, direction, payload = data
|
||||
if direction not in (0, 1):
|
||||
return
|
||||
# RX и TX имеют независимые парсеры: перемежающиеся байты двух направлений
|
||||
# нельзя объединять в один прикладной кадр.
|
||||
parser = self.parsers.setdefault(direction, StreamParser(self.options['protocol']))
|
||||
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, [kind]])
|
||||
return
|
||||
if kind != 'FRAME':
|
||||
return
|
||||
if not payload[1]:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, ['Invalid UART frame']])
|
||||
return
|
||||
gap = float(self.options['gap_ms'])
|
||||
if self.samplerate and gap > 0 and direction in self.last and ss - self.last[direction] > self.samplerate * gap / 1000:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.last[direction] = es
|
||||
if not 0 <= payload[0] <= 255:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, ['Configure UART for 8 data bits']])
|
||||
return
|
||||
self.emit(parser.feed(payload[0], ss, es), direction)
|
||||
26
tools/dsview/decoders/transistor_pair/README.md
Normal file
26
tools/dsview/decoders/transistor_pair/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Transistor pair
|
||||
|
||||
Independent two-channel analyzer. Assign Vin1 and Vin2; Vstat is not used.
|
||||
Available in DSView's **Пользовательские** section as **Transistor pair**.
|
||||
|
||||
- Minimum active ON pulse duration (mintime), independently per input.
|
||||
- Minimum inactive OFF duration, independently per input.
|
||||
- Deadtime from Vin1 OFF to Vin2 ON and vice versa, with independent minimums.
|
||||
- Active overlap is a fault, regardless of the deadtime settings.
|
||||
- Period, frequency and active duty cycle for complete cycles on each input.
|
||||
- Independent active-high/active-low polarity for both inputs.
|
||||
|
||||
All limits are nanoseconds. Zero disables the corresponding lower limit;
|
||||
equality passes. Simultaneous handovers measure zero deadtime. These settings
|
||||
check measured pulses and do not debounce or filter the source waveform.
|
||||
|
||||
Measurements occupy separate waveform rows. Timing violations appear in the
|
||||
FAULT row. Unknown edges outside the capture are not fabricated; partial
|
||||
pulses do not produce full pulse widths or full-cycle frequency/duty values.
|
||||
|
||||
Python: `PairTimingChecker` in `logic_analyzer.decoders.transistor_pair`.
|
||||
Offline API: `analyze_capture(..., dict(mode='Transistor pair', channel=0,
|
||||
vin2_channel=1, vin1_mintime_ns=100, deadtime_12_ns=50))`.
|
||||
|
||||
For Vin/Vstat ACK checks and full driver-cycle OK/WARNING/FAULT results use
|
||||
the separate **IGBT 1SP/1SD** decoder.
|
||||
1
tools/dsview/decoders/transistor_pair/__init__.py
Normal file
1
tools/dsview/decoders/transistor_pair/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
88
tools/dsview/decoders/transistor_pair/pd.py
Normal file
88
tools/dsview/decoders/transistor_pair/pd.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Two-input transistor pair analyzer, independent of driver feedback."""
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.transistor_pair import PairTimingChecker
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'transistor_pair'
|
||||
name = 'Transistor pair'
|
||||
longname = 'Transistor pair: mintime / deadtime / overlap'
|
||||
desc = 'Checks ON/OFF pulse limits, deadtime, overlap, frequency and duty.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['logic']
|
||||
outputs = []
|
||||
tags = ['Clock/timing', 'Util']
|
||||
channels = (
|
||||
{'id': 'vin1', 'name': 'Vin1', 'desc': 'First transistor command'},
|
||||
{'id': 'vin2', 'name': 'Vin2', 'desc': 'Second transistor command'},
|
||||
)
|
||||
options = (
|
||||
{'id': 'vin1_active', 'desc': 'Vin1 active level', 'default': 'high', 'values': ('high', 'low')},
|
||||
{'id': 'vin2_active', 'desc': 'Vin2 active level', 'default': 'high', 'values': ('high', 'low')},
|
||||
{'id': 'vin1_mintime_ns', 'desc': 'Vin1 minimum ON time (ns)', 'default': 0},
|
||||
{'id': 'vin2_mintime_ns', 'desc': 'Vin2 minimum ON time (ns)', 'default': 0},
|
||||
{'id': 'vin1_minoff_ns', 'desc': 'Vin1 minimum OFF time (ns)', 'default': 0},
|
||||
{'id': 'vin2_minoff_ns', 'desc': 'Vin2 minimum OFF time (ns)', 'default': 0},
|
||||
{'id': 'deadtime_12_ns', 'desc': 'Minimum deadtime Vin1 -> Vin2 (ns)', 'default': 0},
|
||||
{'id': 'deadtime_21_ns', 'desc': 'Minimum deadtime Vin2 -> Vin1 (ns)', 'default': 0},
|
||||
)
|
||||
annotations = (
|
||||
('vin1-on', 'Vin1 ON duration'), ('vin2-on', 'Vin2 ON duration'),
|
||||
('deadtime', 'Deadtime'), ('failure', 'FAULT'),
|
||||
('vin1-off', 'Vin1 OFF duration'), ('vin2-off', 'Vin2 OFF duration'),
|
||||
('vin1-period', 'Vin1 period / frequency / duty'),
|
||||
('vin2-period', 'Vin2 period / frequency / duty'),
|
||||
)
|
||||
annotation_rows = (
|
||||
('deadtime', 'Deadtime', (2,)),
|
||||
('faults', 'FAULT: overlap / timing', (3,)),
|
||||
('vin1', 'Vin1 mintime / OFF', (0, 4)),
|
||||
('vin2', 'Vin2 mintime / OFF', (1, 5)),
|
||||
('pwm1', 'Vin1 period / frequency / duty', (6,)),
|
||||
('pwm2', 'Vin2 period / frequency / duty', (7,)),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.samplerate = None
|
||||
self.checker = None
|
||||
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
|
||||
def _put_events(self, events):
|
||||
for event in events:
|
||||
kind = event['kind']
|
||||
channel = event.get('channel', 1) - 1
|
||||
ann = (3 if kind in ('mintime_fail', 'deadtime_fail', 'off_fail', 'overlap') else
|
||||
2 if kind == 'deadtime' else channel if kind == 'mintime_ok' else
|
||||
4 + channel if kind == 'off_time' else 6 + channel)
|
||||
self.put(event['start'], event['end'], self.out_ann, [ann, [event['text'], event['short']]])
|
||||
|
||||
def decode(self):
|
||||
if not self.samplerate:
|
||||
raise ValueError('Cannot decode without samplerate.')
|
||||
if not self.has_channel(0) or not self.has_channel(1):
|
||||
raise ValueError('Connect both Vin1 and Vin2.')
|
||||
numeric = {name: self.options[name] for name in (
|
||||
'vin1_mintime_ns', 'vin2_mintime_ns', 'vin1_minoff_ns', 'vin2_minoff_ns',
|
||||
'deadtime_12_ns', 'deadtime_21_ns')}
|
||||
self.checker = PairTimingChecker(self.samplerate,
|
||||
self.options['vin1_active'] == 'high', self.options['vin2_active'] == 'high', **numeric)
|
||||
# Do not retain DSView's mutable native wait tuple.
|
||||
pins = list(self.wait({}))
|
||||
self._put_events(self.checker.update(self.samplenum, *pins))
|
||||
while True:
|
||||
pins = list(self.wait([{0: 'e'}, {1: 'e'}]))
|
||||
self._put_events(self.checker.update(self.samplenum, *pins))
|
||||
|
||||
def end(self):
|
||||
if self.checker is not None:
|
||||
self._put_events(self.checker.finish(self.samplenum))
|
||||
83
tools/dsview/driver-summary.patch
Normal file
83
tools/dsview/driver-summary.patch
Normal file
@@ -0,0 +1,83 @@
|
||||
--- a/DSView/pv/data/decoderstack.h
|
||||
+++ b/DSView/pv/data/decoderstack.h
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <QString>
|
||||
#include <mutex>
|
||||
|
||||
+#include "decode/gate_summary.h"
|
||||
#include "decode/row.h"
|
||||
#include "../data/signaldata.h"
|
||||
#include "decode/decoderstatus.h"
|
||||
@@ -96,6 +97,7 @@
|
||||
}
|
||||
|
||||
const char* get_root_decoder_id();
|
||||
+ GateSummary gate_summary;
|
||||
|
||||
void add_sub_decoder(decode::Decoder *decoder);
|
||||
void remove_sub_decoder(decode::Decoder *decoder);
|
||||
--- a/DSView/pv/data/decoderstack.cpp
|
||||
+++ b/DSView/pv/data/decoderstack.cpp
|
||||
@@ -369,6 +369,7 @@
|
||||
|
||||
void DecoderStack::init()
|
||||
{
|
||||
+ gate_summary.reset();
|
||||
_sample_count = 0;
|
||||
_samples_decoded = 0;
|
||||
_error_message = QString();
|
||||
@@ -814,6 +815,9 @@
|
||||
const srd_decoder *const decc = pdata->pdo->di->decoder;
|
||||
assert(decc);
|
||||
|
||||
+ if (strcmp(decc->id, "gate_driver_timing") == 0)
|
||||
+ d->gate_summary.observe(a->format());
|
||||
+
|
||||
auto row_iter = d->_rows.end();
|
||||
|
||||
// Try looking up the sub-row of this class
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -190,6 +190,42 @@
|
||||
match_layout->addStretch(1);
|
||||
|
||||
QVBoxLayout *bot_layout = new QVBoxLayout();
|
||||
+ auto gate_status = new QLabel(bot_panel);
|
||||
+ gate_status->setObjectName("gateDriverSummary");
|
||||
+ gate_status->setAlignment(Qt::AlignCenter);
|
||||
+ gate_status->setMinimumHeight(64);
|
||||
+ gate_status->hide();
|
||||
+ bot_layout->addWidget(gate_status);
|
||||
+ auto gate_timer = new QTimer(this);
|
||||
+ connect(gate_timer, &QTimer::timeout, this, [this, gate_status]() {
|
||||
+ int severity = 0;
|
||||
+ int drivers = 0;
|
||||
+ bool complete = true;
|
||||
+ for (auto trace : _session->get_decode_signals()) {
|
||||
+ auto stack = trace->decoder();
|
||||
+ if (QString(stack->get_root_decoder_id()) != "gate_driver_timing") continue;
|
||||
+ ++drivers;
|
||||
+ const bool done = !stack->IsRunning() && stack->get_progress() == 100;
|
||||
+ complete = complete && done;
|
||||
+ int result = stack->gate_summary.severity(done);
|
||||
+ if (stack->out_of_memory() || !stack->error_message().isEmpty())
|
||||
+ result = qMax(result, 1);
|
||||
+ severity = qMax(severity, result);
|
||||
+ }
|
||||
+ gate_status->setVisible(drivers > 0);
|
||||
+ if (!drivers) return;
|
||||
+ const QString text = severity == 2 ? "FAULT" : severity == 1 ? "WARNING" : "OK";
|
||||
+ const QString color = severity == 2 ? "#ff6262" : severity == 1 ? "#ffc857" : "#41d687";
|
||||
+ if (gate_status->text() != text) {
|
||||
+ gate_status->setText(text);
|
||||
+ gate_status->setStyleSheet("QLabel#gateDriverSummary { font-size: 32px; font-weight: bold; "
|
||||
+ "padding: 8px; border: 2px solid " + color + "; border-radius: 6px; color: " + color + "; }");
|
||||
+ }
|
||||
+ gate_status->setToolTip(complete
|
||||
+ ? QString::fromUtf8("Итог всех анализаторов драйвера за всю запись. FAULT: ошибки ACK, Vstat или входов; WARNING: предупреждения, неполные события или нет подтверждений; OK: проверки пройдены.")
|
||||
+ : QString::fromUtf8("Анализ ещё не завершён. Итог OK пока недоступен."));
|
||||
+ });
|
||||
+ gate_timer->start(250);
|
||||
bot_layout->addLayout(bot_title_layout);
|
||||
bot_layout->addLayout(ann_search_layout);
|
||||
bot_layout->addLayout(match_layout);
|
||||
35
tools/dsview/gate-pair-checkbox.patch
Normal file
35
tools/dsview/gate-pair-checkbox.patch
Normal file
@@ -0,0 +1,35 @@
|
||||
--- a/DSView/pv/prop/binding/decoderoptions.cpp
|
||||
+++ b/DSView/pv/prop/binding/decoderoptions.cpp
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
#include "../../data/decoderstack.h"
|
||||
#include "../../data/decode/decoder.h"
|
||||
+#include "../bool.h"
|
||||
#include "../double.h"
|
||||
#include "../enum.h"
|
||||
#include "../int.h"
|
||||
@@ -85,7 +86,23 @@
|
||||
|
||||
Property *prop = NULL;
|
||||
|
||||
- if (opt->values)
|
||||
+ if (strcmp(dec->id, "gate_driver_timing") == 0 &&
|
||||
+ strcmp(opt->id, "pair_analysis") == 0) {
|
||||
+ prop = new Bool(name, name,
|
||||
+ [getter]() -> GVariant* {
|
||||
+ GVariant *value = getter();
|
||||
+ const bool enabled = value && g_variant_get_int64(value) != 0;
|
||||
+ if (value) g_variant_unref(value);
|
||||
+ return g_variant_ref_sink(g_variant_new_boolean(enabled));
|
||||
+ },
|
||||
+ [setter](GVariant *value) {
|
||||
+ GVariant *number = g_variant_ref_sink(g_variant_new_int64(
|
||||
+ g_variant_get_boolean(value) ? 1 : 0));
|
||||
+ setter(number);
|
||||
+ g_variant_unref(number);
|
||||
+ });
|
||||
+ }
|
||||
+ else if (opt->values)
|
||||
prop = bind_enum(name, opt, getter, setter);
|
||||
else if (g_variant_is_of_type(opt->def, G_VARIANT_TYPE("d")))
|
||||
prop = new Double(name, name, 2, "",none, none, getter, setter);
|
||||
151
tools/dsview/install_decoders.ps1
Normal file
151
tools/dsview/install_decoders.ps1
Normal file
@@ -0,0 +1,151 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DsViewPath = (Join-Path $env:ProgramFiles 'DSView'),
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 2
|
||||
# Resolve source paths relative to templates, never the caller working directory.
|
||||
# Standalone DSView and SETGUI use the same adapters and parsing cores.
|
||||
$templateRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$targetRoot = [IO.Path]::GetFullPath($DsViewPath)
|
||||
$decoderRoot = Join-Path $targetRoot 'decoders'
|
||||
|
||||
# Use .NET for compatibility with PowerShell lacking Get-FileHash.
|
||||
# Release both stream and hashing provider even if reading fails.
|
||||
function Get-ContentHash([string]$Path) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
return [BitConverter]::ToString($algorithm.ComputeHash($stream))
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
$algorithm.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Check all existing ancestors: a junction under decoders/common could
|
||||
# redirect a local-looking update outside the chosen installation.
|
||||
function Assert-PlainPath([string]$Path) {
|
||||
$cursor = [IO.Path]::GetFullPath($Path)
|
||||
$first = $true
|
||||
while ($cursor) {
|
||||
if (Test-Path -LiteralPath $cursor) {
|
||||
$item = Get-Item -LiteralPath $cursor -Force
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "Symbolic links and junctions are not supported: $cursor"
|
||||
}
|
||||
if (-not $first -and -not $item.PSIsContainer) {
|
||||
throw "Expected a parent directory: $cursor"
|
||||
}
|
||||
}
|
||||
$parent = [IO.Path]::GetDirectoryName($cursor)
|
||||
if ($parent -eq $cursor) { break }
|
||||
$cursor = $parent
|
||||
$first = $false
|
||||
}
|
||||
}
|
||||
|
||||
Assert-PlainPath $targetRoot
|
||||
$exe = Join-Path $targetRoot 'DSView.exe'
|
||||
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
|
||||
throw "DSView.exe not found in '$targetRoot'. Pass -DsViewPath with the installed DSView directory."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $decoderRoot 'common') -PathType Container)) {
|
||||
throw "DSView decoders/common is missing. Select a complete DSView installation."
|
||||
}
|
||||
|
||||
# An explicit allowlist excludes tests, bytecode and unrelated modules.
|
||||
# The private common/setgui_decoders layout matches the embedded build.
|
||||
$files = @()
|
||||
foreach ($name in @('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
|
||||
foreach ($leaf in @('__init__.py', 'pd.py')) {
|
||||
$files += [pscustomobject]@{
|
||||
Source = Join-Path $PSScriptRoot "decoders/$name/$leaf"
|
||||
Relative = "$name/$leaf"
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($name in @('__init__', 'gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
|
||||
$files += [pscustomobject]@{
|
||||
Source = Join-Path $templateRoot "python/logic_analyzer/decoders/$name.py"
|
||||
Relative = "common/setgui_decoders/$name.py"
|
||||
}
|
||||
}
|
||||
# Validate the entire payload and destination before changing any decoder.
|
||||
foreach ($file in $files) {
|
||||
if (-not (Test-Path -LiteralPath $file.Source -PathType Leaf)) {
|
||||
throw "Missing templates source: $($file.Source). Initialize the templates submodule."
|
||||
}
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
Assert-PlainPath $destination
|
||||
if (Test-Path -LiteralPath $destination -PathType Container) {
|
||||
throw "Expected a file, found a directory: $destination"
|
||||
}
|
||||
if ([IO.Path]::GetFullPath($file.Source) -eq [IO.Path]::GetFullPath($destination)) {
|
||||
throw 'The installation destination must differ from the templates source.'
|
||||
}
|
||||
}
|
||||
# Read-only validation does not create backups or check elevated write access.
|
||||
# Real installation performs its own write-access check before copying.
|
||||
if ($CheckOnly) {
|
||||
Write-Output "Validated $($files.Count) files for $targetRoot. No files changed."
|
||||
return
|
||||
}
|
||||
# A mapped/running Windows EXE cannot be opened for writing. Never terminate DSView.
|
||||
try {
|
||||
$handle = [IO.File]::Open($exe, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
|
||||
$handle.Dispose()
|
||||
} catch {
|
||||
throw "Close DSView and check write permissions for '$targetRoot'. For Program Files, run PowerShell as administrator. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Keep each previous version outside decoder discovery directories.
|
||||
# Retain backups after success for manual recovery, not only after errors.
|
||||
$backup = Join-Path $targetRoot ('set-decoder-backups/' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N'))
|
||||
Assert-PlainPath $backup
|
||||
# Back up all existing files before writing the first replacement.
|
||||
foreach ($file in $files) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
if (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
$saved = Join-Path $backup $file.Relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($saved)) | Out-Null
|
||||
Copy-Item -LiteralPath $destination -Destination $saved
|
||||
}
|
||||
}
|
||||
# Track files before copying: a failed copy may leave a partial destination.
|
||||
# Rollback must include that file as well as completed replacements.
|
||||
$attempted = @()
|
||||
try {
|
||||
foreach ($file in $files) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
$attempted += $file
|
||||
Copy-Item -LiteralPath $file.Source -Destination $destination -Force
|
||||
if ((Get-ContentHash $file.Source) -ne (Get-ContentHash $destination)) {
|
||||
throw "Copy verification failed: $destination"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$failure = $_
|
||||
# Restore overwritten files and remove only new files from our allowlist.
|
||||
# Never recursively delete decoder folders or remove unrelated analyzers.
|
||||
foreach ($file in $attempted) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
$saved = Join-Path $backup $file.Relative
|
||||
try {
|
||||
if (Test-Path -LiteralPath $saved -PathType Leaf) {
|
||||
Copy-Item -LiteralPath $saved -Destination $destination -Force
|
||||
} elseif (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $destination -Force
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not restore $destination. Retained backup: $backup"
|
||||
}
|
||||
}
|
||||
throw $failure
|
||||
}
|
||||
Write-Output "Installed: IGBT 1SP/1SD, SET UART, SET CAN, PM35 UART in $decoderRoot"
|
||||
if (Test-Path -LiteralPath $backup) { Write-Output "Previous files retained in: $backup" }
|
||||
Write-Output 'Restart DSView. Add UART/CAN first, then stack the corresponding SET decoder. No external Python is required.'
|
||||
29
tools/dsview/native/gate_summary.h
Normal file
29
tools/dsview/native/gate_summary.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
// Annotation IDs are the public gate_driver_timing decoder contract.
|
||||
// Independent of row visibility and text search; one summary per decode run.
|
||||
class GateSummary {
|
||||
public:
|
||||
void reset() {
|
||||
controls = 0; acknowledgments = 0; warnings = 0; faults = 0;
|
||||
}
|
||||
void observe(int annotation) {
|
||||
switch (annotation) {
|
||||
case 0: ++controls; break;
|
||||
case 2: ++acknowledgments; break;
|
||||
case 3: ++warnings; break;
|
||||
case 4: case 5: ++faults; break;
|
||||
}
|
||||
}
|
||||
// 0 = OK, 1 = WARNING / incomplete, 2 = FAULT.
|
||||
int severity(bool complete) const {
|
||||
if (faults.load()) return 2;
|
||||
if (!complete || warnings.load() || !acknowledgments.load() ||
|
||||
controls.load() != acknowledgments.load()) return 1;
|
||||
return 0;
|
||||
}
|
||||
private:
|
||||
std::atomic<uint64_t> controls{0}, acknowledgments{0}, warnings{0}, faults{0};
|
||||
};
|
||||
1686
tools/dsview/setgui-host.patch
Normal file
1686
tools/dsview/setgui-host.patch
Normal file
@@ -0,0 +1,1686 @@
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index cc82669f..575eb851 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -190,6 +190,10 @@ if(Qt5Core_FOUND)
|
||||
#set(QT_LIBRARIES Qt5::Gui Qt5::Widgets Qt5::WinExtras)
|
||||
set(QT_INCLUDE_DIRS ${Qt5Gui_INCLUDE_DIRS} ${Qt5Widgets_INCLUDE_DIRS})
|
||||
set(QT_LIBRARIES Qt5::Gui Qt5::Widgets)
|
||||
+ if(WIN32)
|
||||
+ find_package(Qt5WinExtras REQUIRED)
|
||||
+ list(APPEND QT_LIBRARIES Qt5::WinExtras)
|
||||
+ endif()
|
||||
add_definitions(${Qt5Gui_DEFINITIONS} ${Qt5Widgets_DEFINITIONS})
|
||||
else()
|
||||
find_package(Qt6Core QUIET)
|
||||
@@ -551,10 +555,13 @@ set(DSView_RESOURCES
|
||||
|
||||
if(WIN32)
|
||||
# Use the DSView icon for the DSView.exe executable.
|
||||
+ add_definitions(-DUNICODE -D_UNICODE)
|
||||
set(CMAKE_RC_COMPILE_OBJECT "${CMAKE_RC_COMPILER} -O coff -I${CMAKE_CURRENT_SOURCE_DIR} <SOURCE> <OBJECT>")
|
||||
enable_language(RC)
|
||||
# app icon
|
||||
list(APPEND DSView_SOURCES applogo.rc)
|
||||
+ list(APPEND DSView_SOURCES DSView/pv/winnativewidget.cpp DSView/pv/winshadow.cpp)
|
||||
+ list(APPEND DSView_HEADERS DSView/pv/winshadow.h)
|
||||
endif()
|
||||
|
||||
if(Qt5Core_FOUND)
|
||||
diff --git a/DSView/main.cpp b/DSView/main.cpp
|
||||
index 748a59d9..58d2d155 100644
|
||||
--- a/DSView/main.cpp
|
||||
+++ b/DSView/main.cpp
|
||||
@@ -64,6 +64,7 @@ int main(int argc, char *argv[])
|
||||
const char *open_file = NULL;
|
||||
int logLevel = -1;
|
||||
bool bStoreLog = false;
|
||||
+ quintptr embedParent = 0;
|
||||
|
||||
//----------------------rebuild command param
|
||||
#ifdef _WIN32
|
||||
@@ -103,6 +104,8 @@ int main(int argc, char *argv[])
|
||||
{"version", no_argument, 0, 'v'},
|
||||
{"storelog", no_argument, 0, 's'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
+ {"embed-parent", required_argument, 0, 1000},
|
||||
+ {"host-version", no_argument, 0, 1001},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
@@ -113,6 +116,20 @@ int main(int argc, char *argv[])
|
||||
|
||||
switch (c)
|
||||
{
|
||||
+ case 1000: {
|
||||
+ bool ok = false;
|
||||
+ embedParent = QString::fromUtf8(optarg).toULongLong(&ok, 0);
|
||||
+#ifdef _WIN32
|
||||
+ if (!ok || !embedParent || !IsWindow(reinterpret_cast<HWND>(embedParent)))
|
||||
+#else
|
||||
+ if (true)
|
||||
+#endif
|
||||
+ return 2;
|
||||
+ break;
|
||||
+ }
|
||||
+ case 1001:
|
||||
+ printf("DSView host protocol 1\n");
|
||||
+ return 0;
|
||||
case 'l': // log level
|
||||
logLevel = atoi(optarg);
|
||||
break;
|
||||
@@ -174,6 +191,14 @@ bool bHighScale = true;
|
||||
QApplication::setApplicationName("DSView");
|
||||
QApplication::setOrganizationName("DreamSourceLab");
|
||||
QApplication::setOrganizationDomain("www.DreamSourceLab.com");
|
||||
+ if (embedParent) {
|
||||
+ QApplication::setOrganizationName("SET");
|
||||
+ QApplication::setApplicationName("SETGUI-DSView");
|
||||
+ a.setProperty("embeddedHost", true);
|
||||
+ // The native child uses Qt::Tool and is not a primary Qt window.
|
||||
+ // Closing a modal dialog must not end the hosted application's loop.
|
||||
+ a.setQuitOnLastWindowClosed(false);
|
||||
+ }
|
||||
|
||||
//----------------------init log
|
||||
dsv_log_init(); // Don't call before QApplication be inited
|
||||
@@ -199,6 +224,17 @@ bool bHighScale = true;
|
||||
AppControl *control = AppControl::Instance();
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
app.LoadAll(); //load app config
|
||||
+ if (embedParent) {
|
||||
+ app.frameOptions.style = "dark";
|
||||
+ app.appOptions.fontSize = 10;
|
||||
+ QString captureDir = QString::fromUtf8(qgetenv("DSVIEW_CAPTURE_DIR"));
|
||||
+ if (QDir(captureDir).exists() && !captureDir.isEmpty()) {
|
||||
+ app.userHistory.saveDir = captureDir;
|
||||
+ app.userHistory.openDir = captureDir;
|
||||
+ app.userHistory.exportDir = captureDir;
|
||||
+ app.userHistory.screenShotPath = captureDir;
|
||||
+ }
|
||||
+ }
|
||||
LangResource::Instance()->Load(app.frameOptions.language);
|
||||
|
||||
if (app.appOptions.ableSaveLog){
|
||||
@@ -237,8 +273,23 @@ bool bHighScale = true;
|
||||
{
|
||||
pv::MainFrame w;
|
||||
control->Start();
|
||||
- w.ShowFormInit();
|
||||
- w.ShowHelpDocAsync(); //to show the dailog for open help document
|
||||
+#ifdef _WIN32
|
||||
+ if (embedParent) {
|
||||
+ if (!w.ShowEmbedded(embedParent)) return 2;
|
||||
+ printf("DSVIEW_READY %llu\n", static_cast<unsigned long long>(w.winId()));
|
||||
+ printf("DSVIEW_CAPABILITIES OPEN_FILE\n");
|
||||
+ fflush(stdout);
|
||||
+ // Optional render artifact for integration smoke tests.
|
||||
+ const QString screenshot = QString::fromUtf8(qgetenv("DSVIEW_HOST_SCREENSHOT"));
|
||||
+ if (!screenshot.isEmpty()) QTimer::singleShot(2500, &w, [&w, screenshot]() {
|
||||
+ w.grab().save(screenshot);
|
||||
+ });
|
||||
+ } else
|
||||
+#endif
|
||||
+ {
|
||||
+ w.ShowFormInit();
|
||||
+ w.ShowHelpDocAsync();
|
||||
+ }
|
||||
|
||||
ret = a.exec(); //Run the application
|
||||
control->Stop();
|
||||
diff --git a/DSView/pv/config/appconfig.cpp b/DSView/pv/config/appconfig.cpp
|
||||
index 1e318306..b4a7f5ea 100644
|
||||
--- a/DSView/pv/config/appconfig.cpp
|
||||
+++ b/DSView/pv/config/appconfig.cpp
|
||||
@@ -438,8 +438,9 @@ bool AppConfig::IsDarkStyle()
|
||||
return false;
|
||||
}
|
||||
|
||||
-QColor AppConfig::GetStyleColor()
|
||||
-{
|
||||
+QColor AppConfig::GetStyleColor()
|
||||
+{
|
||||
+ if (qApp->property("embeddedHost").toBool()) return QColor("#10151d");
|
||||
if (IsDarkStyle()){
|
||||
return QColor(38, 38, 38);
|
||||
}
|
||||
@@ -551,4 +552,4 @@ QString GetProfileDir()
|
||||
#else
|
||||
return QStandardPaths::writableLocation(QStandardPaths::DataLocation);
|
||||
#endif
|
||||
-}
|
||||
\ No newline at end of file
|
||||
+}
|
||||
diff --git a/DSView/pv/data/decode/annotation.cpp b/DSView/pv/data/decode/annotation.cpp
|
||||
index f285247c..7da1849c 100644
|
||||
--- a/DSView/pv/data/decode/annotation.cpp
|
||||
+++ b/DSView/pv/data/decode/annotation.cpp
|
||||
@@ -179,6 +179,16 @@ const std::vector<QString>& Annotation::annotations() const
|
||||
return resItem.cvt_lines;
|
||||
}
|
||||
|
||||
+bool Annotation::contains_text(const QString &text) const
|
||||
+{
|
||||
+ // The waveform can display any of these labels, not just the longest one.
|
||||
+ for (const auto &label : annotations()) {
|
||||
+ if (label.contains(text, Qt::CaseInsensitive))
|
||||
+ return true;
|
||||
+ }
|
||||
+ return false;
|
||||
+}
|
||||
+
|
||||
bool Annotation::is_numberic()
|
||||
{
|
||||
AnnotationSourceItem *resItem = _status->m_resTable.GetItem(_resIndex);
|
||||
diff --git a/DSView/pv/data/decode/annotation.h b/DSView/pv/data/decode/annotation.h
|
||||
index a5b0e440..06067395 100644
|
||||
--- a/DSView/pv/data/decode/annotation.h
|
||||
+++ b/DSView/pv/data/decode/annotation.h
|
||||
@@ -66,6 +66,8 @@ public:
|
||||
|
||||
const std::vector<QString>& annotations() const;
|
||||
|
||||
+ bool contains_text(const QString &text) const;
|
||||
+
|
||||
private:
|
||||
uint64_t _start_sample;
|
||||
uint64_t _end_sample;
|
||||
diff --git a/DSView/pv/data/decodermodel.cpp b/DSView/pv/data/decodermodel.cpp
|
||||
index 61710e54..ee814a76 100644
|
||||
--- a/DSView/pv/data/decodermodel.cpp
|
||||
+++ b/DSView/pv/data/decodermodel.cpp
|
||||
@@ -32,6 +32,58 @@ using namespace std;
|
||||
namespace pv {
|
||||
namespace data {
|
||||
|
||||
+AnnotationMatchModel::AnnotationMatchModel(QAbstractItemModel *source, QObject *parent)
|
||||
+ : QAbstractTableModel(parent), _source(source)
|
||||
+{
|
||||
+ connect(source, &QAbstractItemModel::modelAboutToBeReset, this, [this]() {
|
||||
+ setMatches({}, QString());
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+int AnnotationMatchModel::rowCount(const QModelIndex &parent) const
|
||||
+{
|
||||
+ return parent.isValid() ? 0 : int(_matches.size());
|
||||
+}
|
||||
+
|
||||
+int AnnotationMatchModel::columnCount(const QModelIndex &parent) const
|
||||
+{
|
||||
+ return parent.isValid() ? 0 : 2;
|
||||
+}
|
||||
+
|
||||
+QModelIndex AnnotationMatchModel::sourceIndex(int row) const
|
||||
+{
|
||||
+ return _source && row >= 0 && row < int(_matches.size()) ? _matches[row] : QModelIndex();
|
||||
+}
|
||||
+
|
||||
+void AnnotationMatchModel::setMatches(const std::vector<QModelIndex> &matches, const QString &term)
|
||||
+{
|
||||
+ beginResetModel();
|
||||
+ _matches = matches;
|
||||
+ _term = term;
|
||||
+ endResetModel();
|
||||
+}
|
||||
+
|
||||
+QVariant AnnotationMatchModel::data(const QModelIndex &index, int role) const
|
||||
+{
|
||||
+ const QModelIndex source = sourceIndex(index.row());
|
||||
+ if (!index.isValid() || !source.isValid()) return QVariant();
|
||||
+ if (role == Qt::DisplayRole && index.column() == 0)
|
||||
+ return _source->headerData(source.column(), Qt::Horizontal, role);
|
||||
+ if (role == Qt::DisplayRole) {
|
||||
+ const auto labels = source.data(Qt::ToolTipRole).toString().split('\n');
|
||||
+ for (const auto &label : labels)
|
||||
+ if (label.contains(_term, Qt::CaseInsensitive)) return label;
|
||||
+ }
|
||||
+ return source.data(role);
|
||||
+}
|
||||
+
|
||||
+QVariant AnnotationMatchModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||
+{
|
||||
+ if (role != Qt::DisplayRole) return QVariant();
|
||||
+ if (orientation == Qt::Vertical) return section + 1;
|
||||
+ return section == 0 ? tr("Annotation") : tr("Match");
|
||||
+}
|
||||
+
|
||||
DecoderModel::DecoderModel(QObject *parent)
|
||||
: QAbstractTableModel(parent),
|
||||
_decoder_stack(NULL)
|
||||
@@ -68,17 +120,51 @@ QVariant DecoderModel::data(const QModelIndex &index, int role) const
|
||||
if (role == Qt::TextAlignmentRole) {
|
||||
return int(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
}
|
||||
- else if (role == Qt::DisplayRole) {
|
||||
+ else if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
|
||||
if (_decoder_stack) {
|
||||
pv::data::decode::Annotation ann;
|
||||
if (_decoder_stack->list_annotation(&ann, index.column(), index.row())) {
|
||||
- return ann.annotations().at(0);
|
||||
+ const auto &labels = ann.annotations();
|
||||
+ if (role == Qt::DisplayRole)
|
||||
+ return labels.empty() ? QVariant() : QVariant(labels.front());
|
||||
+ QStringList text;
|
||||
+ for (const auto &label : labels)
|
||||
+ if (!text.contains(label)) text.append(label);
|
||||
+ return text.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
+bool DecoderModel::annotation_matches(int column, int row, const QStringList &terms) const
|
||||
+{
|
||||
+ if (!_decoder_stack || terms.isEmpty() || terms.first().isEmpty())
|
||||
+ return false;
|
||||
+ decode::Annotation ann;
|
||||
+ if (!_decoder_stack->list_annotation(&ann, column, row) ||
|
||||
+ !ann.contains_text(terms.first()))
|
||||
+ return false;
|
||||
+
|
||||
+ if (terms.size() == 1)
|
||||
+ return true;
|
||||
+
|
||||
+ // Retain the numeric AA-BB-CC sequence search in each individual column.
|
||||
+ // Text containing a hyphen is handled as one literal term by the caller.
|
||||
+ if (!ann.is_numberic())
|
||||
+ return false;
|
||||
+ uint64_t next_row = uint64_t(row) + 1;
|
||||
+ for (int term = 1; term < terms.size(); ++term) {
|
||||
+ do {
|
||||
+ if (!_decoder_stack->list_annotation(&ann, column, next_row++))
|
||||
+ return false;
|
||||
+ } while (!ann.is_numberic());
|
||||
+ if (!ann.contains_text(terms.at(term)))
|
||||
+ return false;
|
||||
+ }
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
QVariant DecoderModel::headerData(int section,
|
||||
Qt::Orientation orientation,
|
||||
int role) const
|
||||
diff --git a/DSView/pv/data/decodermodel.h b/DSView/pv/data/decodermodel.h
|
||||
index d35849f4..f2f15887 100644
|
||||
--- a/DSView/pv/data/decodermodel.h
|
||||
+++ b/DSView/pv/data/decodermodel.h
|
||||
@@ -23,6 +23,8 @@
|
||||
#define DSVIEW_PV_DATA_DECODERMODEL_H
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
+#include <QStringList>
|
||||
+#include <QPointer>
|
||||
|
||||
#include "decode/rowdata.h"
|
||||
|
||||
@@ -31,6 +33,24 @@ namespace data {
|
||||
|
||||
class DecoderStack;
|
||||
|
||||
+// One matched annotation per row; columns in DecoderModel have independent
|
||||
+// timelines, so filtering its row numbers would also expose unrelated events.
|
||||
+class AnnotationMatchModel : public QAbstractTableModel
|
||||
+{
|
||||
+public:
|
||||
+ AnnotationMatchModel(QAbstractItemModel *source, QObject *parent);
|
||||
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
+ QVariant data(const QModelIndex &index, int role) const override;
|
||||
+ QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
|
||||
+ void setMatches(const std::vector<QModelIndex> &matches, const QString &term);
|
||||
+ QModelIndex sourceIndex(int row) const;
|
||||
+private:
|
||||
+ QPointer<QAbstractItemModel> _source;
|
||||
+ std::vector<QModelIndex> _matches;
|
||||
+ QString _term;
|
||||
+};
|
||||
+
|
||||
namespace decode {
|
||||
class Annotation;
|
||||
class Decoder;
|
||||
@@ -47,6 +67,8 @@ public:
|
||||
QVariant data(const QModelIndex &index, int role) const;
|
||||
QVariant headerData(int section, Qt::Orientation orientation,int role) const;
|
||||
|
||||
+ bool annotation_matches(int column, int row, const QStringList &terms) const;
|
||||
+
|
||||
void setDecoderStack(DecoderStack *decoder_stack);
|
||||
|
||||
inline DecoderStack* getDecoderStack(){
|
||||
diff --git a/DSView/pv/data/decoderstack.cpp b/DSView/pv/data/decoderstack.cpp
|
||||
index cb6b0b9a..ca46182f 100644
|
||||
--- a/DSView/pv/data/decoderstack.cpp
|
||||
+++ b/DSView/pv/data/decoderstack.cpp
|
||||
@@ -168,8 +168,7 @@ void DecoderStack::build_row()
|
||||
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
|
||||
if (iter == _rows_gshow.end()) {
|
||||
_rows_gshow[row] = true;
|
||||
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
|
||||
- row.title().contains("warning", Qt::CaseInsensitive)) {
|
||||
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
|
||||
_rows_lshow[row] = false;
|
||||
} else {
|
||||
_rows_lshow[row] = true;
|
||||
@@ -192,8 +191,7 @@ void DecoderStack::build_row()
|
||||
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
|
||||
if (iter == _rows_gshow.end()) {
|
||||
_rows_gshow[row] = true;
|
||||
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
|
||||
- row.title().contains("warning", Qt::CaseInsensitive)) {
|
||||
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
|
||||
_rows_lshow[row] = false;
|
||||
} else {
|
||||
_rows_lshow[row] = true;
|
||||
diff --git a/DSView/pv/dialogs/deviceoptions.cpp b/DSView/pv/dialogs/deviceoptions.cpp
|
||||
index 7d4ae1c4..efe19693 100644
|
||||
--- a/DSView/pv/dialogs/deviceoptions.cpp
|
||||
+++ b/DSView/pv/dialogs/deviceoptions.cpp
|
||||
@@ -392,18 +392,15 @@ void DeviceOptions::logic_probes(QVBoxLayout &layout)
|
||||
|
||||
QPushButton *enable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_ENABLE_ALL), "Enable All"));
|
||||
QPushButton *disable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_DISABLE_ALL), "Disable All"));
|
||||
- enable_all_probes->setMaximumHeight(33);
|
||||
- disable_all_probes->setMaximumHeight(33);
|
||||
+ // Let Qt include the full label and themed padding in the preferred size.
|
||||
+ enable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
+ disable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
enable_all_probes->setFont(font);
|
||||
- disable_all_probes->setFont(font);
|
||||
-
|
||||
- int bt_width = enable_all_probes->fontMetrics().horizontalAdvance(enable_all_probes->text()) + 20;
|
||||
- enable_all_probes->setMaximumWidth(bt_width);
|
||||
- disable_all_probes->setMaximumWidth(bt_width);
|
||||
-
|
||||
- this->update_font();
|
||||
-
|
||||
- contentHeight += enable_all_probes->sizeHint().height();
|
||||
+ disable_all_probes->setFont(font);
|
||||
+
|
||||
+ this->update_font();
|
||||
+
|
||||
+ contentHeight += qMax(enable_all_probes->sizeHint().height(), disable_all_probes->sizeHint().height());
|
||||
contentHeight += channel_line_height * row2 + 50;
|
||||
|
||||
connect(enable_all_probes, SIGNAL(clicked()),
|
||||
diff --git a/DSView/pv/dock/keywordlineedit.cpp b/DSView/pv/dock/keywordlineedit.cpp
|
||||
index 2c2e4722..9a33e11a 100644
|
||||
--- a/DSView/pv/dock/keywordlineedit.cpp
|
||||
+++ b/DSView/pv/dock/keywordlineedit.cpp
|
||||
@@ -353,6 +353,10 @@ void PopupLineEdit::showPupopInput()
|
||||
_popup_input = input;
|
||||
|
||||
connect(input, SIGNAL(sig_inputEnd(QString)), this, SLOT(onPopupInputEditEnd(QString)));
|
||||
+ // The popup owns keyboard input on Windows. Forward Enter after it commits
|
||||
+ // the text and closes, so actions connected to the outer editor still run.
|
||||
+ connect(line, &QLineEdit::returnPressed, this, &QLineEdit::returnPressed,
|
||||
+ Qt::QueuedConnection);
|
||||
|
||||
if (_is_number_mode){
|
||||
connect(line, SIGNAL(valueChanged(int)), this, SLOT(onPopupInputValueChanged(int)));
|
||||
diff --git a/DSView/pv/dock/protocoldock.cpp b/DSView/pv/dock/protocoldock.cpp
|
||||
index 648eb5b8..1a023707 100644
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -37,14 +37,13 @@
|
||||
#include <QHeaderView>
|
||||
#include <QScrollBar>
|
||||
#include <QRegularExpression>
|
||||
-#include <QFuture>
|
||||
-#include <QProgressDialog>
|
||||
-#include <QtConcurrent/QtConcurrent>
|
||||
+#include <QElapsedTimer>
|
||||
#include <QSizePolicy>
|
||||
#include <assert.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
+#include <limits>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QHeaderView>
|
||||
#include "../ui/msgbox.h"
|
||||
@@ -56,6 +55,11 @@
|
||||
#include "../ui/langresource.h"
|
||||
#include "../appcontrol.h"
|
||||
#include "../ui/fn.h"
|
||||
+#include "../dialogs/decoderoptionsdlg.h"
|
||||
+#include "../dialogs/dsmessagebox.h"
|
||||
+#include <QApplication>
|
||||
+#include <QFile>
|
||||
+#include <QTimer>
|
||||
|
||||
using namespace std;
|
||||
|
||||
@@ -69,9 +73,10 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
_view(view)
|
||||
{
|
||||
_session = session;
|
||||
- _cur_search_index = -1;
|
||||
- _search_edited = false;
|
||||
_pro_add_button = NULL;
|
||||
+ _search_timer = new QTimer(this);
|
||||
+ _search_timer->setSingleShot(true);
|
||||
+ connect(_search_timer, &QTimer::timeout, this, &ProtocolDock::search_step);
|
||||
|
||||
//-----------------------------get protocol list
|
||||
GSList *l = const_cast<GSList*>(srd_decoder_list());
|
||||
@@ -160,7 +165,6 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
_ann_search_edit = new PopupLineEdit(bot_panel);
|
||||
|
||||
_ann_search_button->setFixedWidth(_ann_search_button->height());
|
||||
- _ann_search_button->setDisabled(true);
|
||||
|
||||
QHBoxLayout *ann_search_layout = new QHBoxLayout();
|
||||
ann_search_layout->setSpacing(2);
|
||||
@@ -170,6 +174,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
ann_search_layout->addWidget(_nxt_button);
|
||||
|
||||
_table_view = new QTableView(bot_panel);
|
||||
+ _match_model = new data::AnnotationMatchModel(_session->get_decoder_model(), this);
|
||||
_table_view->setModel(_session->get_decoder_model());
|
||||
_table_view->setAlternatingRowColors(true);
|
||||
_table_view->setShowGrid(false);
|
||||
@@ -191,12 +196,32 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
bot_layout->addWidget(_table_view);
|
||||
bot_panel->setLayout(bot_layout);
|
||||
|
||||
+ // Keep decoder rows scrollable so their combined minimum height does not
|
||||
+ // prevent the user from dragging the divider upwards.
|
||||
+ auto top_scroll = new QScrollArea(this);
|
||||
+ top_scroll->setFrameShape(QFrame::NoFrame);
|
||||
+ top_scroll->setWidgetResizable(true);
|
||||
+ top_scroll->setMinimumHeight(70);
|
||||
+ top_scroll->setWidget(top_panel);
|
||||
+
|
||||
QSplitter *split_widget = new QSplitter(this);
|
||||
- split_widget->insertWidget(0, top_panel);
|
||||
+ split_widget->insertWidget(0, top_scroll);
|
||||
split_widget->insertWidget(1, bot_panel);
|
||||
split_widget->setOrientation(Qt::Vertical);
|
||||
split_widget->setCollapsible(0, false);
|
||||
split_widget->setCollapsible(1, false);
|
||||
+ split_widget->setHandleWidth(10);
|
||||
+ split_widget->setStretchFactor(0, 0);
|
||||
+ split_widget->setStretchFactor(1, 1);
|
||||
+ split_widget->setSizes({180, 420});
|
||||
+ split_widget->handle(1)->setCursor(Qt::SizeVerCursor);
|
||||
+ split_widget->handle(1)->setToolTip(tr("Drag up or down to resize Decoders and Decoding Results"));
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ split_widget->setStyleSheet(
|
||||
+ "QSplitter#protocolWidget::handle:vertical { background: #243247; "
|
||||
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
|
||||
+ "QSplitter#protocolWidget::handle:vertical:hover { background: #1570d8; }");
|
||||
+ }
|
||||
|
||||
this->setWidgetResizable(true);
|
||||
this->setWidget(split_widget);
|
||||
@@ -207,6 +232,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
connect(_bot_set_button, SIGNAL(clicked()),this, SLOT(set_model()));
|
||||
connect(_pre_button, SIGNAL(clicked()),this, SLOT(search_pre()));
|
||||
connect(_nxt_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
|
||||
+ connect(_ann_search_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
|
||||
connect(_pro_add_button, SIGNAL(clicked()),this, SLOT(on_add_protocol()));
|
||||
connect(_del_all_button, SIGNAL(clicked()),this, SLOT(on_del_all_protocol()));
|
||||
|
||||
@@ -218,7 +244,12 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
|
||||
connect(_pro_search_button, SIGNAL(clicked()), this, SLOT(show_protocol_select()));
|
||||
|
||||
- connect(_ann_search_edit, SIGNAL(editingFinished()), this, SLOT(search_changed()));
|
||||
+ connect(_ann_search_edit, &QLineEdit::textChanged, this, &ProtocolDock::search_changed);
|
||||
+ connect(_ann_search_edit, &QLineEdit::returnPressed, this, &ProtocolDock::search_nxt);
|
||||
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelAboutToBeReset,
|
||||
+ this, &ProtocolDock::invalidate_search);
|
||||
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelReset,
|
||||
+ this, &ProtocolDock::search_done);
|
||||
|
||||
|
||||
ADD_UI(this);
|
||||
@@ -241,6 +272,12 @@ ProtocolDock::~ProtocolDock()
|
||||
void ProtocolDock::retranslateUi()
|
||||
{
|
||||
_ann_search_edit->setPlaceholderText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCH), "search"));
|
||||
+ _ann_search_edit->setToolTip(tr("Search all enabled result columns, including short waveform labels.\n"
|
||||
+ "Case insensitive. Enter or the magnifier: next match.\n"
|
||||
+ "Numeric sequence: AA-BB-CC."));
|
||||
+ _ann_search_button->setToolTip(tr("Find next (Enter)"));
|
||||
+ _pre_button->setToolTip(tr("Previous match"));
|
||||
+ _nxt_button->setToolTip(tr("Next match"));
|
||||
_matchs_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_MATCHING_ITEMS), "Matching Items:"));
|
||||
_bot_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_LIST_VIEWER), "Protocol List Viewer"));
|
||||
_pro_keyword_edit->ResetText();
|
||||
@@ -290,6 +327,89 @@ int ProtocolDock::get_protocol_index_by_id(QString id)
|
||||
return -1;
|
||||
}
|
||||
|
||||
+bool ProtocolDock::add_host_decoder(const QString &id)
|
||||
+{
|
||||
+ if (_session->is_working() || _session->get_device()->get_work_mode() != LOGIC ||
|
||||
+ QApplication::activeModalWidget() || get_protocol_index_by_id(id) < 0)
|
||||
+ return false;
|
||||
+ _selected_protocol_id = id;
|
||||
+ const size_t count = _protocol_lay_items.size();
|
||||
+ on_add_protocol();
|
||||
+ return _protocol_lay_items.size() > count;
|
||||
+}
|
||||
+
|
||||
+// Opt-in integration check used by SETGUI --check-dsview. Exercise the same
|
||||
+// modal dialogs and removal paths as the right-hand decoder panel.
|
||||
+void ProtocolDock::check_host_decoders()
|
||||
+{
|
||||
+ const QString report = QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK"));
|
||||
+ if (report.isEmpty()) return;
|
||||
+ auto log = [report](const QString &message) {
|
||||
+ QFile file(report);
|
||||
+ if (file.open(QIODevice::WriteOnly | QIODevice::Append))
|
||||
+ file.write((message + "\n").toUtf8());
|
||||
+ };
|
||||
+ auto timer = new QTimer(this);
|
||||
+ int phase = 0;
|
||||
+ connect(timer, &QTimer::timeout, this, [this, timer, phase, log, report]() mutable {
|
||||
+ bool ok = true;
|
||||
+ auto answer = [this](QMessageBox::StandardButton button) {
|
||||
+ QTimer::singleShot(80, this, [button]() {
|
||||
+ auto dialog = qobject_cast<dialogs::DSMessageBox*>(QApplication::activeModalWidget());
|
||||
+ if (dialog && dialog->mBox()->button(button))
|
||||
+ dialog->mBox()->button(button)->click();
|
||||
+ });
|
||||
+ };
|
||||
+ if (phase < 4) {
|
||||
+ const auto before = _protocol_lay_items.size();
|
||||
+ if (before == 0) ok = false;
|
||||
+ else {
|
||||
+ answer(phase % 2 ? QMessageBox::Yes : QMessageBox::No);
|
||||
+ if (phase < 2) OnProtocolDelete(_protocol_lay_items.front());
|
||||
+ else on_del_all_protocol();
|
||||
+ const auto after = _protocol_lay_items.size();
|
||||
+ ok = phase % 2 == 0 ? after == before : after == (phase == 1 ? before - 1 : 0);
|
||||
+ }
|
||||
+ } else if (phase < 8) {
|
||||
+ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart"})[phase - 4];
|
||||
+ if (get_protocol_index_by_id(id) < 0) ok = false;
|
||||
+ else {
|
||||
+ QTimer::singleShot(80, this, [report, id]() {
|
||||
+ auto dialog = qobject_cast<dialogs::DecoderOptionsDlg*>(QApplication::activeModalWidget());
|
||||
+ if (!dialog) return;
|
||||
+ int channel = 1;
|
||||
+ for (auto combo : dialog->findChildren<QComboBox*>()) {
|
||||
+ if (combo->count() > 1 && combo->itemText(0) == "-" && combo->itemData(0).toInt() == -1)
|
||||
+ combo->setCurrentIndex(qMin(channel++, combo->count() - 1));
|
||||
+ }
|
||||
+ dialog->grab().save(report + "." + id + ".png");
|
||||
+ QMetaObject::invokeMethod(dialog, "on_accept", Qt::DirectConnection);
|
||||
+ });
|
||||
+ ok = add_host_decoder(id);
|
||||
+ if (ok) {
|
||||
+ auto trace = _session->get_decode_signals().back();
|
||||
+ ok = QString(trace->decoder()->stack().back()->decoder()->id) == id;
|
||||
+ }
|
||||
+ }
|
||||
+ } else if (phase == 8) {
|
||||
+ answer(QMessageBox::Yes);
|
||||
+ on_del_all_protocol();
|
||||
+ ok = _protocol_lay_items.empty() && _session->get_decode_signals().empty();
|
||||
+ } else {
|
||||
+ log("PASS");
|
||||
+ timer->stop();
|
||||
+ timer->deleteLater();
|
||||
+ return;
|
||||
+ }
|
||||
+ log(QString("phase %1: %2").arg(phase++).arg(ok ? "ok" : "FAIL"));
|
||||
+ if (!ok) {
|
||||
+ timer->stop();
|
||||
+ timer->deleteLater();
|
||||
+ }
|
||||
+ });
|
||||
+ timer->start(350);
|
||||
+}
|
||||
+
|
||||
void ProtocolDock::on_add_protocol()
|
||||
{
|
||||
if (_decoderInfoList.size() == 0){
|
||||
@@ -471,7 +591,7 @@ void ProtocolDock::decoded_progress(int progress)
|
||||
index++;
|
||||
}
|
||||
|
||||
- if (progress == 0 || progress % 10 == 1){
|
||||
+ if (progress == 0 || progress == 100 || progress % 10 == 1){
|
||||
update_model();
|
||||
}
|
||||
}
|
||||
@@ -481,8 +601,6 @@ void ProtocolDock::set_model()
|
||||
pv::dialogs::ProtocolList *protocollist_dlg = new pv::dialogs::ProtocolList(this, _session);
|
||||
protocollist_dlg->exec();
|
||||
resize_table_view(_session->get_decoder_model());
|
||||
- _model_proxy.setSourceModel(_session->get_decoder_model());
|
||||
- search_done();
|
||||
|
||||
// clear mark_index of all DecoderStacks
|
||||
const auto &decode_sigs = _session->get_decode_signals();
|
||||
@@ -513,15 +631,13 @@ void ProtocolDock::update_model()
|
||||
if (index >= decode_sigs.size())
|
||||
decoder_model->setDecoderStack(decode_sigs.at(0)->decoder());
|
||||
}
|
||||
- _model_proxy.setSourceModel(decoder_model);
|
||||
- search_done();
|
||||
resize_table_view(decoder_model);
|
||||
}
|
||||
|
||||
void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
|
||||
{
|
||||
if (decoder_model->getDecoderStack()) {
|
||||
- for (int i = 0; i < decoder_model->columnCount(QModelIndex()) - 1; i++) {
|
||||
+ for (int i = 0; i < _table_view->model()->columnCount(QModelIndex()) - 1; i++) {
|
||||
_table_view->resizeColumnToContents(i);
|
||||
if (_table_view->columnWidth(i) > 200)
|
||||
_table_view->setColumnWidth(i, 200);
|
||||
@@ -535,8 +651,11 @@ void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
|
||||
}
|
||||
}
|
||||
|
||||
-void ProtocolDock::item_clicked(const QModelIndex &index)
|
||||
+void ProtocolDock::item_clicked(const QModelIndex &table_index)
|
||||
{
|
||||
+ const QModelIndex index = table_index.model() == _match_model
|
||||
+ ? _match_model->sourceIndex(table_index.row()) : table_index;
|
||||
+ if (!index.isValid()) return;
|
||||
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
|
||||
auto decoder_stack = decoder_model->getDecoderStack();
|
||||
@@ -555,41 +674,16 @@ void ProtocolDock::item_clicked(const QModelIndex &index)
|
||||
}
|
||||
}
|
||||
|
||||
- _table_view->resizeRowToContents(index.row());
|
||||
- if (index.column() != _model_proxy.filterKeyColumn()) {
|
||||
- _model_proxy.setFilterKeyColumn(index.column());
|
||||
- _model_proxy.setSourceModel(decoder_model);
|
||||
- search_done();
|
||||
- }
|
||||
- QModelIndex filterIndex = _model_proxy.mapFromSource(index);
|
||||
- if (filterIndex.isValid()) {
|
||||
- _cur_search_index = filterIndex.row();
|
||||
- } else {
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _cur_search_index = -1;
|
||||
- } else {
|
||||
- uint64_t up = 0;
|
||||
- uint64_t dn = _model_proxy.rowCount() - 1;
|
||||
- do {
|
||||
- uint64_t md = (up + dn)/2;
|
||||
- QModelIndex curIndex = _model_proxy.mapToSource(_model_proxy.index(md,_model_proxy.filterKeyColumn()));
|
||||
- if (index.row() == curIndex.row()) {
|
||||
- _cur_search_index = md;
|
||||
- break;
|
||||
- } else if (md == up) {
|
||||
- if (curIndex.row() < index.row() && up < dn) {
|
||||
- QModelIndex nxtIndex = _model_proxy.mapToSource(_model_proxy.index(md+1,_model_proxy.filterKeyColumn()));
|
||||
- if (nxtIndex.row() < index.row())
|
||||
- md++;
|
||||
- }
|
||||
- _cur_search_index = md + ((curIndex.row() < index.row()) ? 0.5 : -0.5);
|
||||
- break;
|
||||
- } else if (curIndex.row() < index.row()) {
|
||||
- up = md;
|
||||
- } else if (curIndex.row() > index.row()) {
|
||||
- dn = md;
|
||||
- }
|
||||
- }while(1);
|
||||
+ _table_view->resizeRowToContents(table_index.row());
|
||||
+ // A table click must not silently restrict subsequent searches to this column.
|
||||
+ if (_search_index >= 0 && _search_index < int(_search_matches.size()) &&
|
||||
+ _search_matches[_search_index].index == index)
|
||||
+ return;
|
||||
+ _search_index = -1;
|
||||
+ for (size_t i = 0; i < _search_matches.size(); ++i) {
|
||||
+ if (_search_matches[i].index == index) {
|
||||
+ _search_index = int(i);
|
||||
+ break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +712,21 @@ void ProtocolDock::export_table_view()
|
||||
|
||||
void ProtocolDock::nav_table_view()
|
||||
{
|
||||
+ if (_table_view->model() == _match_model) {
|
||||
+ if (_search_running || _search_matches.empty()) return;
|
||||
+ auto stack = _session->get_decoder_model()->getDecoderStack();
|
||||
+ if (!stack) return;
|
||||
+ const uint64_t offset = _view.offset() * (stack->samplerate() * _view.scale());
|
||||
+ auto match = std::lower_bound(_search_matches.begin(), _search_matches.end(), offset,
|
||||
+ [](const SearchMatch &item, uint64_t sample) { return item.sample < sample; });
|
||||
+ _search_index = match == _search_matches.end() ? int(_search_matches.size()) - 1
|
||||
+ : int(match - _search_matches.begin());
|
||||
+ const QModelIndex index = _match_model->index(_search_index, 1);
|
||||
+ _table_view->setCurrentIndex(index);
|
||||
+ _table_view->scrollTo(index);
|
||||
+ item_clicked(index);
|
||||
+ return;
|
||||
+ }
|
||||
uint64_t row_index = 0;
|
||||
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
|
||||
@@ -625,7 +734,8 @@ void ProtocolDock::nav_table_view()
|
||||
if (decoder_stack) {
|
||||
uint64_t offset = _view.offset() * (decoder_stack->samplerate() * _view.scale());
|
||||
std::map<const pv::data::decode::Row, bool> rows = decoder_stack->get_rows_lshow();
|
||||
- int column = _model_proxy.filterKeyColumn();
|
||||
+ const int selected_column = qMax(0, _table_view->currentIndex().column());
|
||||
+ int column = selected_column;
|
||||
for (std::map<const pv::data::decode::Row, bool>::const_iterator i = rows.begin();
|
||||
i != rows.end(); i++) {
|
||||
if ((*i).second && column-- == 0) {
|
||||
@@ -633,7 +743,7 @@ void ProtocolDock::nav_table_view()
|
||||
break;
|
||||
}
|
||||
}
|
||||
- QModelIndex index = _model_proxy.mapToSource(_model_proxy.index(row_index, _model_proxy.filterKeyColumn()));
|
||||
+ QModelIndex index = decoder_model->index(row_index, selected_column);
|
||||
|
||||
if(index.isValid()){
|
||||
|
||||
@@ -660,196 +770,141 @@ void ProtocolDock::nav_table_view()
|
||||
|
||||
void ProtocolDock::search_pre()
|
||||
{
|
||||
- search_update();
|
||||
- // now the proxy only contains rows that match the name
|
||||
- // let's take the pre one and map it to the original model
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- return;
|
||||
- }
|
||||
- int i = 0;
|
||||
- uint64_t rowCount = _model_proxy.rowCount();
|
||||
- QModelIndex matchingIndex;
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
-
|
||||
- auto decoder_stack = decoder_model->getDecoderStack();
|
||||
- do {
|
||||
- _cur_search_index--;
|
||||
- if (_cur_search_index <= -1 || _cur_search_index >= _model_proxy.rowCount())
|
||||
- _cur_search_index = _model_proxy.rowCount() - 1;
|
||||
-
|
||||
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(ceil(_cur_search_index),_model_proxy.filterKeyColumn()));
|
||||
- if (!decoder_stack || !matchingIndex.isValid())
|
||||
- break;
|
||||
- i = 1;
|
||||
- uint64_t row = matchingIndex.row() + 1;
|
||||
- uint64_t col = matchingIndex.column();
|
||||
- pv::data::decode::Annotation ann;
|
||||
- bool ann_valid = false;
|
||||
-
|
||||
- while(i < _str_list.size()) {
|
||||
- QString nxt = _str_list.at(i);
|
||||
-
|
||||
- do {
|
||||
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
|
||||
- row++;
|
||||
- }
|
||||
- while(ann_valid && !ann.is_numberic());
|
||||
-
|
||||
- if (ann_valid){
|
||||
- QString source = ann.annotations().at(0);
|
||||
- if (source.contains(nxt))
|
||||
- i++;
|
||||
- else
|
||||
- break;
|
||||
- }
|
||||
- else{
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- while(i < _str_list.size() && --rowCount);
|
||||
-
|
||||
- if(i >= _str_list.size() && matchingIndex.isValid()){
|
||||
- _table_view->scrollTo(matchingIndex);
|
||||
- _table_view->setCurrentIndex(matchingIndex);
|
||||
- _table_view->clicked(matchingIndex);
|
||||
- } else {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- }
|
||||
+ navigate_search(-1);
|
||||
}
|
||||
|
||||
void ProtocolDock::search_nxt()
|
||||
{
|
||||
- search_update();
|
||||
- // now the proxy only contains rows that match the name
|
||||
- // let's take the pre one and map it to the original model
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
+ navigate_search(1);
|
||||
+}
|
||||
+
|
||||
+void ProtocolDock::navigate_search(int direction)
|
||||
+{
|
||||
+ if (_search_running) {
|
||||
+ // Finish the current query first; repeated presses do not restart it.
|
||||
+ _search_direction = direction;
|
||||
+ _search_timer->start(0);
|
||||
return;
|
||||
}
|
||||
-
|
||||
- int i = 0;
|
||||
- uint64_t rowCount = _model_proxy.rowCount();
|
||||
- QModelIndex matchingIndex;
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
- auto decoder_stack = decoder_model->getDecoderStack();
|
||||
-
|
||||
- if (decoder_stack == NULL){
|
||||
- dsv_err("decoder_stack is null");
|
||||
+ if (_search_matches.empty())
|
||||
return;
|
||||
- }
|
||||
-
|
||||
- do {
|
||||
- _cur_search_index++;
|
||||
- if (_cur_search_index < 0 || _cur_search_index >= _model_proxy.rowCount())
|
||||
- _cur_search_index = 0;
|
||||
|
||||
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(floor(_cur_search_index),_model_proxy.filterKeyColumn()));
|
||||
-
|
||||
- if (!matchingIndex.isValid())
|
||||
- break;
|
||||
-
|
||||
- i = 1;
|
||||
- uint64_t row = matchingIndex.row() + 1;
|
||||
- uint64_t col = matchingIndex.column();
|
||||
- pv::data::decode::Annotation ann;
|
||||
- bool ann_valid = false;
|
||||
-
|
||||
- while(i < _str_list.size()) {
|
||||
- QString nxt = _str_list.at(i);
|
||||
+ const int count = int(_search_matches.size());
|
||||
+ if (_search_index < 0)
|
||||
+ _search_index = direction > 0 ? 0 : count - 1;
|
||||
+ else
|
||||
+ _search_index = (_search_index + direction + count) % count;
|
||||
+ const QModelIndex index = _match_model->index(_search_index, 1);
|
||||
+ _table_view->setCurrentIndex(index);
|
||||
+ _table_view->scrollTo(index);
|
||||
+ item_clicked(index);
|
||||
+}
|
||||
|
||||
- do {
|
||||
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
|
||||
- row++;
|
||||
- }
|
||||
- while(ann_valid && !ann.is_numberic());
|
||||
-
|
||||
- if (ann_valid){
|
||||
- QString source = ann.annotations().at(0);
|
||||
- if (source.contains(nxt))
|
||||
- i++;
|
||||
- else
|
||||
- break;
|
||||
- }
|
||||
- else{
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- }while(i < _str_list.size() && --rowCount);
|
||||
-
|
||||
- if(i >= _str_list.size() && matchingIndex.isValid()){
|
||||
- _table_view->scrollTo(matchingIndex);
|
||||
- _table_view->setCurrentIndex(matchingIndex);
|
||||
- _table_view->clicked(matchingIndex);
|
||||
- } else {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- }
|
||||
+void ProtocolDock::invalidate_search()
|
||||
+{
|
||||
+ _search_timer->stop();
|
||||
+ _search_running = false;
|
||||
+ _search_direction = 0;
|
||||
+ _search_index = -1;
|
||||
+ _search_matches.clear();
|
||||
+ _match_model->setMatches({}, QString());
|
||||
+ _search_column_sizes.clear();
|
||||
+ _search_row = 0;
|
||||
+ _search_column = 0;
|
||||
+ _matchs_label->setText(QString::number(0));
|
||||
}
|
||||
|
||||
void ProtocolDock::search_done()
|
||||
{
|
||||
- QString str = _ann_search_edit->text().trimmed();
|
||||
- QRegularExpression rx("(-)");
|
||||
- _str_list = str.split(rx);
|
||||
- _model_proxy.setFilterFixedString(_str_list.first());
|
||||
- if (_str_list.size() > 1)
|
||||
- _matchs_label->setText("...");
|
||||
- else
|
||||
- _matchs_label->setText(QString::number(_model_proxy.rowCount()));
|
||||
+ const int pending_direction = _search_direction;
|
||||
+ invalidate_search();
|
||||
+ const QString text = _ann_search_edit->text().trimmed();
|
||||
+ auto decoder_model = _session->get_decoder_model();
|
||||
+ QAbstractItemModel *table_model = text.isEmpty()
|
||||
+ ? static_cast<QAbstractItemModel*>(decoder_model) : _match_model;
|
||||
+ if (_table_view->model() != table_model)
|
||||
+ _table_view->setModel(table_model);
|
||||
+ auto decoder_stack = decoder_model->getDecoderStack();
|
||||
+ if (text.isEmpty() || !decoder_stack)
|
||||
+ return;
|
||||
+
|
||||
+ // Only numeric sequences use '-'. Labels like CAN-FD and negative numbers
|
||||
+ // must remain literal text. Always match every waveform label variant.
|
||||
+ static const QRegularExpression sequence(
|
||||
+ "^[0-9A-Fa-f]+(?:\\s*-\\s*[0-9A-Fa-f]+)+$");
|
||||
+ _search_terms = sequence.match(text).hasMatch()
|
||||
+ ? text.split(QRegularExpression("\\s*-\\s*")) : QStringList{text};
|
||||
+ for (int col = 0; col < decoder_model->columnCount(QModelIndex()); ++col)
|
||||
+ _search_column_sizes.push_back(int(qMin<uint64_t>(
|
||||
+ decoder_stack->list_annotation_size(col), std::numeric_limits<int>::max())));
|
||||
+ _search_running = true;
|
||||
+ _search_direction = pending_direction;
|
||||
+ _matchs_label->setText("...");
|
||||
+ _search_timer->start(0);
|
||||
}
|
||||
|
||||
void ProtocolDock::search_changed()
|
||||
{
|
||||
- _search_edited = true;
|
||||
- _matchs_label->setText("...");
|
||||
+ // A new query cancels any pending navigation for the old text.
|
||||
+ _search_direction = 0;
|
||||
+ search_done();
|
||||
+ if (_search_running)
|
||||
+ _search_timer->start(180);
|
||||
}
|
||||
|
||||
-void ProtocolDock::search_update()
|
||||
+void ProtocolDock::search_step()
|
||||
{
|
||||
- if (!_search_edited)
|
||||
+ if (!_search_running)
|
||||
return;
|
||||
-
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
-
|
||||
+ auto decoder_model = _session->get_decoder_model();
|
||||
auto decoder_stack = decoder_model->getDecoderStack();
|
||||
- if (!decoder_stack)
|
||||
+ if (!decoder_stack) {
|
||||
+ invalidate_search();
|
||||
return;
|
||||
+ }
|
||||
|
||||
- if (decoder_stack->list_annotation_size(_model_proxy.filterKeyColumn()) > ProgressRows) {
|
||||
- QFuture<void> future;
|
||||
- future = QtConcurrent::run([&]{
|
||||
- search_done();
|
||||
- });
|
||||
- Qt::WindowFlags flags = Qt::CustomizeWindowHint;
|
||||
- QProgressDialog dlg(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCHING), "Searching..."),
|
||||
- L_S(STR_PAGE_DLG, S_ID(IDS_DLG_CANCEL), "Cancel"),0,0,this,flags);
|
||||
- dlg.setWindowModality(Qt::WindowModal);
|
||||
- dlg.setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint |
|
||||
- Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint);
|
||||
- dlg.setCancelButton(NULL);
|
||||
-
|
||||
- QFutureWatcher<void> watcher;
|
||||
- connect(&watcher,SIGNAL(finished()),&dlg,SLOT(cancel()));
|
||||
- watcher.setFuture(future);
|
||||
-
|
||||
- dlg.exec();
|
||||
- } else {
|
||||
- search_done();
|
||||
+ // Keep Qt models and widgets on the GUI thread, yielding between chunks.
|
||||
+ // Model resets stop this timer and discard indices before a decoder dies.
|
||||
+ QElapsedTimer elapsed;
|
||||
+ elapsed.start();
|
||||
+ while (_search_column < int(_search_column_sizes.size())) {
|
||||
+ if (_search_row >= _search_column_sizes[_search_column]) {
|
||||
+ ++_search_column;
|
||||
+ _search_row = 0;
|
||||
+ continue;
|
||||
+ }
|
||||
+ const int row = _search_row++;
|
||||
+ if (decoder_model->annotation_matches(_search_column, row, _search_terms)) {
|
||||
+ pv::data::decode::Annotation ann;
|
||||
+ if (decoder_stack->list_annotation(&ann, _search_column, row))
|
||||
+ _search_matches.push_back({decoder_model->index(row, _search_column),
|
||||
+ ann.start_sample()});
|
||||
+ }
|
||||
+ if (elapsed.elapsed() >= 8) {
|
||||
+ _matchs_label->setText(QString("%1...").arg(qulonglong(_search_matches.size())));
|
||||
+ _search_timer->start(1);
|
||||
+ return;
|
||||
+ }
|
||||
}
|
||||
- _search_edited = false;
|
||||
+
|
||||
+ // Result columns have independent row numbers. Navigate in time order.
|
||||
+ std::sort(_search_matches.begin(), _search_matches.end(),
|
||||
+ [](const SearchMatch &a, const SearchMatch &b) {
|
||||
+ if (a.sample != b.sample) return a.sample < b.sample;
|
||||
+ if (a.index.column() != b.index.column()) return a.index.column() < b.index.column();
|
||||
+ return a.index.row() < b.index.row();
|
||||
+ });
|
||||
+ _search_running = false;
|
||||
+ std::vector<QModelIndex> matches;
|
||||
+ matches.reserve(_search_matches.size());
|
||||
+ for (const auto &match : _search_matches) matches.push_back(match.index);
|
||||
+ _match_model->setMatches(matches, _search_terms.first());
|
||||
+ resize_table_view(decoder_model);
|
||||
+ _matchs_label->setText(QString::number(qulonglong(_search_matches.size())));
|
||||
+ const int direction = _search_direction;
|
||||
+ _search_direction = 0;
|
||||
+ if (direction)
|
||||
+ navigate_search(direction);
|
||||
}
|
||||
|
||||
//-------------------IProtocolItemLayerCallback
|
||||
@@ -1100,7 +1155,8 @@ void ProtocolDock::UpdateFont()
|
||||
QRect rc = fm.boundingRect(str);
|
||||
|
||||
int lineHeight = rc.height() + 15;
|
||||
- _pro_keyword_edit->setFixedHeight(rc.height() + 5);
|
||||
+ // Include the line edit's styled padding and border so glyphs are not clipped.
|
||||
+ _pro_keyword_edit->setFixedHeight(qMax(rc.height() + 5, _pro_keyword_edit->sizeHint().height()));
|
||||
int pannelHeight = lineHeight * _protocol_lay_items.size() + _pro_keyword_edit->height();
|
||||
|
||||
if (pannelHeight < 100){
|
||||
diff --git a/DSView/pv/dock/protocoldock.h b/DSView/pv/dock/protocoldock.h
|
||||
index 0da4c4cc..2bcceeb8 100644
|
||||
--- a/DSView/pv/dock/protocoldock.h
|
||||
+++ b/DSView/pv/dock/protocoldock.h
|
||||
@@ -33,9 +33,8 @@
|
||||
#include <QScrollArea>
|
||||
#include <QSplitter>
|
||||
#include <QTableView>
|
||||
-#include <QSortFilterProxyModel>
|
||||
+#include <QTimer>
|
||||
#include <vector>
|
||||
-#include <mutex>
|
||||
#include <list>
|
||||
#include "../data/decodermodel.h"
|
||||
#include "protocolitemlayer.h"
|
||||
@@ -75,14 +74,13 @@ public IUiWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
-public:
|
||||
- static const uint64_t ProgressRows = 100000;
|
||||
-
|
||||
public:
|
||||
ProtocolDock(QWidget *parent, view::View &view, SigSession *session);
|
||||
~ProtocolDock();
|
||||
|
||||
void del_all_protocol();
|
||||
+ bool add_host_decoder(const QString &id);
|
||||
+ void check_host_decoders();
|
||||
bool add_protocol_by_id(QString id, bool silent, std::list<pv::data::decode::Decoder*> &sub_decoders);
|
||||
|
||||
void reset_view();
|
||||
@@ -119,6 +117,8 @@ private:
|
||||
void UpdateFont() override;
|
||||
|
||||
void adjustPannelSize();
|
||||
+ void invalidate_search();
|
||||
+ void navigate_search(int direction);
|
||||
|
||||
signals:
|
||||
void protocol_updated();
|
||||
@@ -139,18 +139,29 @@ private slots:
|
||||
void search_nxt();
|
||||
void search_done();
|
||||
void search_changed();
|
||||
- void search_update();
|
||||
+ void search_step();
|
||||
void show_protocol_select();
|
||||
|
||||
private:
|
||||
SigSession *_session;
|
||||
view::View &_view;
|
||||
- QSortFilterProxyModel _model_proxy;
|
||||
- int _cur_search_index;
|
||||
- QStringList _str_list;
|
||||
+ struct SearchMatch {
|
||||
+ QModelIndex index;
|
||||
+ uint64_t sample;
|
||||
+ };
|
||||
+ std::vector<SearchMatch> _search_matches;
|
||||
+ std::vector<int> _search_column_sizes;
|
||||
+ QTimer *_search_timer;
|
||||
+ QStringList _search_terms;
|
||||
+ int _search_row = 0;
|
||||
+ int _search_column = 0;
|
||||
+ int _search_index = -1;
|
||||
+ int _search_direction = 0;
|
||||
+ bool _search_running = false;
|
||||
|
||||
QWidget *_top_panel;
|
||||
QTableView *_table_view;
|
||||
+ data::AnnotationMatchModel *_match_model;
|
||||
QPushButton *_pre_button;
|
||||
QPushButton *_nxt_button;
|
||||
PopupLineEdit *_ann_search_edit;
|
||||
@@ -172,8 +183,6 @@ private:
|
||||
QString _selected_protocol_id;
|
||||
XToolButton *_pro_search_button;
|
||||
|
||||
- mutable std::mutex _search_mutex;
|
||||
- bool _search_edited;
|
||||
};
|
||||
|
||||
} // namespace dock
|
||||
diff --git a/DSView/pv/mainframe.cpp b/DSView/pv/mainframe.cpp
|
||||
index 9f92d42b..f493c90e 100644
|
||||
--- a/DSView/pv/mainframe.cpp
|
||||
+++ b/DSView/pv/mainframe.cpp
|
||||
@@ -44,7 +44,8 @@
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QFont>
|
||||
-#include <algorithm>
|
||||
+#include <algorithm>
|
||||
+#include <cstdio>
|
||||
#include <QWindow>
|
||||
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
@@ -97,7 +98,8 @@ MainFrame::MainFrame()
|
||||
#ifdef _WIN32
|
||||
setWindowFlags(Qt::FramelessWindowHint);
|
||||
_is_win32_parent_window = true;
|
||||
- _taskBtn = NULL;
|
||||
+ _taskBtn = NULL;
|
||||
+ _taskPrg = NULL;
|
||||
isWin32 = true;
|
||||
#else
|
||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint);
|
||||
@@ -297,7 +299,7 @@ void MainFrame::resizeEvent(QResizeEvent *event)
|
||||
|
||||
void MainFrame::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
- writeSettings();
|
||||
+ if (!qApp->property("embeddedHost").toBool()) writeSettings();
|
||||
|
||||
if (_mainWindow->able_to_close()){
|
||||
|
||||
@@ -309,7 +311,8 @@ void MainFrame::closeEvent(QCloseEvent *event)
|
||||
}
|
||||
#endif
|
||||
|
||||
- event->accept();
|
||||
+ event->accept();
|
||||
+ if (qApp->property("embeddedHost").toBool()) qApp->quit();
|
||||
}
|
||||
else{
|
||||
event->ignore();
|
||||
@@ -645,7 +648,7 @@ void MainFrame::writeSettings()
|
||||
dsv_info("Save form, x:%d, y:%d, w:%d, h:%d", x, y, w, h);
|
||||
}
|
||||
|
||||
-void MainFrame::ShowFormInit()
|
||||
+void MainFrame::ShowFormInit()
|
||||
{
|
||||
ReadSettings();
|
||||
|
||||
@@ -697,9 +700,80 @@ void MainFrame::ShowFormInit()
|
||||
AttachNativeWindow();
|
||||
}
|
||||
#endif
|
||||
-}
|
||||
-
|
||||
-void MainFrame::AttachNativeWindow()
|
||||
+}
|
||||
+
|
||||
+#ifdef _WIN32
|
||||
+bool MainFrame::ShowEmbedded(quintptr parentId)
|
||||
+{
|
||||
+ const HWND parent = reinterpret_cast<HWND>(parentId);
|
||||
+ DWORD parentPid = 0;
|
||||
+ GetWindowThreadProcessId(parent, &parentPid);
|
||||
+ if (!parentPid || !IsWindow(parent)) return false;
|
||||
+ _titleBar->hide();
|
||||
+ _titleBar->EnableAbleDrag(false);
|
||||
+ setMinimumSize(0, 0);
|
||||
+ _mainWindow->setMinimumSize(0, 0);
|
||||
+ setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
|
||||
+ const HWND child = reinterpret_cast<HWND>(winId());
|
||||
+ SetWindowLongPtr(child, GWL_STYLE, WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS);
|
||||
+ SetWindowLongPtr(child, GWL_EXSTYLE, GetWindowLongPtr(child, GWL_EXSTYLE) & ~WS_EX_APPWINDOW);
|
||||
+ SetLastError(0);
|
||||
+ SetParent(child, parent);
|
||||
+ if (GetLastError() != 0 || GetParent(child) != parent) return false;
|
||||
+ _mainWindow->restore_dock();
|
||||
+ PopupDlgList::SetCurrentScreen(QGuiApplication::primaryScreen());
|
||||
+ QByteArray commandBuffer;
|
||||
+ auto fit = [this, parent, child, parentPid, commandBuffer]() mutable {
|
||||
+ DWORD currentPid = 0;
|
||||
+ GetWindowThreadProcessId(parent, ¤tPid);
|
||||
+ if (!IsWindow(parent) || currentPid != parentPid) {
|
||||
+ qApp->quit();
|
||||
+ return;
|
||||
+ }
|
||||
+ DWORD available = 0;
|
||||
+ const HANDLE input = GetStdHandle(STD_INPUT_HANDLE);
|
||||
+ if (PeekNamedPipe(input, NULL, 0, NULL, &available, NULL) && available) {
|
||||
+ char data[256];
|
||||
+ DWORD count = 0;
|
||||
+ if (ReadFile(input, data, qMin<DWORD>(available, sizeof(data)), &count, NULL)) {
|
||||
+ commandBuffer.append(data, count);
|
||||
+ int newline;
|
||||
+ while ((newline = commandBuffer.indexOf('\n')) >= 0) {
|
||||
+ const QByteArray command = commandBuffer.left(newline).trimmed();
|
||||
+ commandBuffer.remove(0, newline + 1);
|
||||
+ if (command == "CLOSE") close();
|
||||
+ else if (command.startsWith("ADD_DECODER "))
|
||||
+ _mainWindow->addHostDecoder(QString::fromUtf8(command.mid(12)));
|
||||
+ else if (command.startsWith("OPEN_FILE ")) {
|
||||
+ const QString path = QString::fromUtf8(QByteArray::fromBase64(command.mid(10)));
|
||||
+ printf("DSVIEW_FILE_RESULT %s\n", _mainWindow->openHostFile(path));
|
||||
+ fflush(stdout);
|
||||
+ }
|
||||
+ }
|
||||
+ if (commandBuffer.size() > 262144) commandBuffer.clear();
|
||||
+ }
|
||||
+ }
|
||||
+ RECT rect, childRect;
|
||||
+ GetClientRect(parent, &rect);
|
||||
+ GetWindowRect(child, &childRect);
|
||||
+ const int width = rect.right - rect.left;
|
||||
+ const int height = rect.bottom - rect.top;
|
||||
+ if (width > 0 && height > 0 &&
|
||||
+ (childRect.right - childRect.left != width || childRect.bottom - childRect.top != height))
|
||||
+ ::MoveWindow(child, 0, 0, width, height, TRUE);
|
||||
+ };
|
||||
+ QTimer *hostTimer = new QTimer(this);
|
||||
+ connect(hostTimer, &QTimer::timeout, this, fit);
|
||||
+ hostTimer->start(50);
|
||||
+ fit();
|
||||
+ QFrame::show();
|
||||
+ if (!qgetenv("DSVIEW_HOST_CHECK").isEmpty())
|
||||
+ QTimer::singleShot(3500, _mainWindow, &MainWindow::checkHostDecoders);
|
||||
+ return true;
|
||||
+}
|
||||
+#endif
|
||||
+
|
||||
+void MainFrame::AttachNativeWindow()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -1022,7 +1096,7 @@ void MainFrame::ReadSettings()
|
||||
void MainFrame::showEvent(QShowEvent *event)
|
||||
{
|
||||
// Taskbar Progress Effert for Win7 and Above
|
||||
- if (_taskBtn && _taskBtn->window() == NULL) {
|
||||
+ if (!qApp->property("embeddedHost").toBool() && _taskBtn && _taskBtn->window() == NULL) {
|
||||
_taskBtn->setWindow(windowHandle());
|
||||
_taskPrg = _taskBtn->progress();
|
||||
}
|
||||
@@ -1032,8 +1106,9 @@ void MainFrame::showEvent(QShowEvent *event)
|
||||
|
||||
void MainFrame::setTaskbarProgress(int progress)
|
||||
{
|
||||
-#ifdef _WIN32
|
||||
- if (progress > 0) {
|
||||
+#ifdef _WIN32
|
||||
+ if (!_taskPrg) return;
|
||||
+ if (progress > 0) {
|
||||
_taskPrg->setVisible(true);
|
||||
_taskPrg->setValue(progress);
|
||||
} else {
|
||||
@@ -1107,8 +1182,14 @@ QWidget* MainFrame::GetBodyView()
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
-bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
|
||||
-{
|
||||
+bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
|
||||
+{
|
||||
+ if (qApp->property("embeddedHost").toBool() &&
|
||||
+ static_cast<MSG*>(message)->message == WM_CLOSE) {
|
||||
+ QTimer::singleShot(0, this, [this]() { close(); });
|
||||
+ *result = 0;
|
||||
+ return true;
|
||||
+ }
|
||||
if (_parentNativeWidget != NULL)
|
||||
{
|
||||
MSG *msg = static_cast<MSG*>(message);
|
||||
diff --git a/DSView/pv/mainframe.h b/DSView/pv/mainframe.h
|
||||
index 0c2ec3ee..76f91faa 100644
|
||||
--- a/DSView/pv/mainframe.h
|
||||
+++ b/DSView/pv/mainframe.h
|
||||
@@ -99,7 +99,10 @@ public:
|
||||
public:
|
||||
MainFrame();
|
||||
|
||||
- void ShowFormInit();
|
||||
+ void ShowFormInit();
|
||||
+#ifdef _WIN32
|
||||
+ bool ShowEmbedded(quintptr parentId);
|
||||
+#endif
|
||||
void ShowHelpDocAsync();
|
||||
|
||||
bool IsMaxsized();
|
||||
diff --git a/DSView/pv/mainwindow.cpp b/DSView/pv/mainwindow.cpp
|
||||
index 74dc348b..c6800415 100644
|
||||
--- a/DSView/pv/mainwindow.cpp
|
||||
+++ b/DSView/pv/mainwindow.cpp
|
||||
@@ -22,12 +22,16 @@
|
||||
|
||||
#include <QAction>
|
||||
#include <QButtonGroup>
|
||||
-#include <QFileDialog>
|
||||
+#include <QFileDialog>
|
||||
+#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QStatusBar>
|
||||
-#include <QVBoxLayout>
|
||||
+#include <QVBoxLayout>
|
||||
+#include <QScrollArea>
|
||||
+#include <QScrollBar>
|
||||
+#include <QSplitter>
|
||||
#include <QWidget>
|
||||
#include <QDesktopServices>
|
||||
#include <QKeyEvent>
|
||||
@@ -108,8 +112,60 @@
|
||||
namespace pv
|
||||
{
|
||||
|
||||
- namespace{
|
||||
- QString tmp_file;
|
||||
+ namespace{
|
||||
+ QString tmp_file;
|
||||
+
|
||||
+ // Keep the instrument controls above the waveform, rather than above
|
||||
+ // the right-hand docks. Narrow hosts scroll instead of wrapping Help.
|
||||
+ class EmbeddedToolbars : public QScrollArea {
|
||||
+ public:
|
||||
+ EmbeddedToolbars(const QList<QToolBar*> &bars, QWidget *parent)
|
||||
+ : QScrollArea(parent), _bars(bars)
|
||||
+ {
|
||||
+ setFrameShape(QFrame::NoFrame);
|
||||
+ setWidgetResizable(true);
|
||||
+ setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||
+ auto panel = new QWidget(this);
|
||||
+ auto layout = new QHBoxLayout(panel);
|
||||
+ layout->setContentsMargins(0, 0, 0, 0);
|
||||
+ layout->setSpacing(0);
|
||||
+ layout->setSizeConstraint(QLayout::SetMinAndMaxSize);
|
||||
+ for (auto bar : _bars) {
|
||||
+ layout->addWidget(bar);
|
||||
+ bar->installEventFilter(this);
|
||||
+ }
|
||||
+ layout->addStretch(1);
|
||||
+ setWidget(panel);
|
||||
+ panel->installEventFilter(this);
|
||||
+ updateSize();
|
||||
+ }
|
||||
+ protected:
|
||||
+ bool eventFilter(QObject *object, QEvent *event) override {
|
||||
+ if (event->type() == QEvent::LayoutRequest)
|
||||
+ updateSize();
|
||||
+ return QScrollArea::eventFilter(object, event);
|
||||
+ }
|
||||
+ void resizeEvent(QResizeEvent *event) override {
|
||||
+ QScrollArea::resizeEvent(event);
|
||||
+ updateSize();
|
||||
+ }
|
||||
+ private:
|
||||
+ void updateSize() {
|
||||
+ int height = 0;
|
||||
+ int width = 0;
|
||||
+ for (auto bar : _bars) {
|
||||
+ const QSize hint = bar->sizeHint();
|
||||
+ bar->setMinimumSize(hint);
|
||||
+ width += hint.width();
|
||||
+ height = qMax(height, hint.height());
|
||||
+ }
|
||||
+ if (width > viewport()->width())
|
||||
+ height += horizontalScrollBar()->sizeHint().height();
|
||||
+ setFixedHeight(height);
|
||||
+ }
|
||||
+ QList<QToolBar*> _bars;
|
||||
+ };
|
||||
}
|
||||
|
||||
MainWindow::MainWindow(toolbars::TitleBar *title_bar, QWidget *parent)
|
||||
@@ -189,10 +245,15 @@ namespace pv
|
||||
|
||||
|
||||
setIconSize(QSize(40, 40));
|
||||
- addToolBar(_sampling_bar);
|
||||
- addToolBar(_trig_bar);
|
||||
- addToolBar(_file_bar);
|
||||
- addToolBar(_logo_bar);
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ _vertical_layout->insertWidget(0, new EmbeddedToolbars(
|
||||
+ {_sampling_bar, _trig_bar, _file_bar, _logo_bar}, _central_widget));
|
||||
+ } else {
|
||||
+ addToolBar(_sampling_bar);
|
||||
+ addToolBar(_trig_bar);
|
||||
+ addToolBar(_file_bar);
|
||||
+ addToolBar(_logo_bar);
|
||||
+ }
|
||||
|
||||
// Setup the dockWidget
|
||||
_protocol_dock = new QDockWidget(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_DOCK_TITLE), "Decode Protocol"), this);
|
||||
@@ -200,8 +261,43 @@ namespace pv
|
||||
_protocol_dock->setFeatures(QDockWidget::DockWidgetMovable);
|
||||
_protocol_dock->setAllowedAreas(Qt::RightDockWidgetArea);
|
||||
_protocol_dock->setVisible(false);
|
||||
- _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
|
||||
- _protocol_dock->setWidget(_protocol_widget);
|
||||
+ _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ // The dock occupies the right column, but its visible panel can be
|
||||
+ // shortened from the top. Move the title with the whole panel.
|
||||
+ _protocol_dock->setTitleBarWidget(new QWidget(_protocol_dock));
|
||||
+ _protocol_dock->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||||
+ auto outer = new QSplitter(Qt::Vertical, _protocol_dock);
|
||||
+ outer->setObjectName("decoderOuterSplit");
|
||||
+ auto space = new QWidget(outer);
|
||||
+ space->setMinimumHeight(0);
|
||||
+ auto panel = new QWidget(outer);
|
||||
+ auto panel_layout = new QVBoxLayout(panel);
|
||||
+ panel_layout->setContentsMargins(0, 0, 0, 0);
|
||||
+ panel_layout->setSpacing(0);
|
||||
+ auto title = new QLabel(_protocol_dock->windowTitle(), panel);
|
||||
+ title->setStyleSheet("background: #151d28; padding: 5px 10px;");
|
||||
+ connect(_protocol_dock, &QWidget::windowTitleChanged, title, &QLabel::setText);
|
||||
+ panel_layout->addWidget(title);
|
||||
+ panel_layout->addWidget(_protocol_widget, 1);
|
||||
+ outer->addWidget(space);
|
||||
+ outer->addWidget(panel);
|
||||
+ outer->setCollapsible(0, true);
|
||||
+ outer->setCollapsible(1, false);
|
||||
+ outer->setHandleWidth(10);
|
||||
+ outer->setStretchFactor(0, 0);
|
||||
+ outer->setStretchFactor(1, 1);
|
||||
+ outer->setSizes({0, 600});
|
||||
+ outer->handle(1)->setCursor(Qt::SizeVerCursor);
|
||||
+ outer->handle(1)->setToolTip(tr("Drag the top edge to resize the entire Decoders panel"));
|
||||
+ outer->setStyleSheet(
|
||||
+ "QSplitter#decoderOuterSplit::handle:vertical { background: #243247; "
|
||||
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
|
||||
+ "QSplitter#decoderOuterSplit::handle:vertical:hover { background: #1570d8; }");
|
||||
+ _protocol_dock->setWidget(outer);
|
||||
+ } else {
|
||||
+ _protocol_dock->setWidget(_protocol_widget);
|
||||
+ }
|
||||
|
||||
_session->set_decoder_pannel(_protocol_widget);
|
||||
|
||||
@@ -479,7 +575,38 @@ namespace pv
|
||||
return true;
|
||||
}
|
||||
|
||||
- void MainWindow::on_protocol(bool visible)
|
||||
+ const char *MainWindow::openHostFile(const QString &path)
|
||||
+ {
|
||||
+ if (_session->is_working() || _session->is_saving() || QApplication::activeModalWidget())
|
||||
+ return "busy";
|
||||
+ if (!QFileInfo(path).isFile() || QFileInfo(path).suffix().compare("dsl", Qt::CaseInsensitive))
|
||||
+ return "error";
|
||||
+ if (confirm_to_store_data()) {
|
||||
+ on_save();
|
||||
+ return "save";
|
||||
+ }
|
||||
+ if (_session->is_working() || _session->is_saving()) return "busy";
|
||||
+ if (_device_agent->is_hardware()) save_config();
|
||||
+ try {
|
||||
+ return _session->set_file(path) ? "ok" : "error";
|
||||
+ } catch (...) {
|
||||
+ return "error";
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::addHostDecoder(const QString &id)
|
||||
+ {
|
||||
+ on_protocol(true);
|
||||
+ _protocol_widget->add_host_decoder(id);
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::checkHostDecoders()
|
||||
+ {
|
||||
+ on_protocol(true);
|
||||
+ _protocol_widget->check_host_decoders();
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::on_protocol(bool visible)
|
||||
{
|
||||
_protocol_dock->setVisible(visible);
|
||||
|
||||
@@ -1414,8 +1541,9 @@ namespace pv
|
||||
_session->update_lang_text();
|
||||
}
|
||||
|
||||
- void MainWindow::switchTheme(QString style)
|
||||
- {
|
||||
+ void MainWindow::switchTheme(QString style)
|
||||
+ {
|
||||
+ if (qApp->property("embeddedHost").toBool()) style = "dark";
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
|
||||
if (app.frameOptions.style != style)
|
||||
@@ -1427,7 +1555,25 @@ namespace pv
|
||||
QString qssRes = ":/" + style + ".qss";
|
||||
QFile qss(qssRes);
|
||||
qss.open(QFile::ReadOnly | QFile::Text);
|
||||
- qApp->setStyleSheet(qss.readAll());
|
||||
+ QString stylesheet = QString::fromUtf8(qss.readAll());
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ QFile hostStyle(QString::fromUtf8(qgetenv("DSVIEW_HOST_STYLE")));
|
||||
+ if (hostStyle.open(QFile::ReadOnly | QFile::Text))
|
||||
+ stylesheet += QString::fromUtf8(hostStyle.readAll());
|
||||
+ QPalette palette;
|
||||
+ palette.setColor(QPalette::Window, QColor("#10151d"));
|
||||
+ palette.setColor(QPalette::Base, QColor("#111c29"));
|
||||
+ palette.setColor(QPalette::AlternateBase, QColor("#151d28"));
|
||||
+ palette.setColor(QPalette::WindowText, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Text, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Button, QColor("#243247"));
|
||||
+ palette.setColor(QPalette::ButtonText, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Highlight, QColor("#1570d8"));
|
||||
+ palette.setColor(QPalette::HighlightedText, Qt::white);
|
||||
+ qApp->setPalette(palette);
|
||||
+ qApp->setFont(QFont("Segoe UI", 10));
|
||||
+ }
|
||||
+ qApp->setStyleSheet(stylesheet);
|
||||
qss.close();
|
||||
|
||||
UiManager::Instance()->Update(UI_UPDATE_ACTION_THEME);
|
||||
diff --git a/DSView/pv/mainwindow.h b/DSView/pv/mainwindow.h
|
||||
index a43e0427..8212fe14 100644
|
||||
--- a/DSView/pv/mainwindow.h
|
||||
+++ b/DSView/pv/mainwindow.h
|
||||
@@ -93,7 +93,10 @@ public:
|
||||
public:
|
||||
explicit MainWindow(toolbars::TitleBar *title_bar, QWidget *parent = 0);
|
||||
|
||||
- void openDoc();
|
||||
+ void openDoc();
|
||||
+ void addHostDecoder(const QString &id);
|
||||
+ const char *openHostFile(const QString &path);
|
||||
+ void checkHostDecoders();
|
||||
|
||||
public slots:
|
||||
void switchTheme(QString style);
|
||||
diff --git a/DSView/pv/toolbars/filebar.cpp b/DSView/pv/toolbars/filebar.cpp
|
||||
index f562431c..792c6971 100644
|
||||
--- a/DSView/pv/toolbars/filebar.cpp
|
||||
+++ b/DSView/pv/toolbars/filebar.cpp
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <QFileDialog>
|
||||
#include <deque>
|
||||
#include <QApplication>
|
||||
+#include <cstdio>
|
||||
|
||||
#include "filebar.h"
|
||||
#include "../ui/msgbox.h"
|
||||
@@ -146,7 +147,9 @@ void FileBar::on_actionOpen_triggered()
|
||||
this,
|
||||
L_S(STR_PAGE_DLG, S_ID(IDS_DLG_OPEN_FILE), "Open File"),
|
||||
app.userHistory.openDir,
|
||||
- "DSView Data (*.dsl)");
|
||||
+ qApp->property("embeddedHost").toBool()
|
||||
+ ? "Digital captures (*.dsl *.csv);;DSView Data (*.dsl);;CSV (*.csv)"
|
||||
+ : "DSView Data (*.dsl)");
|
||||
|
||||
if (!file_name.isEmpty()) {
|
||||
QString fname = path::GetDirectoryName(file_name);
|
||||
@@ -155,7 +158,12 @@ void FileBar::on_actionOpen_triggered()
|
||||
app.SaveHistory();
|
||||
}
|
||||
|
||||
- sig_load_file(file_name);
|
||||
+ if (qApp->property("embeddedHost").toBool() && file_name.endsWith(".csv", Qt::CaseInsensitive)) {
|
||||
+ printf("DSVIEW_IMPORT_CSV %s\n", file_name.toUtf8().toBase64().constData());
|
||||
+ fflush(stdout);
|
||||
+ } else {
|
||||
+ sig_load_file(file_name);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/DSView/pv/toolbars/logobar.cpp b/DSView/pv/toolbars/logobar.cpp
|
||||
index 4b3b492c..95c28b49 100644
|
||||
--- a/DSView/pv/toolbars/logobar.cpp
|
||||
+++ b/DSView/pv/toolbars/logobar.cpp
|
||||
@@ -106,13 +106,17 @@ LogoBar::LogoBar(SigSession *session, QWidget *parent) :
|
||||
_logo_button.setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||
_logo_button.setPopupMode(QToolButton::InstantPopup);
|
||||
|
||||
- QWidget *spacer = new QWidget(this);
|
||||
- spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
- addWidget(spacer);
|
||||
+ if (!qApp->property("embeddedHost").toBool()) {
|
||||
+ QWidget *spacer = new QWidget(this);
|
||||
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
+ addWidget(spacer);
|
||||
+ }
|
||||
addWidget(&_logo_button);
|
||||
- QWidget *margin = new QWidget(this);
|
||||
- margin->setMinimumWidth(20);
|
||||
- addWidget(margin);
|
||||
+ if (!qApp->property("embeddedHost").toBool()) {
|
||||
+ QWidget *margin = new QWidget(this);
|
||||
+ margin->setMinimumWidth(20);
|
||||
+ addWidget(margin);
|
||||
+ }
|
||||
|
||||
connect(_action_en, SIGNAL(triggered()), this, SLOT(on_actionEn_triggered()));
|
||||
connect(_action_cn, SIGNAL(triggered()), this, SLOT(on_actionCn_triggered()));
|
||||
24
tools/dsview/tests/gate_summary_test.cpp
Normal file
24
tools/dsview/tests/gate_summary_test.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include "gate_summary.h"
|
||||
#include <cassert>
|
||||
int main() {
|
||||
GateSummary summary;
|
||||
assert(summary.severity(true) == 1); // Empty captures are not OK.
|
||||
summary.observe(0); summary.observe(1); summary.observe(2);
|
||||
assert(summary.severity(false) == 1);
|
||||
assert(summary.severity(true) == 0);
|
||||
summary.observe(0); // A command without a complete ACK at EOF.
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(2);
|
||||
assert(summary.severity(true) == 0);
|
||||
summary.observe(3);
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(4);
|
||||
assert(summary.severity(false) == 2);
|
||||
assert(summary.severity(true) == 2);
|
||||
summary.observe(2); // Later good events never hide earlier faults.
|
||||
assert(summary.severity(true) == 2);
|
||||
summary.reset();
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(5);
|
||||
assert(summary.severity(true) == 2);
|
||||
}
|
||||
438
tools/dsview/tests/test_decoders.py
Normal file
438
tools/dsview/tests/test_decoders.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Wire vectors and DSView API boundary tests; no sigrok installation needed."""
|
||||
import binascii
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import types
|
||||
import tempfile
|
||||
import importlib.util
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('dsview_build', ROOT / 'build.py')
|
||||
BUILD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(BUILD)
|
||||
RUNTIME = tempfile.TemporaryDirectory()
|
||||
BUILD.package_decoders(Path(RUNTIME.name))
|
||||
(Path(RUNTIME.name) / 'decoders/common/__init__.py').write_text('')
|
||||
|
||||
|
||||
class FakeDecoder:
|
||||
def has_channel(self, channel):
|
||||
return channel < 2
|
||||
|
||||
def register(self, kind):
|
||||
return kind
|
||||
|
||||
def put(self, ss, es, output, data):
|
||||
self.emitted.append((ss, es, output, data))
|
||||
|
||||
|
||||
stub = types.ModuleType('sigrokdecode')
|
||||
stub.Decoder = FakeDecoder
|
||||
stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = 0, 1, 2
|
||||
previous = sys.modules.get('sigrokdecode')
|
||||
sys.modules['sigrokdecode'] = stub
|
||||
sys.path.insert(0, str(Path(RUNTIME.name) / 'decoders'))
|
||||
try:
|
||||
uart = importlib.import_module('set_uart.pd')
|
||||
wire = importlib.import_module('common.setgui_decoders.set_uart')
|
||||
can = importlib.import_module('set_can.pd')
|
||||
cancore = importlib.import_module('common.setgui_decoders.set_can')
|
||||
pm = importlib.import_module('pm35_uart.pd')
|
||||
gate = importlib.import_module('gate_driver_timing.pd')
|
||||
pair_decoder = importlib.import_module('transistor_pair.pd')
|
||||
pmcore = importlib.import_module('common.setgui_decoders.pm35_uart')
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if previous is None:
|
||||
del sys.modules['sigrokdecode']
|
||||
else:
|
||||
sys.modules['sigrokdecode'] = previous
|
||||
|
||||
PING = bytes.fromhex('A5 5A 02 08 01 00 00 00 2A 00 34 12 00 00 33 EC 33 04')
|
||||
BRIDGE = bytes.fromhex('AA 55 08 01 01 67 45 23 01 AA BB FE 14')
|
||||
|
||||
|
||||
def crc32_frame(header, payload=b''):
|
||||
data = header + payload
|
||||
return data + struct.pack('<I', binascii.crc32(data[2:]) & 0xffffffff)
|
||||
|
||||
|
||||
def modbus(raw):
|
||||
return raw + struct.pack('<H', wire.crc16(raw, True))
|
||||
|
||||
|
||||
def feed(parser, raw):
|
||||
events = []
|
||||
for i, byte in enumerate(raw):
|
||||
events.extend(parser.feed(byte, i * 10, i * 10 + 9))
|
||||
return events
|
||||
|
||||
|
||||
def instance(module, **options):
|
||||
decoder = module.Decoder()
|
||||
decoder.options = {item['id']: item['default'] for item in decoder.options}
|
||||
decoder.options.update(options)
|
||||
decoder.emitted = []
|
||||
decoder.metadata(2, 1000000)
|
||||
decoder.start()
|
||||
return decoder
|
||||
|
||||
|
||||
def uart_bytes(decoder, raw, direction=0, base=0):
|
||||
for i, byte in enumerate(raw):
|
||||
ss = base + i * 100
|
||||
decoder.decode(ss, ss+80, ('DATA', direction, (byte, [])))
|
||||
decoder.decode(ss, ss+90, ('FRAME', direction, (byte, True)))
|
||||
|
||||
|
||||
def frames(decoder):
|
||||
return [item[3][1] for item in decoder.emitted if item[2] == 1]
|
||||
|
||||
|
||||
class WireTests(unittest.TestCase):
|
||||
def test_reference_crc(self):
|
||||
self.assertEqual(wire.crc16(b'123456789'), 0x29b1)
|
||||
self.assertEqual(wire.crc16(b'123456789', True), 0x4b37)
|
||||
|
||||
def test_documented_ping(self):
|
||||
result = wire.parse_frame(PING)
|
||||
self.assertEqual((result['destination'], result['sequence']), (42, 0x1234))
|
||||
|
||||
def test_documented_bridge(self):
|
||||
result = wire.parse_frame(BRIDGE)
|
||||
self.assertEqual((result['can_id'], result['payload']), (0x1234567, b'\xaa\xbb'))
|
||||
|
||||
def test_v1_big_endian_header(self):
|
||||
frame = crc32_frame(bytes.fromhex('a55a010c12340003'), b'abc')
|
||||
result = wire.parse_frame(frame)
|
||||
self.assertEqual(result['sequence'], 0x1234)
|
||||
self.assertIn('FIRMWARE_DATA', result['summary'])
|
||||
|
||||
def test_noise_crc_and_resync(self):
|
||||
damaged = bytearray(PING)
|
||||
damaged[-1] ^= 1
|
||||
events = feed(wire.StreamParser(), b'noise\xa5' + damaged + PING + BRIDGE)
|
||||
self.assertEqual(sum(e[2] is not None for e in events), 2)
|
||||
self.assertTrue(any(e[3] and 'CRC' in e[3] for e in events))
|
||||
|
||||
def test_bad_version_and_length(self):
|
||||
events = feed(wire.StreamParser(), bytes.fromhex('a55aff a55a010100000201') + PING)
|
||||
self.assertEqual(sum(e[2] is not None for e in events), 1)
|
||||
self.assertGreaterEqual(sum(bool(e[3]) for e in events), 2)
|
||||
|
||||
def test_max_payload(self):
|
||||
raw = crc32_frame(bytes.fromhex('a55a020009000000000001000002'), bytes(range(256))*2)
|
||||
events = feed(wire.StreamParser(), raw)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(len(events[0][2]['payload']), 512)
|
||||
|
||||
def test_unknown_message_preserved(self):
|
||||
raw = crc32_frame(bytes.fromhex('a55a020034120000000001000000'))
|
||||
self.assertEqual(wire.parse_frame(raw)['message_type'], 0x1234)
|
||||
|
||||
def test_uart_directions_are_independent(self):
|
||||
decoder = instance(uart)
|
||||
for i, byte in enumerate(PING):
|
||||
decoder.decode(i*10, i*10+9, ('FRAME', 0, (byte, True)))
|
||||
decoder.decode(i*10, i*10+9, ('FRAME', 1, (byte, True)))
|
||||
self.assertEqual([f['direction'] for f in frames(decoder)], [0, 1])
|
||||
|
||||
def test_uart_invalid_last_byte_not_emitted(self):
|
||||
decoder = instance(uart)
|
||||
uart_bytes(decoder, PING[:-1])
|
||||
decoder.decode(2000, 2090, ('FRAME', 0, (PING[-1], False)))
|
||||
self.assertFalse(frames(decoder))
|
||||
uart_bytes(decoder, PING, base=3000)
|
||||
self.assertEqual(len(frames(decoder)), 1)
|
||||
|
||||
def test_uart_gap_discards_partial(self):
|
||||
decoder = instance(uart)
|
||||
uart_bytes(decoder, PING[:10])
|
||||
uart_bytes(decoder, PING, base=200000)
|
||||
self.assertEqual(len(frames(decoder)), 1)
|
||||
|
||||
|
||||
class CanTests(unittest.TestCase):
|
||||
def segments(self, packet=PING):
|
||||
yield bytes([0x10]) + struct.pack('<H', len(packet)) + packet[:5]
|
||||
for i, offset in enumerate(range(5, len(packet), 7), 1):
|
||||
yield bytes([0x20 | (i & 15)]) + packet[offset:offset+7]
|
||||
|
||||
def test_set_reassembly(self):
|
||||
decoder = instance(can, protocol='set-v2')
|
||||
for i, data in enumerate(self.segments()):
|
||||
decoder.decode(i*1000, i*1000+900, ('extended', 0x122a0000, 'data', len(data), list(data)))
|
||||
self.assertEqual(frames(decoder)[-1]['raw'], PING)
|
||||
|
||||
def test_missing_segment(self):
|
||||
reader = cancore.Reassembler()
|
||||
parts = list(self.segments())
|
||||
reader.feed(0x122a0000, parts[0], 0, 9, 0)
|
||||
events = reader.feed(0x122a0000, parts[2], 10, 19, 1)
|
||||
self.assertIn('sequence', events[-1][3])
|
||||
self.assertFalse(reader.pending)
|
||||
|
||||
def test_timeout_and_new_first(self):
|
||||
reader = cancore.Reassembler()
|
||||
first = next(self.segments())
|
||||
reader.feed(0x122a0000, first, 0, 9, 0)
|
||||
events = reader.feed(0x122a0000, first, 20, 29, 500)
|
||||
self.assertIn('timeout', events[0][3])
|
||||
self.assertEqual(reader.pending[0x122a0000]['start'], 20)
|
||||
|
||||
def test_id_mismatch(self):
|
||||
reader = cancore.Reassembler()
|
||||
for i, part in enumerate(self.segments()):
|
||||
events = reader.feed(0x122b0000, part, i*10, i*10+9, i)
|
||||
self.assertIn('disagree', events[-1][3])
|
||||
|
||||
def test_sequence_wrap(self):
|
||||
packet = crc32_frame(bytes.fromhex('a55a0200010000002a0001008000'), bytes(128))
|
||||
reader = cancore.Reassembler()
|
||||
for i, part in enumerate(self.segments(packet)):
|
||||
events = reader.feed(0x122a0000, part, i*10, i*10+9, i)
|
||||
self.assertEqual(events[-1][2]['raw'], packet)
|
||||
|
||||
def test_interleaved_channels(self):
|
||||
reader = cancore.Reassembler()
|
||||
complete = []
|
||||
for i, part in enumerate(self.segments()):
|
||||
for channel in (0, 1):
|
||||
complete += reader.feed(0x122a0000 | channel, part, i*10, i*10+9, i)
|
||||
self.assertEqual(sum(bool(e[2] and 'raw' in e[2]) for e in complete), 2)
|
||||
|
||||
def test_gas(self):
|
||||
result = cancore.legacy(0x1b530010, b'\x34\x12\xfe\xff')
|
||||
self.assertEqual(result['registers'], [(16, 0x1234), (17, 0xfffe)])
|
||||
with self.assertRaises(ValueError):
|
||||
cancore.legacy(0x1b530010, b'\x00')
|
||||
|
||||
def test_balsam_mask_and_endianness(self):
|
||||
result = cancore.legacy(0xba0010, bytes.fromhex('a01012340000ffff'), 'balsam')
|
||||
self.assertEqual(result['registers'], [(16, 0x1234), (18, 0xffff)])
|
||||
self.assertIsNone(cancore.legacy(0xba000f, bytes(8), 'balsam'))
|
||||
|
||||
def test_ignore_standard_and_remote(self):
|
||||
decoder = instance(can)
|
||||
decoder.decode(0, 9, ('standard', 0x123, 'data', 0, []))
|
||||
decoder.decode(10, 19, ('extended', 0x1b530010, 'remote', 0, []))
|
||||
self.assertFalse(decoder.emitted)
|
||||
|
||||
def test_boot_vectors(self):
|
||||
self.assertIn('ENTER_BOOT', cancore.legacy(0x13590702, b'')['summary'])
|
||||
self.assertIn('offset=0x8', cancore.legacy(0x135b0001, bytes.fromhex('1011121314151617'))['summary'])
|
||||
self.assertIn('status=0', cancore.legacy(0x1b5c0702, bytes.fromhex('00ff000000000000'))['summary'])
|
||||
|
||||
|
||||
class PM35Tests(unittest.TestCase):
|
||||
def test_single_wire_auto(self):
|
||||
reader = pmcore.PM35Parser('auto')
|
||||
raw = modbus(bytes.fromhex('100300000002')) + modbus(bytes.fromhex('1003041234ffff'))
|
||||
events = feed(reader, raw)
|
||||
self.assertEqual(len(events), 2)
|
||||
self.assertTrue(all(event[2] for event in events))
|
||||
|
||||
def test_request_and_response(self):
|
||||
request = modbus(bytes.fromhex('100300000002'))
|
||||
response = modbus(bytes.fromhex('1003041234ffff'))
|
||||
decoder = instance(pm)
|
||||
uart_bytes(decoder, request, 1)
|
||||
uart_bytes(decoder, response, 0, 10000)
|
||||
self.assertEqual(len(frames(decoder)), 2)
|
||||
self.assertEqual(frames(decoder)[1]['values'], [0x1234, 65535])
|
||||
|
||||
def test_128_word_legacy_reply(self):
|
||||
events = feed(pmcore.PM35Parser('response'), modbus(b'\x10\x03\x00' + bytes(256)))
|
||||
self.assertEqual(len(events[0][2]['values']), 128)
|
||||
|
||||
def test_write_command(self):
|
||||
result = pmcore.parse_pm35(modbus(bytes.fromhex('1006007f8000')), 'request')
|
||||
self.assertIn('command_bits=8000', result['summary'])
|
||||
|
||||
def test_exception(self):
|
||||
result = pmcore.parse_pm35(modbus(bytes.fromhex('108302')), 'response')
|
||||
self.assertIn('exception', result['summary'])
|
||||
|
||||
def test_crc_and_range_errors(self):
|
||||
with self.assertRaisesRegex(ValueError, 'CRC'):
|
||||
pmcore.parse_pm35(bytes.fromhex('1003000000010000'), 'request')
|
||||
with self.assertRaisesRegex(ValueError, 'range'):
|
||||
pmcore.parse_pm35(modbus(bytes.fromhex('1003007f0002')), 'request')
|
||||
|
||||
|
||||
class GateAdapterTests(unittest.TestCase):
|
||||
def test_wait_releases_dsview_shared_pin_tuple(self):
|
||||
for channels, pair in (((1,), 0), ((0, 1), 0), ((0, 1), 1)):
|
||||
with self.subTest(channels=channels, pair=pair):
|
||||
decoder = instance(pair_decoder if pair else gate)
|
||||
decoder.has_channel = lambda channel: channel in channels
|
||||
shared_pins = tuple([0, 0])
|
||||
calls = []
|
||||
|
||||
def wait(conditions):
|
||||
# Native DSView owns one reference and PyTuple_SetItem
|
||||
# rejects any reference retained by the previous caller.
|
||||
self.assertEqual(sys.getrefcount(shared_pins), 2)
|
||||
if len(calls) == 3:
|
||||
raise StopIteration
|
||||
calls.append(conditions)
|
||||
decoder.samplenum = len(calls) * 10
|
||||
decoder.matched = 0
|
||||
return shared_pins
|
||||
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_orphan_filter_boundaries_profiles_and_polarities(self):
|
||||
for profile in ('1SP0635', '1SD536F2'):
|
||||
for active_high in (True, False):
|
||||
with self.subTest(profile=profile, active_high=active_high):
|
||||
checker = gate.TimingChecker(1_000_000_000, profile=profile,
|
||||
vstat_active_high=active_high, orphan_min_width_ns=50)
|
||||
for start, width, expected in ((100, 20, []), (200, 49, []),
|
||||
(300, 50, ['orphan']), (400, 51, ['orphan'])):
|
||||
self.assertEqual(checker.on_status_edge(start, int(active_high)), [])
|
||||
events = checker.on_status_edge(start + width, int(not active_high))
|
||||
self.assertEqual([event['kind'] for event in events], expected)
|
||||
self.assertIsNone(checker.status_start)
|
||||
|
||||
def test_orphan_filter_preserves_ack_errors_faults_and_missing_ack(self):
|
||||
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=10_000)
|
||||
checker.on_control_edge(0, 1)
|
||||
self.assertEqual(checker.on_status_edge(250, 1)[0]['kind'], 'delay_ok')
|
||||
self.assertEqual(checker.on_status_edge(950, 0)[0]['kind'], 'width_ok')
|
||||
checker.on_control_edge(1000, 0)
|
||||
checker.on_status_edge(1250, 1)
|
||||
self.assertEqual(checker.on_status_edge(1270, 0)[0]['kind'], 'width_fail')
|
||||
checker.on_status_edge(2000, 1)
|
||||
self.assertEqual(checker.on_status_edge(4000, 0)[0]['kind'], 'fault')
|
||||
checker.on_control_edge(5000, 1)
|
||||
self.assertEqual(checker.expire(checker.next_deadline())[0]['kind'], 'missing')
|
||||
|
||||
def test_orphan_filter_default_disabled_and_unknown_duration_retained(self):
|
||||
checker = gate.TimingChecker(1_000_000_000)
|
||||
checker.on_status_edge(10, 1)
|
||||
self.assertEqual(checker.on_status_edge(30, 0)[0]['short'], 'ORPHAN 20.0 ns')
|
||||
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=50)
|
||||
# A recording starting within a pulse gives no measurable pulse width.
|
||||
self.assertEqual(checker.on_status_edge(10, 0)[0]['short'], 'Vstat?')
|
||||
|
||||
def test_orphan_filter_rejects_invalid_thresholds(self):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'ORPHAN'):
|
||||
gate.TimingChecker(100_000_000, orphan_min_width_ns=value)
|
||||
|
||||
def test_orphan_filter_option_through_native_edge_api(self):
|
||||
decoder = instance(gate, orphan_min_width_ns=50)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
samples = iter([(10, 1), (12, 0), (20, 1), (25, 0)])
|
||||
def wait(conditions):
|
||||
decoder.samplenum, level = next(samples)
|
||||
decoder.matched = 2
|
||||
return 0, level
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(decoder.emitted), 1)
|
||||
self.assertEqual(decoder.emitted[0][:2], (20, 25))
|
||||
self.assertEqual(decoder.emitted[0][3][1][1], 'ORPHAN 50.0 ns')
|
||||
|
||||
def test_profiles_through_native_edge_api(self):
|
||||
for profile, active, edges, expected in [
|
||||
('1SP0635', 'high', [(100, 1, 0, 1), (125, 1, 1, 2), (195, 1, 0, 2)], 700),
|
||||
('1SD536F2', 'low', [(20, 1, 1, 1), (58, 1, 0, 2), (148, 1, 1, 2)], 900),
|
||||
]:
|
||||
with self.subTest(profile=profile):
|
||||
decoder = instance(gate, profile=profile, vstat_active=active)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
samples = iter(edges)
|
||||
def wait(conditions):
|
||||
decoder.samplenum, vin, vstat, decoder.matched = next(samples)
|
||||
return vin, vstat
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 1, 2])
|
||||
self.assertIn(str(expected), decoder.emitted[-1][3][1][0])
|
||||
|
||||
def test_driver_has_no_pair_controls(self):
|
||||
decoder = instance(gate)
|
||||
self.assertNotIn('pair_analysis', decoder.options)
|
||||
self.assertNotIn('vin2_mintime_ns', decoder.options)
|
||||
self.assertEqual([c['id'] for c in gate.Decoder.optional_channels], ['vin', 'vstat'])
|
||||
|
||||
def test_pair_decoder_without_vstat(self):
|
||||
decoder = instance(pair_decoder, vin2_mintime_ns=200, deadtime_12_ns=50)
|
||||
decoder.metadata(2, 1_000_000_000)
|
||||
samples = iter([(0, (0, 0)), (10, (1, 0)), (110, (0, 0)),
|
||||
(140, (0, 1)), (240, (0, 0)), (280, (1, 0))])
|
||||
def wait(conditions):
|
||||
decoder.samplenum, pins = next(samples)
|
||||
return pins
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
text = ' '.join(item[3][1][0] for item in decoder.emitted)
|
||||
self.assertIn('Deadtime Vin1 -> Vin2: 30.0 ns', text)
|
||||
self.assertIn('FAIL: Vin2 active', text)
|
||||
self.assertIn('duty', text)
|
||||
self.assertNotIn('ACK', text)
|
||||
self.assertTrue(any(e[3][0] == 3 for e in decoder.emitted))
|
||||
decoder.reset()
|
||||
self.assertIsNone(decoder.checker)
|
||||
|
||||
def test_status_only_and_pair_requires_two_inputs(self):
|
||||
decoder = instance(gate)
|
||||
decoder.has_channel = lambda channel: channel == 1
|
||||
samples = iter([(10, 1), (1010, 0)])
|
||||
def wait(conditions):
|
||||
self.assertEqual(conditions, [{1: 'e'}])
|
||||
decoder.samplenum, status = next(samples)
|
||||
decoder.matched = 1
|
||||
return (255, status, 255)
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(decoder.emitted), 1)
|
||||
decoder = instance(pair_decoder)
|
||||
decoder.has_channel = lambda channel: channel == 0
|
||||
with self.assertRaisesRegex(ValueError, 'Vin1 and Vin2'):
|
||||
decoder.decode()
|
||||
|
||||
def test_missing_ack_timer_emits_error(self):
|
||||
decoder = instance(gate)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
calls = []
|
||||
def wait(conditions):
|
||||
calls.append(conditions)
|
||||
if len(calls) == 1:
|
||||
decoder.samplenum, decoder.matched = 100, 1
|
||||
elif len(calls) == 2:
|
||||
decoder.samplenum += conditions[2]['skip']
|
||||
decoder.matched = 4
|
||||
else:
|
||||
raise StopIteration
|
||||
return 1, 0
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 4])
|
||||
|
||||
def test_packaged_core_matches_canonical_templates(self):
|
||||
canonical = ROOT.parents[1] / 'python/logic_analyzer/decoders'
|
||||
packaged = Path(RUNTIME.name) / 'decoders/common/setgui_decoders'
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
self.assertEqual((canonical / (name + '.py')).read_bytes(),
|
||||
(packaged / (name + '.py')).read_bytes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
21
tools/dsview/tests/test_gate_summary.py
Normal file
21
tools/dsview/tests/test_gate_summary.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPILER = shutil.which('g++') or (r'C:/setcorp/tools/msys64/ucrt64/bin/g++.exe' if os.name == 'nt' else None)
|
||||
|
||||
@unittest.skipUnless(COMPILER and Path(COMPILER).is_file(), 'C++ compiler required')
|
||||
class GateSummaryTests(unittest.TestCase):
|
||||
def test_verdict_priority_completion_and_reset(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
binary = Path(temporary) / ('summary.exe' if os.name == 'nt' else 'summary')
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = str(Path(COMPILER).parent) + os.pathsep + env.get('PATH', '')
|
||||
subprocess.run([COMPILER, '-std=c++11', '-static', '-I', str(ROOT / 'native'),
|
||||
str(ROOT / 'tests/gate_summary_test.cpp'), '-o', str(binary)],
|
||||
env=env, check=True, capture_output=True)
|
||||
subprocess.run([str(binary)], env=env, check=True, capture_output=True)
|
||||
156
tools/dsview/tests/test_gate_timing.py
Normal file
156
tools/dsview/tests/test_gate_timing.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
MODULE_PATH = (Path(__file__).resolve().parents[3] / 'python/logic_analyzer/decoders/gate_timing.py')
|
||||
SPEC = importlib.util.spec_from_file_location('gate_driver_timing_core', MODULE_PATH)
|
||||
TIMING = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(TIMING)
|
||||
|
||||
|
||||
def kinds(events):
|
||||
return [item['kind'] for item in events]
|
||||
|
||||
|
||||
class TimingCheckerTests(unittest.TestCase):
|
||||
def test_1sp0635_nominal_ack_at_100_mhz(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(100, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(125, 1)), ['delay_ok'])
|
||||
result = checker.on_status_edge(195, 0)
|
||||
self.assertEqual(kinds(result), ['width_ok'])
|
||||
self.assertEqual(result[0]['width_ns'], 700.0)
|
||||
|
||||
def test_1sd536f2_nominal_ack_at_100_mhz_active_low(self):
|
||||
checker = TIMING.TimingChecker(
|
||||
100_000_000, profile='1SD536F2', vstat_active_high=False)
|
||||
checker.on_control_edge(20, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(58, 0)), ['delay_ok'])
|
||||
result = checker.on_status_edge(148, 1)
|
||||
self.assertEqual(kinds(result), ['width_ok'])
|
||||
self.assertEqual(result[0]['width_ns'], 900.0)
|
||||
|
||||
def test_late_delay_is_warning_but_valid_width_passes(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(50, 1)), ['delay_warn'])
|
||||
self.assertEqual(kinds(checker.on_status_edge(120, 0)), ['width_ok'])
|
||||
|
||||
def test_too_short_ack_fails(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
checker.on_status_edge(25, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(55, 0)), ['width_fail'])
|
||||
|
||||
def test_long_status_is_fault(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
checker.on_status_edge(25, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(225, 0)), ['fault'])
|
||||
|
||||
def test_missing_ack_expires(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(10, 0)
|
||||
deadline = checker.next_deadline()
|
||||
self.assertIsNotNone(deadline)
|
||||
self.assertEqual(kinds(checker.expire(deadline)), ['missing'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class InputTimingTests(unittest.TestCase):
|
||||
def test_both_directions_and_independent_minimums(self):
|
||||
c = TIMING.InputTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=200)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
self.assertEqual(kinds(c.update(110, 0, 0)), ['mintime_ok'])
|
||||
event = c.update(140, 0, 1)[0]
|
||||
self.assertEqual(event['duration_ns'], 30)
|
||||
self.assertIn('Vin1 -> Vin2', event['text'])
|
||||
self.assertEqual(kinds(c.update(240, 0, 0)), ['mintime_fail'])
|
||||
event = c.update(280, 1, 0)[0]
|
||||
self.assertEqual(event['duration_ns'], 40)
|
||||
self.assertIn('Vin2 -> Vin1', event['text'])
|
||||
|
||||
def test_atomic_handover_and_active_low(self):
|
||||
c = TIMING.InputTimingChecker(1e9, False, False)
|
||||
c.update(0, 1, 1)
|
||||
c.update(10, 0, 1)
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertEqual(kinds(events), ['mintime_ok', 'deadtime'])
|
||||
self.assertEqual(events[1]['duration_ns'], 0)
|
||||
self.assertEqual(kinds(c.update(30, 0, 1)), ['mintime_ok', 'deadtime'])
|
||||
|
||||
def test_overlap_and_capture_boundaries(self):
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
c.update(0, 1, 0)
|
||||
c.update(10, 1, 1)
|
||||
events = c.update(25, 0, 1)
|
||||
self.assertEqual(kinds(events), ['overlap'])
|
||||
self.assertEqual(events[0]['duration_ns'], 15)
|
||||
c.update(30, 1, 1)
|
||||
self.assertEqual(kinds(c.finish(50)), ['overlap'])
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
c.update(0, 0, 0)
|
||||
self.assertEqual(c.update(10, 1, 0), [])
|
||||
self.assertEqual(c.finish(20), [])
|
||||
|
||||
def test_no_stale_deadtime_after_same_input_restarts(self):
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
for t, a, b in [(0, 0, 0), (10, 1, 0), (20, 0, 0), (30, 1, 0), (40, 0, 0)]:
|
||||
c.update(t, a, b)
|
||||
self.assertEqual(c.update(50, 0, 1)[0]['duration_ns'], 10)
|
||||
|
||||
def test_invalid_minimums(self):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.assertRaises(ValueError):
|
||||
TIMING.InputTimingChecker(1e9, vin2_mintime_ns=value)
|
||||
|
||||
|
||||
class CycleResultTests(unittest.TestCase):
|
||||
def cycle(self, checker, origin=0, delay=250, width=700, inverted=False):
|
||||
events = checker.on_control_edge(origin + 100, not inverted)
|
||||
events += checker.on_status_edge(origin + 100 + delay, 1)
|
||||
events += checker.on_status_edge(origin + 100 + delay + width, 0)
|
||||
events += checker.on_control_edge(origin + 3000, inverted)
|
||||
events += checker.on_status_edge(origin + 3250, 1)
|
||||
events += checker.on_status_edge(origin + 3950, 0)
|
||||
return [e for e in events if e['kind'].startswith('cycle_')]
|
||||
|
||||
def test_complete_cycle_range_and_active_low(self):
|
||||
for inverted in (False, True):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True, vin_active_high=not inverted)
|
||||
events = self.cycle(checker, inverted=inverted)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual((events[0]['kind'], events[0]['start'], events[0]['end']), ('cycle_ok', 100, 3950))
|
||||
|
||||
def test_warning_fault_and_next_cycle_recovery(self):
|
||||
for delay, width, verdict in [(500, 700, 'warning'), (250, 100, 'fault'), (250, 2000, 'fault')]:
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
self.assertEqual(self.cycle(checker, delay=delay, width=width)[0]['kind'], 'cycle_' + verdict)
|
||||
self.assertEqual(self.cycle(checker, origin=10000)[0]['kind'], 'cycle_ok')
|
||||
|
||||
def test_missing_ack_and_clipped_capture(self):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
checker.on_control_edge(100, 1)
|
||||
checker.expire(1500)
|
||||
checker.on_control_edge(3000, 0)
|
||||
checker.on_status_edge(3250, 1)
|
||||
events = checker.on_status_edge(3950, 0)
|
||||
self.assertEqual(events[-1]['kind'], 'cycle_fault')
|
||||
checker.on_control_edge(10000, 1)
|
||||
self.assertEqual(checker.finish_cycles(10100)[0]['kind'], 'cycle_warning')
|
||||
self.assertEqual(checker.finish_cycles(10100), [])
|
||||
|
||||
def test_no_ok_before_second_ack_finishes(self):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
events = checker.on_control_edge(100, 1)
|
||||
events += checker.on_status_edge(350, 1)
|
||||
events += checker.on_status_edge(1050, 0)
|
||||
events += checker.on_control_edge(3000, 0)
|
||||
events += checker.on_status_edge(3250, 1)
|
||||
self.assertFalse(any(e['kind'].startswith('cycle_') for e in events))
|
||||
self.assertEqual(checker.on_status_edge(3950, 0)[-1]['kind'], 'cycle_ok')
|
||||
116
tools/dsview/tests/test_install_decoders.py
Normal file
116
tools/dsview/tests/test_install_decoders.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Exercise the standalone PowerShell installer without modifying installed DSView."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SHELL = shutil.which('powershell.exe') or shutil.which('pwsh')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt' and SHELL, 'Windows PowerShell required')
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix='dsview installer ')
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.app = Path(self.temp.name)/'DSView test'
|
||||
self.app.mkdir()
|
||||
(self.app/'DSView.exe').write_bytes(b'fixture, not executable')
|
||||
(self.app/'decoders/common').mkdir(parents=True)
|
||||
(self.app/'decoders/common/__init__.py').write_text('# existing common package\n')
|
||||
(self.app/'decoders/common/keep.txt').write_text('unrelated')
|
||||
|
||||
def run_installer(self, *extra, script=None):
|
||||
return subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
str(script or ROOT/'install_decoders.ps1'),
|
||||
'-DsViewPath', str(self.app), *extra], capture_output=True, text=True)
|
||||
|
||||
def test_install_import_and_update_backup(self):
|
||||
result = self.run_installer()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
code = '''
|
||||
import importlib, pathlib, sys, types
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root))
|
||||
stub = types.ModuleType('sigrokdecode')
|
||||
stub.Decoder = type('Decoder', (), {})
|
||||
sys.modules['sigrokdecode'] = stub
|
||||
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
module = importlib.import_module(name + '.pd')
|
||||
assert module.Decoder.api_version == 3
|
||||
core = importlib.import_module('common.setgui_decoders.gate_timing')
|
||||
pathlib.Path(core.__file__).resolve().relative_to(root)
|
||||
assert '1SP0635' in core.PROFILES
|
||||
'''
|
||||
check = subprocess.run([sys.executable, '-I', '-c', code, str(self.app/'decoders')],
|
||||
cwd=self.app, capture_output=True, text=True)
|
||||
self.assertEqual(check.returncode, 0, check.stderr)
|
||||
target = self.app/'decoders/set_uart/pd.py'
|
||||
target.write_text('# old decoder\n')
|
||||
result = self.run_installer()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
backups = list((self.app/'set-decoder-backups').glob('*/set_uart/pd.py'))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_text(), '# old decoder\n')
|
||||
self.assertEqual(target.read_bytes(), (ROOT/'decoders/set_uart/pd.py').read_bytes())
|
||||
self.assertEqual((self.app/'decoders/common/keep.txt').read_text(), 'unrelated')
|
||||
self.assertEqual((self.app/'decoders/common/__init__.py').read_text(), '# existing common package\n')
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
self.assertEqual((self.app/('decoders/common/setgui_decoders/'+name+'.py')).read_bytes(),
|
||||
(ROOT.parents[1]/('python/logic_analyzer/decoders/'+name+'.py')).read_bytes())
|
||||
|
||||
def test_check_only_does_not_install(self):
|
||||
result = self.run_installer('-CheckOnly')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.app/'decoders/set_uart').exists())
|
||||
self.assertFalse((self.app/'set-decoder-backups').exists())
|
||||
|
||||
def test_copy_failure_restores_previous_decoders(self):
|
||||
old = self.app/'decoders/gate_driver_timing/pd.py'
|
||||
old.parent.mkdir()
|
||||
old.write_text('# original\n')
|
||||
runner = Path(self.temp.name)/'inject-failure.ps1'
|
||||
runner.write_text('''
|
||||
param([string]$Installer, [string]$Target)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$global:installerTestFailed = $false
|
||||
function Copy-Item {
|
||||
param([string]$LiteralPath, [string]$Destination, [switch]$Force)
|
||||
if (-not $global:installerTestFailed -and $Destination.Replace('\\', '/').EndsWith('/decoders/set_uart/pd.py')) {
|
||||
$global:installerTestFailed = $true
|
||||
throw 'Injected copy failure'
|
||||
}
|
||||
Microsoft.PowerShell.Management\\Copy-Item -LiteralPath $LiteralPath -Destination $Destination -Force:$Force
|
||||
}
|
||||
& $Installer -DsViewPath $Target
|
||||
''', encoding='utf-8')
|
||||
result = subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
str(runner), '-Installer', str(ROOT/'install_decoders.ps1'),
|
||||
'-Target', str(self.app)], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(old.read_text(), '# original\n')
|
||||
self.assertFalse((old.parent/'__init__.py').exists())
|
||||
self.assertFalse((self.app/'decoders/set_uart/__init__.py').exists())
|
||||
self.assertEqual(len(list((self.app/'set-decoder-backups').glob('*/gate_driver_timing/pd.py'))), 1, result.stderr)
|
||||
|
||||
def test_incomplete_target_is_untouched(self):
|
||||
(self.app/'DSView.exe').unlink()
|
||||
result = self.run_installer()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.app/'decoders/set_uart').exists())
|
||||
|
||||
def test_missing_source_is_detected_before_changes(self):
|
||||
source = Path(self.temp.name)/'empty-template/tools/dsview'
|
||||
source.mkdir(parents=True)
|
||||
shutil.copy2(ROOT/'install_decoders.ps1', source/'install_decoders.ps1')
|
||||
result = self.run_installer(script=source/'install_decoders.ps1')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.app/'decoders/gate_driver_timing').exists())
|
||||
self.assertFalse((self.app/'set-decoder-backups').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
55
tools/dsview/tests/test_runtime.py
Normal file
55
tools/dsview/tests/test_runtime.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Prevent packaging a new GUI with a stale native DSView component."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
RECIPE = Path(__file__).resolve().parents[1] / 'build.py'
|
||||
SPEC = importlib.util.spec_from_file_location('dsview_runtime_build', RECIPE)
|
||||
BUILD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(BUILD)
|
||||
|
||||
|
||||
class RuntimeTests(unittest.TestCase):
|
||||
def test_legacy_runtime_and_mismatched_binary_or_recipe_require_rebuild(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
(output / 'DSView.exe').write_bytes(b'native fixture')
|
||||
(output / 'host-protocol-1').write_text('DSView host protocol 1\n')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
stamp = {'recipe': 'new', 'executable': hashlib.sha256(b'native fixture').hexdigest()}
|
||||
(output / 'host-build.json').write_text(json.dumps(stamp))
|
||||
with patch.object(BUILD, 'recipe_digest', return_value='new'):
|
||||
self.assertTrue(BUILD.runtime_is_current(output))
|
||||
(output / 'DSView.exe').write_bytes(b'old native fixture')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
(output / 'DSView.exe').write_bytes(b'native fixture')
|
||||
with patch.object(BUILD, 'recipe_digest', return_value='changed'):
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
(output / 'host-build.json').write_text('partial JSON')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
|
||||
def test_patch_adapters_and_shared_cores_all_invalidate_runtime(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
recipe = root / 'tools/dsview'
|
||||
core = root / 'python/logic_analyzer/decoders'
|
||||
core.mkdir(parents=True)
|
||||
(recipe / 'decoders/gate_driver_timing').mkdir(parents=True)
|
||||
(recipe / 'native').mkdir()
|
||||
files = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h',
|
||||
recipe / 'decoders/gate_driver_timing/pd.py']
|
||||
files += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
|
||||
for item in files:
|
||||
item.write_text('initial')
|
||||
with patch.object(BUILD, '__file__', str(recipe / 'build.py')):
|
||||
initial = BUILD.recipe_digest()
|
||||
for item in files:
|
||||
with self.subTest(file=item.name):
|
||||
item.write_text('changed')
|
||||
self.assertNotEqual(initial, BUILD.recipe_digest())
|
||||
item.write_text('initial')
|
||||
self.assertEqual(initial, BUILD.recipe_digest())
|
||||
58
tools/dsview/tests/test_transistor_pair.py
Normal file
58
tools/dsview/tests/test_transistor_pair.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'python'))
|
||||
from logic_analyzer.decoders.transistor_pair import PairTimingChecker
|
||||
|
||||
|
||||
class TransistorPairTests(unittest.TestCase):
|
||||
def test_directional_deadtime_limits_and_equality(self):
|
||||
c = PairTimingChecker(1e9, deadtime_12_ns=30, deadtime_21_ns=50)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
c.update(110, 0, 0)
|
||||
events = c.update(140, 0, 1)
|
||||
self.assertEqual(events[0]['kind'], 'deadtime')
|
||||
self.assertEqual(events[0]['duration_ns'], 30)
|
||||
c.update(240, 0, 0)
|
||||
self.assertEqual(c.update(280, 1, 0)[0]['kind'], 'deadtime_fail')
|
||||
|
||||
def test_independent_on_off_times_frequency_and_duty(self):
|
||||
c = PairTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=101,
|
||||
vin1_minoff_ns=201, vin2_minoff_ns=50)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
self.assertEqual(c.update(110, 0, 0)[0]['kind'], 'mintime_ok')
|
||||
c.update(140, 0, 1)
|
||||
self.assertEqual(c.update(240, 0, 0)[0]['kind'], 'mintime_fail')
|
||||
events = c.update(310, 1, 0)
|
||||
self.assertTrue(any(e['kind'] == 'off_fail' for e in events))
|
||||
period = next(e for e in events if e['kind'] == 'period')
|
||||
self.assertAlmostEqual(period['frequency_hz'], 1e9/300)
|
||||
self.assertAlmostEqual(period['duty_percent'], 100/3)
|
||||
self.assertEqual((period['start'], period['end']), (10, 310))
|
||||
|
||||
def test_atomic_handover_active_low_and_overlap(self):
|
||||
c = PairTimingChecker(1e9, False, False, deadtime_12_ns=1)
|
||||
c.update(0, 1, 1)
|
||||
c.update(10, 0, 1)
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertTrue(any(e['kind'] == 'deadtime_fail' and e['duration_ns'] == 0 for e in events))
|
||||
c.update(30, 0, 0)
|
||||
self.assertEqual(c.finish(50)[0]['kind'], 'overlap')
|
||||
self.assertTrue(any(e['kind'] == 'overlap' for e in c.update(60, 1, 0)))
|
||||
|
||||
def test_capture_boundary_does_not_invent_complete_pulses(self):
|
||||
c = PairTimingChecker(1e9)
|
||||
self.assertEqual(c.update(0, 1, 0), [])
|
||||
self.assertEqual(c.update(10, 0, 0), [])
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertEqual([e['kind'] for e in events], ['off_time'])
|
||||
self.assertEqual(c.finish(30), [])
|
||||
|
||||
def test_invalid_limits(self):
|
||||
for name in ('deadtime_12_ns', 'deadtime_21_ns', 'vin1_minoff_ns', 'vin2_minoff_ns'):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.subTest(name=name, value=value), self.assertRaises(ValueError):
|
||||
PairTimingChecker(1e9, **{name: value})
|
||||
96
tools/dsview/windows-usb-events.patch
Normal file
96
tools/dsview/windows-usb-events.patch
Normal file
@@ -0,0 +1,96 @@
|
||||
--- a/libsigrok4DSL/hardware/DSL/dslogic.c
|
||||
+++ b/libsigrok4DSL/hardware/DSL/dslogic.c
|
||||
@@ -1316,10 +1316,15 @@
|
||||
{
|
||||
int i;
|
||||
sr_info("%s: remove fds from polling", __func__);
|
||||
+#ifdef _WIN32
|
||||
+ /* Windows libusb has no poll descriptors; remove the timer source. */
|
||||
+ sr_source_remove(-1);
|
||||
+#else
|
||||
/* Remove fds from polling. */
|
||||
for (i = 0; devc->usbfd[i] != -1; i++)
|
||||
sr_source_remove(devc->usbfd[i]);
|
||||
g_free(devc->usbfd);
|
||||
+#endif
|
||||
}
|
||||
|
||||
static void report_overflow(struct DSL_context *devc)
|
||||
@@ -1486,6 +1491,13 @@
|
||||
}
|
||||
|
||||
/* setup callback function for data transfer */
|
||||
+#ifdef _WIN32
|
||||
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
|
||||
+ * thread pumps libusb via receive_data on a bounded timer instead. */
|
||||
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
|
||||
+ if (ret != SR_OK)
|
||||
+ return ret;
|
||||
+#else
|
||||
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
|
||||
for (i = 0; lupfd[i]; i++);
|
||||
|
||||
@@ -1500,7 +1512,8 @@
|
||||
devc->usbfd[i] = lupfd[i]->fd;
|
||||
}
|
||||
devc->usbfd[i] = -1;
|
||||
- g_free(lupfd);
|
||||
+ libusb_free_pollfds(lupfd);
|
||||
+#endif
|
||||
|
||||
wr_cmd.header.dest = DSL_CTL_START;
|
||||
wr_cmd.header.size = 0;
|
||||
--- a/libsigrok4DSL/hardware/DSL/dscope.c
|
||||
+++ b/libsigrok4DSL/hardware/DSL/dscope.c
|
||||
@@ -1901,10 +1901,15 @@
|
||||
{
|
||||
int i;
|
||||
sr_info("%s: remove fds from polling", __func__);
|
||||
+#ifdef _WIN32
|
||||
+ /* Windows libusb has no poll descriptors; remove the timer source. */
|
||||
+ sr_source_remove(-1);
|
||||
+#else
|
||||
/* Remove fds from polling. */
|
||||
for (i = 0; devc->usbfd[i] != -1; i++)
|
||||
sr_source_remove(devc->usbfd[i]);
|
||||
g_free(devc->usbfd);
|
||||
+#endif
|
||||
}
|
||||
|
||||
static int receive_data(int fd, int revents, const struct sr_dev_inst *sdi)
|
||||
@@ -2088,6 +2093,13 @@
|
||||
}
|
||||
|
||||
/* setup callback function for data transfer */
|
||||
+#ifdef _WIN32
|
||||
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
|
||||
+ * thread pumps libusb via receive_data on a bounded timer instead. */
|
||||
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
|
||||
+ if (ret != SR_OK)
|
||||
+ return ret;
|
||||
+#else
|
||||
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
|
||||
for (i = 0; lupfd[i]; i++);
|
||||
|
||||
@@ -2103,7 +2115,8 @@
|
||||
}
|
||||
|
||||
devc->usbfd[i] = -1;
|
||||
- g_free(lupfd);
|
||||
+ libusb_free_pollfds(lupfd);
|
||||
+#endif
|
||||
|
||||
wr_cmd.header.dest = DSL_CTL_START;
|
||||
wr_cmd.header.size = 0;
|
||||
--- a/libsigrok4DSL/session.c
|
||||
+++ b/libsigrok4DSL/session.c
|
||||
@@ -207,7 +207,8 @@
|
||||
sr_dbg("Running...");
|
||||
|
||||
/* Do we have real sources? */
|
||||
- if (session->num_sources == 1 && session->pollfds[0].fd == -1) {
|
||||
+ if (session->num_sources == 1 && session->pollfds[0].fd == -1
|
||||
+ && session->sources[0].timeout == 0) {
|
||||
/* Dummy source, freewheel over it. */
|
||||
while (session->num_sources) {
|
||||
if (session->abort_session) {
|
||||
Reference in New Issue
Block a user