diff --git a/python/logic_analyzer/analysis.py b/python/logic_analyzer/analysis.py index cec59ee..07368ed 100644 --- a/python/logic_analyzer/analysis.py +++ b/python/logic_analyzer/analysis.py @@ -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) diff --git a/python/logic_analyzer/conversion.py b/python/logic_analyzer/conversion.py new file mode 100644 index 0000000..75b30cc --- /dev/null +++ b/python/logic_analyzer/conversion.py @@ -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'' + struct.pack('' + struct.pack(' 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) diff --git a/python/logic_analyzer/csv_import.py b/python/logic_analyzer/csv_import.py new file mode 100644 index 0000000..ca5aeec --- /dev/null +++ b/python/logic_analyzer/csv_import.py @@ -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) diff --git a/python/logic_analyzer/csv_options.py b/python/logic_analyzer/csv_options.py new file mode 100644 index 0000000..11703ce --- /dev/null +++ b/python/logic_analyzer/csv_options.py @@ -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() diff --git a/python/logic_analyzer/decoders/gate_timing.py b/python/logic_analyzer/decoders/gate_timing.py index e8685c8..9172fed 100644 --- a/python/logic_analyzer/decoders/gate_timing.py +++ b/python/logic_analyzer/decoders/gate_timing.py @@ -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 [] diff --git a/python/logic_analyzer/decoders/transistor_pair.py b/python/logic_analyzer/decoders/transistor_pair.py new file mode 100644 index 0000000..851406b --- /dev/null +++ b/python/logic_analyzer/decoders/transistor_pair.py @@ -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 diff --git a/python/logic_analyzer/dslogic.py b/python/logic_analyzer/dslogic.py new file mode 100644 index 0000000..e64e03b --- /dev/null +++ b/python/logic_analyzer/dslogic.py @@ -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 diff --git a/python/logic_analyzer/dslogic_trigger.py b/python/logic_analyzer/dslogic_trigger.py new file mode 100644 index 0000000..f365071 --- /dev/null +++ b/python/logic_analyzer/dslogic_trigger.py @@ -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) diff --git a/python/logic_analyzer/dsview_helper.py b/python/logic_analyzer/dsview_helper.py new file mode 100644 index 0000000..e69dea0 --- /dev/null +++ b/python/logic_analyzer/dsview_helper.py @@ -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() diff --git a/python/logic_analyzer/dsview_host.py b/python/logic_analyzer/dsview_host.py new file mode 100644 index 0000000..809cfd6 --- /dev/null +++ b/python/logic_analyzer/dsview_host.py @@ -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) diff --git a/python/logic_analyzer/files.py b/python/logic_analyzer/files.py index 0db09b0..eab2bf0 100644 --- a/python/logic_analyzer/files.py +++ b/python/logic_analyzer/files.py @@ -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 diff --git a/python/logic_analyzer/sal_metadata.py b/python/logic_analyzer/sal_metadata.py new file mode 100644 index 0000000..710ad06 --- /dev/null +++ b/python/logic_analyzer/sal_metadata.py @@ -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) diff --git a/python/tests/test_dslogic_trigger.py b/python/tests/test_dslogic_trigger.py new file mode 100644 index 0000000..5cca789 --- /dev/null +++ b/python/tests/test_dslogic_trigger.py @@ -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) diff --git a/python/tests/test_logic_analyzer_analysis.py b/python/tests/test_logic_analyzer_analysis.py index 584239c..56f396d 100644 --- a/python/tests/test_logic_analyzer_analysis.py +++ b/python/tests/test_logic_analyzer_analysis.py @@ -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) diff --git a/python/tests/test_logic_analyzer_dslogic.py b/python/tests/test_logic_analyzer_dslogic.py new file mode 100644 index 0000000..95ae396 --- /dev/null +++ b/python/tests/test_logic_analyzer_dslogic.py @@ -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(' .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) diff --git a/python/tests/test_logic_csv_import.py b/python/tests/test_logic_csv_import.py new file mode 100644 index 0000000..2d87966 --- /dev/null +++ b/python/tests/test_logic_csv_import.py @@ -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()) diff --git a/tools/dsview/README.md b/tools/dsview/README.md new file mode 100644 index 0000000..2417921 --- /dev/null +++ b/tools/dsview/README.md @@ -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 ` 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 ` 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 \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 \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 ` 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`. diff --git a/tools/dsview/build.py b/tools/dsview/build.py new file mode 100644 index 0000000..860f555 --- /dev/null +++ b/tools/dsview/build.py @@ -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() diff --git a/tools/dsview/decoder-panel.patch b/tools/dsview/decoder-panel.patch new file mode 100644 index 0000000..5947bed --- /dev/null +++ b/tools/dsview/decoder-panel.patch @@ -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 _items; + ISearchItemClick *_item_click; + QScrollArea *_scroll; ++ std::vector > _sections; + }; + + #endif // SEARCHCOMBOBOX_H +--- a/DSView/pv/dock/searchcombobox.cpp ++++ b/DSView/pv/dock/searchcombobox.cpp +@@ -25,6 +25,7 @@ + #include + #include + #include ++#include + #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 + { diff --git a/tools/dsview/decoders/gate_driver_timing/README.md b/tools/dsview/decoders/gate_driver_timing/README.md new file mode 100644 index 0000000..7ed9fab --- /dev/null +++ b/tools/dsview/decoders/gate_driver_timing/README.md @@ -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. diff --git a/tools/dsview/decoders/gate_driver_timing/__init__.py b/tools/dsview/decoders/gate_driver_timing/__init__.py new file mode 100644 index 0000000..d0a29e0 --- /dev/null +++ b/tools/dsview/decoders/gate_driver_timing/__init__.py @@ -0,0 +1,3 @@ +"""DSView protocol decoder for SCALE gate-driver timing checks.""" + +from .pd import Decoder diff --git a/tools/dsview/decoders/gate_driver_timing/pd.py b/tools/dsview/decoders/gate_driver_timing/pd.py new file mode 100644 index 0000000..5b59ec8 --- /dev/null +++ b/tools/dsview/decoders/gate_driver_timing/pd.py @@ -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)) diff --git a/tools/dsview/decoders/pm35_uart/__init__.py b/tools/dsview/decoders/pm35_uart/__init__.py new file mode 100644 index 0000000..e99f16b --- /dev/null +++ b/tools/dsview/decoders/pm35_uart/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder diff --git a/tools/dsview/decoders/pm35_uart/pd.py b/tools/dsview/decoders/pm35_uart/pd.py new file mode 100644 index 0000000..2922af5 --- /dev/null +++ b/tools/dsview/decoders/pm35_uart/pd.py @@ -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) diff --git a/tools/dsview/decoders/set_can/__init__.py b/tools/dsview/decoders/set_can/__init__.py new file mode 100644 index 0000000..e99f16b --- /dev/null +++ b/tools/dsview/decoders/set_can/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder diff --git a/tools/dsview/decoders/set_can/pd.py b/tools/dsview/decoders/set_can/pd.py new file mode 100644 index 0000000..0a775e5 --- /dev/null +++ b/tools/dsview/decoders/set_can/pd.py @@ -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)]]) diff --git a/tools/dsview/decoders/set_uart/__init__.py b/tools/dsview/decoders/set_uart/__init__.py new file mode 100644 index 0000000..e99f16b --- /dev/null +++ b/tools/dsview/decoders/set_uart/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder diff --git a/tools/dsview/decoders/set_uart/pd.py b/tools/dsview/decoders/set_uart/pd.py new file mode 100644 index 0000000..e2e0645 --- /dev/null +++ b/tools/dsview/decoders/set_uart/pd.py @@ -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) diff --git a/tools/dsview/decoders/transistor_pair/README.md b/tools/dsview/decoders/transistor_pair/README.md new file mode 100644 index 0000000..9fcac6c --- /dev/null +++ b/tools/dsview/decoders/transistor_pair/README.md @@ -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. diff --git a/tools/dsview/decoders/transistor_pair/__init__.py b/tools/dsview/decoders/transistor_pair/__init__.py new file mode 100644 index 0000000..e99f16b --- /dev/null +++ b/tools/dsview/decoders/transistor_pair/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder diff --git a/tools/dsview/decoders/transistor_pair/pd.py b/tools/dsview/decoders/transistor_pair/pd.py new file mode 100644 index 0000000..a5b1f2f --- /dev/null +++ b/tools/dsview/decoders/transistor_pair/pd.py @@ -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)) diff --git a/tools/dsview/driver-summary.patch b/tools/dsview/driver-summary.patch new file mode 100644 index 0000000..993814e --- /dev/null +++ b/tools/dsview/driver-summary.patch @@ -0,0 +1,83 @@ +--- a/DSView/pv/data/decoderstack.h ++++ b/DSView/pv/data/decoderstack.h +@@ -30,6 +30,7 @@ + #include + #include + ++#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); diff --git a/tools/dsview/gate-pair-checkbox.patch b/tools/dsview/gate-pair-checkbox.patch new file mode 100644 index 0000000..a5bee1f --- /dev/null +++ b/tools/dsview/gate-pair-checkbox.patch @@ -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); diff --git a/tools/dsview/install_decoders.ps1 b/tools/dsview/install_decoders.ps1 new file mode 100644 index 0000000..ffedc86 --- /dev/null +++ b/tools/dsview/install_decoders.ps1 @@ -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.' diff --git a/tools/dsview/native/gate_summary.h b/tools/dsview/native/gate_summary.h new file mode 100644 index 0000000..c888b31 --- /dev/null +++ b/tools/dsview/native/gate_summary.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +// 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 controls{0}, acknowledgments{0}, warnings{0}, faults{0}; +}; diff --git a/tools/dsview/setgui-host.patch b/tools/dsview/setgui-host.patch new file mode 100644 index 0000000..8470419 --- /dev/null +++ b/tools/dsview/setgui-host.patch @@ -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} ") + 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(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(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& 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& 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 &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 ++#include ++#include + + #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 &matches, const QString &term); ++ QModelIndex sourceIndex(int row) const; ++private: ++ QPointer _source; ++ std::vector _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_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_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 + #include + #include +-#include +-#include +-#include ++#include + #include + #include + #include + #include + #include ++#include + #include + #include + #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 ++#include ++#include + + 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(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(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(QApplication::activeModalWidget()); ++ if (!dialog) return; ++ int channel = 1; ++ for (auto combo : dialog->findChildren()) { ++ 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 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_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(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( ++ decoder_stack->list_annotation_size(col), std::numeric_limits::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 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 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 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 + #include + #include +-#include ++#include + #include +-#include + #include + #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 &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 _search_matches; ++ std::vector _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 + #include + #include +-#include ++#include ++#include + #include + + #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(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(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(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(message)->message == WM_CLOSE) { ++ QTimer::singleShot(0, this, [this]() { close(); }); ++ *result = 0; ++ return true; ++ } + if (_parentNativeWidget != NULL) + { + MSG *msg = static_cast(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 + #include +-#include ++#include ++#include + #include + #include + #include + #include +-#include ++#include ++#include ++#include ++#include + #include + #include + #include +@@ -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 &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 _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 + #include + #include ++#include + + #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())); diff --git a/tools/dsview/tests/gate_summary_test.cpp b/tools/dsview/tests/gate_summary_test.cpp new file mode 100644 index 0000000..8801ccd --- /dev/null +++ b/tools/dsview/tests/gate_summary_test.cpp @@ -0,0 +1,24 @@ +#include "gate_summary.h" +#include +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); +} diff --git a/tools/dsview/tests/test_decoders.py b/tools/dsview/tests/test_decoders.py new file mode 100644 index 0000000..b1fd85e --- /dev/null +++ b/tools/dsview/tests/test_decoders.py @@ -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(' 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() diff --git a/tools/dsview/tests/test_gate_summary.py b/tools/dsview/tests/test_gate_summary.py new file mode 100644 index 0000000..39df4a0 --- /dev/null +++ b/tools/dsview/tests/test_gate_summary.py @@ -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) diff --git a/tools/dsview/tests/test_gate_timing.py b/tools/dsview/tests/test_gate_timing.py new file mode 100644 index 0000000..ed8e0d2 --- /dev/null +++ b/tools/dsview/tests/test_gate_timing.py @@ -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') diff --git a/tools/dsview/tests/test_install_decoders.py b/tools/dsview/tests/test_install_decoders.py new file mode 100644 index 0000000..19302b8 --- /dev/null +++ b/tools/dsview/tests/test_install_decoders.py @@ -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() diff --git a/tools/dsview/tests/test_runtime.py b/tools/dsview/tests/test_runtime.py new file mode 100644 index 0000000..c085793 --- /dev/null +++ b/tools/dsview/tests/test_runtime.py @@ -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()) diff --git a/tools/dsview/tests/test_transistor_pair.py b/tools/dsview/tests/test_transistor_pair.py new file mode 100644 index 0000000..538894d --- /dev/null +++ b/tools/dsview/tests/test_transistor_pair.py @@ -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}) diff --git a/tools/dsview/windows-usb-events.patch b/tools/dsview/windows-usb-events.patch new file mode 100644 index 0000000..41e6743 --- /dev/null +++ b/tools/dsview/windows-usb-events.patch @@ -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) {