Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
1
python/logic_analyzer/__init__.py
Normal file
1
python/logic_analyzer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
358
python/logic_analyzer/analysis.py
Normal file
358
python/logic_analyzer/analysis.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Offline analysis of timestamped edges; no resampling or hardware access."""
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left, bisect_right
|
||||
from dataclasses import dataclass
|
||||
import heapq
|
||||
import math
|
||||
|
||||
from .files import ImportCancelled
|
||||
from .decoders.gate_timing import TimingChecker
|
||||
from .decoders.set_uart import StreamParser
|
||||
from .decoders.pm35_uart import PM35Parser
|
||||
from .decoders.set_can import legacy, Reassembler
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisEvent:
|
||||
start: float
|
||||
end: float
|
||||
kind: str
|
||||
text: str
|
||||
details: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
events: list
|
||||
truncated: bool
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
def pulse_measurements(channel, time, start, end):
|
||||
"""Measure a complete adjacent high/low cycle, without scanning the record."""
|
||||
edges = channel.edges
|
||||
index = bisect_right(edges, time)
|
||||
result = {'level': channel.initial ^ (index & 1)}
|
||||
if 0 < index < len(edges):
|
||||
left, right = edges[index - 1], edges[index]
|
||||
if start <= left < right <= end:
|
||||
result['width'] = right - left
|
||||
result['high' if result['level'] else 'low'] = right - left
|
||||
# Prefer the next complete interval, otherwise the preceding one.
|
||||
other = None
|
||||
if index + 1 < len(edges) and edges[index + 1] <= end:
|
||||
other = edges[index + 1] - right
|
||||
elif index >= 2 and edges[index - 2] >= start:
|
||||
other = left - edges[index - 2]
|
||||
if other is not None:
|
||||
result['low' if result['level'] else 'high'] = other
|
||||
result['period'] = result['high'] + result['low']
|
||||
result['frequency'] = 1 / result['period']
|
||||
result['duty'] = 100 * result['high'] / result['period']
|
||||
return result
|
||||
|
||||
|
||||
def interval_edges(channel, a, b):
|
||||
"""Count rising/falling edges in (min(a,b), max(a,b)]."""
|
||||
left, right = sorted((a, b))
|
||||
lo, hi = bisect_right(channel.edges, left), bisect_right(channel.edges, right)
|
||||
count = hi - lo
|
||||
first_rising = channel.initial ^ (lo & 1) == 0
|
||||
rising = count // 2 + int(bool(count & 1) and first_rising)
|
||||
return rising, count - rising
|
||||
|
||||
|
||||
def _cancel(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=False, cancel=lambda: False):
|
||||
"""Yield (start, end, byte, error), sampling 8-bit UART at bit centres."""
|
||||
bit = 1.0 / baud
|
||||
bits = 9 + (parity != 'none') + stops
|
||||
edges = channel.edges
|
||||
index = bisect_left(edges, start)
|
||||
while index < len(edges) and edges[index] < end:
|
||||
_cancel(cancel)
|
||||
time = edges[index]
|
||||
level = channel.initial ^ ((index + 1) & 1) ^ inverted
|
||||
if level:
|
||||
index += 1
|
||||
continue
|
||||
finish = time + bits * bit
|
||||
if finish > end + bit * 1e-6:
|
||||
yield time, end, None, 'Неполный UART-байт в конце диапазона'
|
||||
break
|
||||
read = lambda pos: channel.level_at(time + pos * bit) ^ inverted
|
||||
if read(0.5):
|
||||
index += 1 # A glitch shorter than half a start bit.
|
||||
continue
|
||||
values = [read(1.5 + i) for i in range(8)]
|
||||
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'
|
||||
stop_start = 9 + (parity != 'none')
|
||||
if any(read(stop_start + i + 0.5) != 1 for i in range(stops)):
|
||||
error = 'Ошибка стопового бита UART / BREAK'
|
||||
yield time, finish, value, error
|
||||
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
|
||||
|
||||
|
||||
def _number(bits):
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | bit
|
||||
return value
|
||||
|
||||
|
||||
def can_crc(bits):
|
||||
crc = 0
|
||||
for bit in bits:
|
||||
feedback = ((crc >> 14) & 1) ^ bit
|
||||
crc = (crc << 1) & 0x7fff
|
||||
if feedback:
|
||||
crc ^= 0x4599
|
||||
return crc
|
||||
|
||||
|
||||
class _CanBits:
|
||||
def __init__(self, channel, time, end, bitrate, inverted, sample_point):
|
||||
self.channel, self.time, self.end = channel, time, end
|
||||
self.bit, self.inverted = 1 / bitrate, inverted
|
||||
self.sample_point = sample_point
|
||||
self.position, self.previous, self.run = 0, None, 0
|
||||
self.bits = []
|
||||
self.last_sample = time
|
||||
|
||||
def raw(self):
|
||||
# Resynchronise on recessive->dominant edges near the bit boundary.
|
||||
boundary = self.time + self.position * self.bit
|
||||
if self.position:
|
||||
lo = bisect_right(self.channel.edges, max(self.last_sample, boundary - self.bit * .2))
|
||||
hi = bisect_right(self.channel.edges, boundary + self.bit * .2, lo)
|
||||
for index in range(lo, hi):
|
||||
level = self.channel.initial ^ ((index + 1) & 1) ^ self.inverted
|
||||
if level == 0:
|
||||
self.time += self.channel.edges[index] - boundary
|
||||
boundary = self.channel.edges[index]
|
||||
break
|
||||
sample = boundary + self.sample_point * self.bit
|
||||
if sample >= self.end:
|
||||
raise EOFError('Неполный CAN-кадр в конце диапазона')
|
||||
self.last_sample = sample
|
||||
self.position += 1
|
||||
return self.channel.level_at(sample) ^ self.inverted
|
||||
|
||||
def stuffed(self):
|
||||
if self.run == 5:
|
||||
value = self.raw()
|
||||
if value == self.previous:
|
||||
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
|
||||
self.previous = value
|
||||
self.bits.append(value)
|
||||
return value
|
||||
|
||||
def take(self, count):
|
||||
return _number([self.stuffed() for _ in range(count)])
|
||||
|
||||
|
||||
def can_frames(channel, start, end, bitrate, inverted=False, sample_point=.7, cancel=lambda: False):
|
||||
"""Classic CAN, strict stuffing/CRC/form validation before application decode."""
|
||||
edges = channel.edges
|
||||
index = bisect_left(edges, start)
|
||||
bit = 1 / bitrate
|
||||
while index < len(edges) and edges[index] < end:
|
||||
_cancel(cancel)
|
||||
time = edges[index]
|
||||
level = channel.initial ^ ((index + 1) & 1) ^ inverted
|
||||
previous = edges[index - 1] if index else start
|
||||
# SOF follows at least three recessive intermission bits.
|
||||
if level or time - previous < bit * 2.9:
|
||||
index += 1
|
||||
continue
|
||||
reader = _CanBits(channel, time, end, bitrate, inverted, sample_point)
|
||||
frame, error = None, None
|
||||
try:
|
||||
if reader.take(1):
|
||||
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')
|
||||
ident = (ident << 18) | reader.take(18)
|
||||
rtr = reader.take(1)
|
||||
if reader.take(2):
|
||||
raise ValueError('CAN FD / reserved bits не поддерживаются')
|
||||
elif reader.take(1):
|
||||
raise ValueError('CAN FD / reserved bit не поддерживается')
|
||||
dlc = reader.take(4)
|
||||
if dlc > 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')
|
||||
if reader.raw() != 1:
|
||||
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')
|
||||
if 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:
|
||||
error = str(exc)
|
||||
finish = min(end, reader.time + reader.position * bit)
|
||||
yield time, finish, frame, error
|
||||
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
|
||||
|
||||
|
||||
def analyze_capture(capture, options, start=None, end=None, progress=lambda n: None, cancel=lambda: False):
|
||||
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('Выберите непустой интервал анализа.')
|
||||
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('Лимит результатов должен быть положительным.')
|
||||
|
||||
def emit(a, b, kind, text, details=''):
|
||||
if len(events) >= limit:
|
||||
raise OverflowError()
|
||||
events.append(AnalysisEvent(a, b, kind, text, details))
|
||||
|
||||
def report(time):
|
||||
nonlocal last_progress
|
||||
_cancel(cancel)
|
||||
value = min(99, int((time - start) / (end - start) * 100))
|
||||
if value != last_progress:
|
||||
progress(value)
|
||||
last_progress = value
|
||||
|
||||
def parsed(items):
|
||||
for a, b, frame, error in items:
|
||||
emit(a, b, 'error' if error else 'frame', error or frame['summary'],
|
||||
'' if not frame else bytes(frame.get('raw', frame.get('payload', b''))).hex(' '))
|
||||
if frame and frame.get('protocol') == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
|
||||
try:
|
||||
application = legacy(frame['can_id'], frame['payload'])
|
||||
emit(a, b, 'frame', application['summary'])
|
||||
except ValueError as exc:
|
||||
emit(a, b, 'error', str(exc))
|
||||
|
||||
truncated = False
|
||||
try:
|
||||
if mode in ('1SP0635', '1SD536F2'):
|
||||
other = options.get('status_channel', 1)
|
||||
if other == options.get('channel', 0):
|
||||
raise ValueError('Vin и Vstat должны быть разными каналами.')
|
||||
status = capture.channels[other]
|
||||
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
|
||||
# to preserve ns precision for CSV timestamps far from zero.
|
||||
tick = lambda t: round((t - start) * 1e9)
|
||||
|
||||
def timing(items):
|
||||
for item in items:
|
||||
text = item['text']
|
||||
if item['kind'] == 'fault':
|
||||
text += ' · аварийная обратная связь; превышение тока не подтверждено'
|
||||
emit(start + item['start'] / 1e9, start + item['end'] / 1e9, item['kind'], text)
|
||||
|
||||
def transitions(ch, which):
|
||||
first = bisect_right(ch.edges, start)
|
||||
for i in range(first, bisect_right(ch.edges, end)):
|
||||
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)):
|
||||
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.expire(tick(end)))
|
||||
if checker.status_start is not None:
|
||||
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
|
||||
for pending in checker.pending:
|
||||
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin: диапазон закончился до тайм-аута ACK')
|
||||
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.')
|
||||
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'
|
||||
else options.get('gap_ms', 100) / 1000)
|
||||
last = None
|
||||
for a, b, value, error in uart_frames(channel, start, end, baud, parity, stops, options.get('inverted', False), cancel):
|
||||
report(a)
|
||||
if parser and last is not None and gap > 0 and a - last >= gap:
|
||||
parsed(parser.flush())
|
||||
last = b
|
||||
if error:
|
||||
if parser:
|
||||
parsed(parser.flush())
|
||||
emit(a, b, 'error', error)
|
||||
elif parser:
|
||||
parsed(parser.feed(value, a, b))
|
||||
else:
|
||||
emit(a, b, 'byte', 'UART 0x%02X' % value, chr(value) if 32 <= value < 127 else '')
|
||||
if parser:
|
||||
parsed(parser.flush())
|
||||
elif mode in ('CAN', 'SET CAN'):
|
||||
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.')
|
||||
reassembler = Reassembler()
|
||||
for a, b, frame, error in can_frames(channel, start, end, bitrate, options.get('inverted', False), sample_point, cancel):
|
||||
report(a)
|
||||
if error:
|
||||
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: сборка прервана ошибкой шины')
|
||||
reassembler.pending.clear()
|
||||
continue
|
||||
ident, data = frame['ident'], frame['data']
|
||||
emit(a, b, 'can', 'CAN %s ID=%08X DLC=%d %s %s' % (
|
||||
'EXT' if frame['extended'] else 'STD', ident, frame['dlc'],
|
||||
'RTR' if frame['remote'] else 'DATA', 'ACK' if frame['ack'] else 'NACK'), data.hex(' '))
|
||||
if mode == 'SET CAN' and frame['extended'] and not frame['remote']:
|
||||
protocol = options.get('protocol', 'protocan')
|
||||
if protocol == 'set-v2':
|
||||
parsed(reassembler.feed(ident, data, a, b, b * 1000))
|
||||
else:
|
||||
try:
|
||||
result = legacy(ident, data, protocol)
|
||||
if result:
|
||||
emit(a, b, 'frame', result['summary'], data.hex(' '))
|
||||
except ValueError as exc:
|
||||
emit(a, b, 'error', str(exc))
|
||||
for pending in reassembler.pending.values():
|
||||
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
|
||||
else:
|
||||
raise ValueError('Неизвестный анализатор: ' + mode)
|
||||
except OverflowError:
|
||||
truncated = True
|
||||
_cancel(cancel)
|
||||
progress(100)
|
||||
events.sort(key=lambda event: (event.start, event.end))
|
||||
return AnalysisResult(events, truncated, start, end)
|
||||
1
python/logic_analyzer/decoders/__init__.py
Normal file
1
python/logic_analyzer/decoders/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
181
python/logic_analyzer/decoders/gate_timing.py
Normal file
181
python/logic_analyzer/decoders/gate_timing.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Pure timing checker used by the DSView decoder and unit tests.
|
||||
|
||||
All time values in profiles are nanoseconds. The ACK delay in the vendor
|
||||
data sheets is a typical value only, so the delay window is intentionally an
|
||||
engineering warning threshold. ACK pulse width has specified limits and is
|
||||
therefore checked as PASS/FAIL.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
PROFILES = {
|
||||
'1SP0635': {
|
||||
'ack_delay_typ_ns': 250.0,
|
||||
'ack_width_min_ns': 400.0,
|
||||
'ack_width_typ_ns': 700.0,
|
||||
'ack_width_max_ns': 1050.0,
|
||||
'fault_threshold_ns': 1500.0,
|
||||
},
|
||||
'1SD536F2': {
|
||||
'ack_delay_typ_ns': 380.0,
|
||||
'ack_width_min_ns': 600.0,
|
||||
'ack_width_typ_ns': 900.0,
|
||||
'ack_width_max_ns': 1800.0,
|
||||
# The application manual calls >1.5 us a fault, while individual
|
||||
# data sheets allow ACK pulses up to 1.8 us. A correlated pulse in
|
||||
# the specified ACK range wins; otherwise the longer limit is used.
|
||||
'fault_threshold_ns': 1800.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_ns(value_ns):
|
||||
if value_ns >= 1000000.0:
|
||||
return '%.3f ms' % (value_ns / 1000000.0)
|
||||
if value_ns >= 1000.0:
|
||||
return '%.3f us' % (value_ns / 1000.0)
|
||||
return '%.1f ns' % value_ns
|
||||
|
||||
|
||||
class TimingChecker(object):
|
||||
"""Match Vin edges to Vstat pulses and return annotation dictionaries."""
|
||||
|
||||
def __init__(self, samplerate, profile='1SP0635', delay_tolerance_ns=100.0,
|
||||
vin_active_high=True, vstat_active_high=True,
|
||||
custom=None):
|
||||
if not samplerate:
|
||||
raise ValueError('samplerate is required')
|
||||
if profile == 'custom':
|
||||
if not custom:
|
||||
raise ValueError('custom profile values are required')
|
||||
self.spec = dict(custom)
|
||||
else:
|
||||
self.spec = dict(PROFILES[profile])
|
||||
self.profile = profile
|
||||
self.samplerate = float(samplerate)
|
||||
self.delay_tolerance_ns = float(delay_tolerance_ns)
|
||||
self.vin_active_high = bool(vin_active_high)
|
||||
self.vstat_active_high = bool(vstat_active_high)
|
||||
self.pending = deque()
|
||||
self.status_start = None
|
||||
self.status_control = None
|
||||
|
||||
# Long enough to avoid calling a merely late ACK "missing", while
|
||||
# still producing a useful annotation during a capture.
|
||||
self.ack_timeout_ns = max(
|
||||
self.spec['ack_delay_typ_ns'] + 3.0 * self.delay_tolerance_ns,
|
||||
self.spec['ack_delay_typ_ns'] + self.spec['ack_width_max_ns'])
|
||||
|
||||
def samples_to_ns(self, samples):
|
||||
return float(samples) * 1000000000.0 / self.samplerate
|
||||
|
||||
def ns_to_samples(self, value_ns):
|
||||
return max(1, int(round(float(value_ns) * self.samplerate / 1000000000.0)))
|
||||
|
||||
def next_deadline(self):
|
||||
if not self.pending:
|
||||
return None
|
||||
return self.pending[0]['sample'] + self.ns_to_samples(self.ack_timeout_ns)
|
||||
|
||||
def on_control_edge(self, sample, level):
|
||||
state_on = bool(level) == self.vin_active_high
|
||||
item = {
|
||||
'sample': int(sample),
|
||||
'level': int(level),
|
||||
'edge': 'ON' if state_on else 'OFF',
|
||||
}
|
||||
self.pending.append(item)
|
||||
return [{
|
||||
'kind': 'control', 'start': int(sample), 'end': int(sample),
|
||||
'text': 'Vin %s' % item['edge'], 'short': item['edge'],
|
||||
}]
|
||||
|
||||
def expire(self, sample):
|
||||
events = []
|
||||
timeout_samples = self.ns_to_samples(self.ack_timeout_ns)
|
||||
while self.pending and int(sample) >= self.pending[0]['sample'] + timeout_samples:
|
||||
item = self.pending.popleft()
|
||||
end = item['sample'] + timeout_samples
|
||||
events.append({
|
||||
'kind': 'missing', 'start': item['sample'], 'end': end,
|
||||
'text': 'FAIL: no Vstat ACK after Vin %s (timeout %s)' %
|
||||
(item['edge'], format_ns(self.ack_timeout_ns)),
|
||||
'short': 'NO ACK',
|
||||
})
|
||||
return events
|
||||
|
||||
def on_status_edge(self, sample, level):
|
||||
sample = int(sample)
|
||||
is_active = bool(level) == self.vstat_active_high
|
||||
events = self.expire(sample)
|
||||
|
||||
if is_active:
|
||||
# Ignore a second active edge caused by an inconsistent trace.
|
||||
if self.status_start is not None:
|
||||
return events
|
||||
self.status_start = sample
|
||||
self.status_control = self.pending.popleft() if self.pending else None
|
||||
if self.status_control is not None:
|
||||
delay_ns = self.samples_to_ns(sample - self.status_control['sample'])
|
||||
typ_ns = self.spec['ack_delay_typ_ns']
|
||||
delta_ns = delay_ns - typ_ns
|
||||
in_window = abs(delta_ns) <= self.delay_tolerance_ns
|
||||
events.append({
|
||||
'kind': 'delay_ok' if in_window else 'delay_warn',
|
||||
'start': self.status_control['sample'], 'end': sample,
|
||||
'delay_ns': delay_ns,
|
||||
'text': '%s: ACK delay %s (typ %s, delta %+0.1f ns)' %
|
||||
('PASS' if in_window else 'WARN', format_ns(delay_ns),
|
||||
format_ns(typ_ns), delta_ns),
|
||||
'short': '%s %s' % ('OK' if in_window else 'WARN',
|
||||
format_ns(delay_ns)),
|
||||
})
|
||||
return events
|
||||
|
||||
if self.status_start is None:
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': sample, 'end': sample,
|
||||
'text': 'Unexpected inactive Vstat edge', 'short': 'Vstat?',
|
||||
})
|
||||
return events
|
||||
|
||||
start = self.status_start
|
||||
control = self.status_control
|
||||
width_ns = self.samples_to_ns(sample - start)
|
||||
self.status_start = None
|
||||
self.status_control = None
|
||||
lo = self.spec['ack_width_min_ns']
|
||||
hi = self.spec['ack_width_max_ns']
|
||||
|
||||
if control is not None and lo <= width_ns <= hi:
|
||||
events.append({
|
||||
'kind': 'width_ok', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'PASS: ACK width %s (limit %s...%s)' %
|
||||
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
|
||||
'short': 'ACK %s' % format_ns(width_ns),
|
||||
})
|
||||
elif width_ns > self.spec['fault_threshold_ns']:
|
||||
events.append({
|
||||
'kind': 'fault', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'FAULT: Vstat active for %s' % format_ns(width_ns),
|
||||
'short': 'FAULT %s' % format_ns(width_ns),
|
||||
})
|
||||
elif control is None:
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'Unexpected Vstat pulse %s (no Vin edge)' % format_ns(width_ns),
|
||||
'short': 'ORPHAN %s' % format_ns(width_ns),
|
||||
})
|
||||
else:
|
||||
events.append({
|
||||
'kind': 'width_fail', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'FAIL: ACK width %s outside %s...%s' %
|
||||
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
|
||||
'short': 'BAD ACK %s' % format_ns(width_ns),
|
||||
})
|
||||
return events
|
||||
95
python/logic_analyzer/decoders/pm35_uart.py
Normal file
95
python/logic_analyzer/decoders/pm35_uart.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""PM35 MODBUS 03/06, including the legacy 128-word/zero-byte-count reply."""
|
||||
from .set_uart import crc16, number
|
||||
|
||||
|
||||
def parse_pm35(raw, role):
|
||||
raw = bytes(raw)
|
||||
if len(raw) < 5:
|
||||
raise ValueError('Incomplete PM35 frame')
|
||||
if crc16(raw[:-2], True) != number(raw[-2:]):
|
||||
raise ValueError('PM35 CRC mismatch')
|
||||
address, function = raw[:2]
|
||||
result = dict(protocol='PM35', controller=address, function=function, raw=raw)
|
||||
if function in (0x83, 0x86):
|
||||
if len(raw) != 5 or role == 'request':
|
||||
raise ValueError('Invalid exception response')
|
||||
result['summary'] = 'PM35 node=%d exception fn=%02X code=%d' % (address, function, raw[2])
|
||||
elif function == 6 or function == 3 and role == 'request':
|
||||
if len(raw) != 8:
|
||||
raise ValueError('PM35 request/06 must contain 8 bytes')
|
||||
register, value = number(raw[2:4], 'big'), number(raw[4:6], 'big')
|
||||
if register >= 128 or function == 3 and not 1 <= value <= 128 - register:
|
||||
raise ValueError('PM35 register range outside 0..127')
|
||||
result.update(register=register, value=value)
|
||||
result['summary'] = 'PM35 node=%d %s R%d %s=%d' % (address,
|
||||
'READ' if function == 3 else 'WRITE/echo', register,
|
||||
'count' if function == 3 else 'value', value)
|
||||
if function == 6 and register == 127:
|
||||
result['summary'] += ' command_bits=%04X' % value
|
||||
elif function == 3:
|
||||
count = raw[2] or 256
|
||||
if count % 2 or len(raw) != count + 5:
|
||||
raise ValueError('Invalid PM35 response byte count')
|
||||
result['values'] = [number(raw[i:i+2], 'big') for i in range(3, len(raw)-2, 2)]
|
||||
result['summary'] = 'PM35 node=%d READ response %d words: %s' % (address,
|
||||
len(result['values']), ' '.join('%04X' % v for v in result['values']))
|
||||
else:
|
||||
raise ValueError('Unsupported PM35 function')
|
||||
return result
|
||||
|
||||
|
||||
class PM35Parser:
|
||||
def __init__(self, role):
|
||||
self.role = role
|
||||
self.buffer = []
|
||||
|
||||
def flush(self):
|
||||
if not self.buffer:
|
||||
return []
|
||||
ss, es = self.buffer[0][1], self.buffer[-1][2]
|
||||
self.buffer = []
|
||||
return [(ss, es, None, 'Incomplete PM35 frame')]
|
||||
|
||||
def feed(self, value, ss, es):
|
||||
self.buffer.append((value, ss, es))
|
||||
events = []
|
||||
while len(self.buffer) >= 3:
|
||||
raw = bytes(item[0] for item in self.buffer)
|
||||
fn = raw[1]
|
||||
if fn not in (3, 6, 0x83, 0x86):
|
||||
del self.buffer[0]
|
||||
continue
|
||||
role = self.role
|
||||
total = 5 if fn & 128 else 8
|
||||
if role == 'auto' and fn == 3:
|
||||
candidates = [(8, 'request'), ((raw[2] or 256) + 5, 'response')]
|
||||
found = None
|
||||
for length, candidate_role in candidates:
|
||||
if len(raw) >= length:
|
||||
try:
|
||||
found = (length, candidate_role, parse_pm35(raw[:length], candidate_role))
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if found:
|
||||
length, candidate_role, result = found
|
||||
events.append((self.buffer[0][1], self.buffer[length-1][2], result, None))
|
||||
del self.buffer[:length]
|
||||
continue
|
||||
if len(raw) < max(length for length, _ in candidates):
|
||||
break
|
||||
events.append((self.buffer[0][1], self.buffer[-1][2], None, 'Invalid PM35 frame/CRC'))
|
||||
del self.buffer[0]
|
||||
continue
|
||||
if fn == 3 and role == 'response':
|
||||
total = (raw[2] or 256) + 5
|
||||
if len(raw) < total:
|
||||
break
|
||||
result, error = None, None
|
||||
try:
|
||||
result = parse_pm35(raw[:total], role)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
events.append((self.buffer[0][1], self.buffer[total-1][2], result, error))
|
||||
del self.buffer[:1 if error else total]
|
||||
return events
|
||||
124
python/logic_analyzer/decoders/set_can.py
Normal file
124
python/logic_analyzer/decoders/set_can.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ProtoCAN, Balsam and SET v2 classic CAN binding."""
|
||||
from .set_uart import number, parse_frame
|
||||
|
||||
TYPES = {0: 'BROADCAST', 1: 'DISCRETE', 2: 'ANALOG', 3: 'GAS',
|
||||
4: 'MODBUS_COIL', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING',
|
||||
7: 'MODBUS_INPUT', 8: 'ERROR', 9: 'BOOT_CONTROL', 10: 'BOOT_DATA_A',
|
||||
11: 'BOOT_DATA_B', 12: 'BOOT_STATUS', 13: 'BOOT_DISCOVERY', 15: 'PULSE'}
|
||||
BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIFY '
|
||||
'COMMIT CONFIRM REBOOT ABORT QUERY_PROGRESS').split(), 1))
|
||||
|
||||
|
||||
def legacy(ident, data, profile='protocan'):
|
||||
if not 0 <= ident <= 0x1fffffff or len(data) > 8:
|
||||
raise ValueError('Invalid classic CAN frame')
|
||||
relative = ident - 0xba0000
|
||||
if profile == 'balsam':
|
||||
if not (0 <= relative < 13 or 16 <= relative < 29):
|
||||
return None
|
||||
if len(data) != 8:
|
||||
raise ValueError('Balsam requires DLC=8')
|
||||
header = number(data[:2], 'big')
|
||||
address, mask = header & 0x1fff, header >> 13
|
||||
registers = [(address + i, number(data[2+i*2:4+i*2], 'big'))
|
||||
for i in range(3) if mask & (4 >> i)]
|
||||
return dict(protocol='Balsam', registers=registers, summary=
|
||||
'Balsam node=%d %s mask=%X %s' % ((relative & 15) + 1,
|
||||
'data' if relative >= 16 else 'command', mask,
|
||||
' '.join('R%04X=%04X' % pair for pair in registers)))
|
||||
msg, body = (ident >> 16) & 15, ident & 0xffff
|
||||
result = dict(protocol='ProtoCAN', priority=(ident >> 28) & 1,
|
||||
route=(ident >> 27) & 1, device_type=(ident >> 24) & 7,
|
||||
device_id=(ident >> 20) & 15, message_type=msg, body=body)
|
||||
text = 'ProtoCAN %s dev=%d:%d route=%d priority=%d body=%04X' % (
|
||||
TYPES.get(msg, 'TYPE_%X' % msg), result['device_type'], result['device_id'],
|
||||
result['route'], result['priority'], body)
|
||||
if msg == 3:
|
||||
if len(data) % 2 or body + len(data)//2 > 65536:
|
||||
raise ValueError('Invalid GAS register payload/range')
|
||||
result['registers'] = [(body + i//2, number(data[i:i+2])) for i in range(0, len(data), 2)]
|
||||
text += ' ' + ('READ' if not data else ' '.join('R%04X=%04X' % pair for pair in result['registers']))
|
||||
elif 4 <= msg <= 7:
|
||||
text += ' address=%03X count=%d' % (body >> 4, body & 15)
|
||||
elif msg == 8:
|
||||
text += ' info=%02X code=%02X' % (body >> 8, body & 255)
|
||||
elif msg in (9, 12):
|
||||
command = body & 255
|
||||
text += ' session=%d command=%s' % (body >> 8, BOOT.get(command, '%02X' % command))
|
||||
if msg == 12:
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT_STATUS requires DLC=8')
|
||||
text += ' status=%d slot=%d next_block=%d CRC32=%08X' % (data[0], data[1], number(data[2:4]), number(data[4:8]))
|
||||
elif command in (3, 4):
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT metadata requires DLC=8')
|
||||
if command == 3:
|
||||
text += ' image_size=%d CRC32=%08X' % (number(data[:4]), number(data[4:]))
|
||||
else:
|
||||
text += ' product=%d hw=%d..%d version=%08X' % (number(data[:2]), data[2], data[3], number(data[4:]))
|
||||
elif command in BOOT and data:
|
||||
raise ValueError('BOOT control requires DLC=0')
|
||||
elif msg in (10, 11):
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT_DATA requires DLC=8')
|
||||
text += ' block=%d offset=0x%X' % (body, body * 8)
|
||||
elif msg in (0, 1, 2):
|
||||
shift = 4 if msg == 0 else 12
|
||||
text += ' type=%X value=%X' % (body >> shift, body & ((1 << shift) - 1))
|
||||
result['summary'] = text
|
||||
return result
|
||||
|
||||
|
||||
class Reassembler:
|
||||
def __init__(self):
|
||||
self.pending = {}
|
||||
|
||||
def feed(self, ident, data, ss, es, now_ms):
|
||||
events = []
|
||||
for key, state in list(self.pending.items()):
|
||||
if now_ms - state['time'] >= 500:
|
||||
events.append((state['start'], es, None, 'SET CAN timeout ID=%08X' % key))
|
||||
del self.pending[key]
|
||||
if ident >> 24 != 0x12:
|
||||
return events
|
||||
try:
|
||||
if not 1 <= len(data) <= 8:
|
||||
raise ValueError('Invalid SET CAN DLC')
|
||||
pci = data[0] & 0xf0
|
||||
if pci == 0x30:
|
||||
if len(data) != 3 or data[0] & 15 > 2 or data[2] > 127:
|
||||
raise ValueError('Invalid FLOW_CONTROL')
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='FLOW_CONTROL status=%d block=%d st_min=%d ms' % (data[0] & 15, data[1], data[2])), None))
|
||||
elif pci == 0x10:
|
||||
if data[0] != 0x10 or len(data) != 8 or not 18 <= number(data[1:3]) <= 530:
|
||||
raise ValueError('Invalid FIRST segment')
|
||||
old = self.pending.pop(ident, None)
|
||||
if old:
|
||||
events.append((old['start'], es, None, 'FIRST replaced incomplete packet'))
|
||||
if len(self.pending) >= 64:
|
||||
raise ValueError('Too many concurrent SET CAN packets (64)')
|
||||
self.pending[ident] = dict(start=ss, time=now_ms, total=number(data[1:3]),
|
||||
data=bytearray(data[3:]), sequence=1)
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='FIRST length=%d' % number(data[1:3])), None))
|
||||
elif pci == 0x20:
|
||||
state = self.pending.get(ident)
|
||||
if not state or len(data) < 2 or data[0] & 15 != state['sequence']:
|
||||
raise ValueError('Unexpected CONSECUTIVE sequence')
|
||||
state['data'].extend(data[1:])
|
||||
state['time'], state['sequence'] = now_ms, (state['sequence'] + 1) & 15
|
||||
if len(state['data']) > state['total']:
|
||||
raise ValueError('SET CAN packet exceeds advertised length')
|
||||
if len(state['data']) == state['total']:
|
||||
packet = parse_frame(state['data'])
|
||||
if packet['protocol'] != 'SET v2' or packet['source'] != (ident >> 8) & 255 or packet['destination'] != (ident >> 16) & 255 or bool(packet['flags'] & 32) != bool(ident & 128):
|
||||
raise ValueError('CAN ID and SET header disagree')
|
||||
events.append((state['start'], es, packet, None))
|
||||
del self.pending[ident]
|
||||
else:
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='CONSECUTIVE SN=%d' % (data[0] & 15)), None))
|
||||
else:
|
||||
raise ValueError('Unknown SET CAN PCI')
|
||||
except ValueError as exc:
|
||||
state = self.pending.pop(ident, None)
|
||||
events.append((state['start'] if state else ss, es, None, str(exc)))
|
||||
return events
|
||||
133
python/logic_analyzer/decoders/set_uart.py
Normal file
133
python/logic_analyzer/decoders/set_uart.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Portable wire readers based on templates/c/set-protocol (no native DLL)."""
|
||||
import binascii
|
||||
|
||||
|
||||
V2 = {1: 'PING', 2: 'DEVICE_INFO', 3: 'CAPABILITIES', 8: 'DIAGNOSTICS',
|
||||
9: 'READ', 10: 'WRITE', 16: 'LOG_READ', 17: 'CATALOG',
|
||||
18: 'SUBSCRIBE', 19: 'PUBLISH', 20: 'UNSUBSCRIBE'}
|
||||
V2.update(dict((256 + i, 'FW_' + name) for i, name in enumerate(
|
||||
('BEGIN', 'DATA', 'END', 'ABORT', 'STATUS', 'ACTIVATE'))))
|
||||
V1 = dict(enumerate(('PING DEVICE_INFO GET_OBJECT SET_OBJECT GET_OBJECT_LIST '
|
||||
'GET_OBJECT_INFO COMMAND_STATUS DIAGNOSTICS READ_REGISTERS WRITE_REGISTERS '
|
||||
'FIRMWARE_BEGIN FIRMWARE_DATA FIRMWARE_END FIRMWARE_ABORT FIRMWARE_STATUS '
|
||||
'READ_LOGS GAS_CATALOG GAS_WATCH_SET GAS_WATCH_DATA').split(), 1))
|
||||
V1.update({128: 'NACK', 129: 'ACK', 130: 'ERROR'})
|
||||
V1.update(dict(enumerate(('SENSOR_SCAN SENSOR_LIST SENSOR_READ SENSOR_DATA '
|
||||
'SET_USER_BYTES SET_RESOLUTION SET_POLL_PERIOD SEND_ID_CAN UI_KEY UI_READ '
|
||||
'UI_STATE EEPROM_SCAN EEPROM_INFO EEPROM_READ EEPROM_LIST').split(), 32)))
|
||||
|
||||
|
||||
def number(data, order='little'):
|
||||
return int.from_bytes(bytes(data), order)
|
||||
|
||||
|
||||
def crc16(data, modbus=False):
|
||||
crc = 0xffff
|
||||
for byte in data:
|
||||
crc ^= byte if modbus else byte << 8
|
||||
for _ in range(8):
|
||||
if modbus:
|
||||
crc = (crc >> 1) ^ (0xa001 if crc & 1 else 0)
|
||||
else:
|
||||
crc = ((crc << 1) ^ (0x1021 if crc & 0x8000 else 0)) & 0xffff
|
||||
return crc
|
||||
|
||||
|
||||
def parse_frame(raw):
|
||||
raw = bytes(raw)
|
||||
bridge = raw[:2] == b'\xaa\x55'
|
||||
if bridge:
|
||||
if len(raw) < 11 or not 6 <= raw[2] <= 14 or len(raw) != raw[2] + 5:
|
||||
raise ValueError('Invalid ProtoCAN length')
|
||||
crc, received = crc16(raw[2:-2]), number(raw[-2:])
|
||||
ident, flags = number(raw[5:9]), raw[4]
|
||||
if ident > (0x1fffffff if flags & 1 else 0x7ff) or flags & 0xf0:
|
||||
raise ValueError('Invalid ProtoCAN ID/flags')
|
||||
result = dict(protocol='ProtoCAN bridge', sequence=raw[3], flags=flags,
|
||||
can_id=ident, payload=raw[9:-2])
|
||||
result['summary'] = 'ProtoCAN seq=%d ID=%08X flags=%02X' % (raw[3], ident, flags)
|
||||
else:
|
||||
if len(raw) < 12 or raw[:2] != b'\xa5\x5a' or raw[2] not in (1, 2):
|
||||
raise ValueError('Invalid SET signature/version')
|
||||
version = raw[2]
|
||||
header = 14 if version == 2 else 8
|
||||
if len(raw) < header + 4:
|
||||
raise ValueError('Incomplete SET header')
|
||||
size = number(raw[12:14]) if version == 2 else number(raw[6:8], 'big')
|
||||
if size > 512 or len(raw) != header + size + 4:
|
||||
raise ValueError('Invalid SET payload length')
|
||||
crc, received = binascii.crc32(raw[2:-4]) & 0xffffffff, number(raw[-4:])
|
||||
msg = number(raw[4:6]) if version == 2 else raw[3]
|
||||
seq = number(raw[10:12]) if version == 2 else number(raw[4:6], 'big')
|
||||
result = dict(protocol='SET v2' if version == 2 else 'SETGUI v1',
|
||||
message_type=msg, sequence=seq, payload=raw[header:-4])
|
||||
name = (V2 if version == 2 else V1).get(msg, 'TYPE_%04X' % msg)
|
||||
result['summary'] = '%s %s seq=%d len=%d' % (result['protocol'], name, seq, size)
|
||||
if version == 2:
|
||||
result.update(source=number(raw[6:8]), destination=number(raw[8:10]), flags=raw[3])
|
||||
result['summary'] += ' %04X->%04X flags=%02X' % (result['source'], result['destination'], raw[3])
|
||||
if raw[3] & 0xc0:
|
||||
raise ValueError('Reserved SET flags')
|
||||
if raw[3] & 1:
|
||||
if size < 2:
|
||||
raise ValueError('SET response missing status')
|
||||
result['status'] = number(result['payload'][:2])
|
||||
result['summary'] += ' status=%d' % result['status']
|
||||
if crc != received:
|
||||
raise ValueError('CRC mismatch: received %X expected %X' % (received, crc))
|
||||
result['raw'] = raw
|
||||
return result
|
||||
|
||||
|
||||
class StreamParser:
|
||||
"""Bounded byte stream; keep sample positions during resynchronization."""
|
||||
def __init__(self, mode='auto'):
|
||||
self.mode = mode
|
||||
self.buffer = []
|
||||
|
||||
def feed(self, value, start, end):
|
||||
self.buffer.append((value, start, end))
|
||||
events = []
|
||||
while len(self.buffer) >= 3:
|
||||
raw = bytes(item[0] for item in self.buffer)
|
||||
bridge = raw[:2] == b'\xaa\x55' and self.mode in ('auto', 'bridge')
|
||||
modern = raw[:2] == b'\xa5\x5a' and self.mode != 'bridge'
|
||||
if not bridge and not modern:
|
||||
del self.buffer[0]
|
||||
continue
|
||||
error = None
|
||||
if bridge:
|
||||
total = raw[2] + 5
|
||||
if not 6 <= raw[2] <= 14:
|
||||
error = 'Invalid ProtoCAN length'
|
||||
else:
|
||||
if raw[2] not in (1, 2) or self.mode in ('v1', 'v2') and raw[2] != int(self.mode[1]):
|
||||
error = 'Unexpected SET version'
|
||||
header = 14 if raw[2] == 2 else 8
|
||||
if not error and len(raw) < header:
|
||||
break
|
||||
size = number(raw[12:14]) if raw[2] == 2 else number(raw[6:8], 'big')
|
||||
total = header + size + 4
|
||||
if size > 512:
|
||||
error = 'Payload exceeds 512 bytes'
|
||||
if not error and len(raw) < total:
|
||||
break
|
||||
last = min(len(raw), total) - 1
|
||||
result = None
|
||||
if not error:
|
||||
try:
|
||||
result = parse_frame(raw[:total])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
events.append((self.buffer[0][1], self.buffer[last][2], result, error))
|
||||
del self.buffer[:1 if error else total]
|
||||
return events
|
||||
|
||||
def flush(self):
|
||||
if not self.buffer:
|
||||
return []
|
||||
pending = self.buffer
|
||||
self.buffer = []
|
||||
if pending[0][0] not in (0xa5, 0xaa):
|
||||
return []
|
||||
return [(pending[0][1], pending[-1][2], None, 'Incomplete frame')]
|
||||
299
python/logic_analyzer/files.py
Normal file
299
python/logic_analyzer/files.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Digital capture readers. Store edges, never expand captures to sample arrays.
|
||||
|
||||
DSView layout: DreamSourceLab/DSView libsigrok4DSL/session_driver.c.
|
||||
SAL v1 layout is validated against example/digitOSC; v3 RLE layout reference:
|
||||
https://github.com/nemanjan00/sigrok2sal/blob/master/SAL_FORMAT.md
|
||||
Unknown binary versions fail explicitly instead of guessing their layout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from bisect import bisect_right
|
||||
import configparser
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
|
||||
class ImportCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigitalChannel:
|
||||
name: str
|
||||
initial: int
|
||||
edges: array
|
||||
|
||||
def level_at(self, time):
|
||||
return self.initial ^ (bisect_right(self.edges, time) & 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigitalCapture:
|
||||
source: str
|
||||
format: str
|
||||
channels: list
|
||||
start: float
|
||||
end: float
|
||||
sample_rate: float = 0
|
||||
|
||||
|
||||
def _check(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
def read_capture(path, progress=lambda value: None, cancel=lambda: False):
|
||||
path = Path(path)
|
||||
try:
|
||||
if path.suffix.lower() == '.csv':
|
||||
result = _csv(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.')
|
||||
except (KeyError, struct.error, zipfile.BadZipFile, EOFError,
|
||||
configparser.Error, UnicodeError) as exc:
|
||||
raise ValueError('Повреждённый или неподдерживаемый файл: %s' % exc) from exc
|
||||
_check(cancel)
|
||||
if not result.channels or not math.isfinite(result.start) or not math.isfinite(result.end):
|
||||
raise ValueError('Файл не содержит цифровой записи.')
|
||||
progress(100)
|
||||
return result
|
||||
|
||||
|
||||
def _csv(path, progress, cancel):
|
||||
size = max(1, path.stat().st_size)
|
||||
consumed = 0
|
||||
channels = None
|
||||
previous = []
|
||||
start = end = None
|
||||
dsview = False
|
||||
with path.open(encoding='utf-8-sig', newline='') as stream:
|
||||
for number, line in enumerate(stream, 1):
|
||||
consumed += len(line)
|
||||
if number % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(min(99, int(consumed * 100 / size)))
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.lstrip().startswith((';', '#')):
|
||||
dsview |= 'DSView' in line
|
||||
continue
|
||||
if channels is None:
|
||||
delimiter = max((',', ';', '\t'), key=line.count)
|
||||
names = [v.strip() for v in next(csv.reader([line], delimiter=delimiter))]
|
||||
match = re.fullmatch(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', names[0], re.I)
|
||||
if not match or not 2 <= len(names) <= 65 or any(not n for n in names):
|
||||
raise ValueError('Ожидается CSV: Time [s], затем цифровые каналы (0/1).')
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError('Повторяющиеся имена каналов CSV.')
|
||||
factor = {'s': 1, 'ms': 1e-3, 'us': 1e-6, 'µs': 1e-6, 'ns': 1e-9}[(match[1] or 's').lower()]
|
||||
channels = [DigitalChannel(name, 0, array('d')) for name in names[1:]]
|
||||
continue
|
||||
row = next(csv.reader([line], delimiter=delimiter))
|
||||
try:
|
||||
time = float(row[0].strip().replace(',', '.')) * factor
|
||||
values = [v.strip() for v in row[1:]]
|
||||
if len(values) != len(channels) or any(v not in ('0', '1') for v in values):
|
||||
raise ValueError('цифровые уровни должны быть 0 или 1')
|
||||
if not math.isfinite(time) or (end is not None and time <= end):
|
||||
raise ValueError('время должно строго возрастать и быть конечным')
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise ValueError('CSV, строка %d: %s' % (number, exc)) from exc
|
||||
if start is None:
|
||||
start = time
|
||||
for channel, value in zip(channels, values):
|
||||
channel.initial = int(value)
|
||||
else:
|
||||
for i, channel in enumerate(channels):
|
||||
if values[i] != previous[i]:
|
||||
channel.edges.append(time)
|
||||
previous = values
|
||||
end = time
|
||||
if start is None:
|
||||
raise ValueError('CSV не содержит данных.')
|
||||
return DigitalCapture(str(path), 'DSLogic CSV' if dsview else 'Logic 2 CSV', channels, start, end)
|
||||
|
||||
|
||||
class _Binary:
|
||||
def __init__(self, data):
|
||||
self.data, self.pos = data, 0
|
||||
|
||||
def take(self, size):
|
||||
if size < 0 or self.pos + size > len(self.data):
|
||||
raise ValueError('Обрезанный двоичный блок.')
|
||||
value = self.data[self.pos:self.pos + size]
|
||||
self.pos += size
|
||||
return value
|
||||
|
||||
def unpack(self, fmt):
|
||||
return struct.unpack('<' + fmt, self.take(struct.calcsize('<' + fmt)))
|
||||
|
||||
|
||||
def _runs(data):
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
value = data[pos]
|
||||
pos += 1
|
||||
if value >= 128:
|
||||
raise ValueError('Некорректный RLE SAL.')
|
||||
if value & 64:
|
||||
value &= 63
|
||||
for _ in range(9):
|
||||
if pos == len(data):
|
||||
raise ValueError('Обрезанный RLE SAL.')
|
||||
byte = data[pos]
|
||||
pos += 1
|
||||
value = (value << 7) | (byte & 127)
|
||||
if not byte & 128:
|
||||
break
|
||||
else:
|
||||
raise ValueError('Слишком длинный RLE SAL.')
|
||||
yield value + 1
|
||||
|
||||
|
||||
def _sal_channel(data, name, cancel):
|
||||
reader = _Binary(data)
|
||||
if reader.take(8) != b'<SALEAE>':
|
||||
raise ValueError('Неверная сигнатура SAL.')
|
||||
version, kind = reader.unpack('II')
|
||||
if version not in (1, 3, 4) or kind != 100:
|
||||
raise ValueError('Не поддерживается цифровой SAL версии %d, тип %d.' % (version, kind))
|
||||
schema, rate, unix_ms, fractional_ms = reader.unpack('BdQd')
|
||||
if schema != 1 or not math.isfinite(rate) or rate <= 0:
|
||||
raise ValueError('Некорректный заголовок канала SAL.')
|
||||
for _ in range(2):
|
||||
optional, = reader.unpack('B')
|
||||
if optional not in (0, 1):
|
||||
raise ValueError('Неизвестный заголовок SAL.')
|
||||
if optional:
|
||||
reader.take(8)
|
||||
count, = reader.unpack('Q')
|
||||
if not 0 < count <= len(data) // 26:
|
||||
raise ValueError('Неверное число блоков SAL.')
|
||||
channel = DigitalChannel(name, 0, array('d'))
|
||||
previous_end = None
|
||||
level = 0
|
||||
for chunk in range(count):
|
||||
_check(cancel)
|
||||
if version == 1:
|
||||
begin, end, samples, chunk_rate, scale, length = reader.unpack('6Q')
|
||||
if samples != end - begin or chunk_rate != rate or scale != 1:
|
||||
raise ValueError('Неподдерживаемая шкала блока SAL.')
|
||||
encoded = reader.take(length)
|
||||
entries, = reader.unpack('Q')
|
||||
if not entries:
|
||||
raise ValueError('В SAL отсутствует индекс начального уровня.')
|
||||
index = reader.take(entries * 20)
|
||||
offset, byte_offset, initial = struct.unpack_from('<QQI', index)
|
||||
if offset or byte_offset:
|
||||
raise ValueError('Некорректный индекс SAL.')
|
||||
else:
|
||||
begin, end, initial, flags, length = reader.unpack('QQBBQ')
|
||||
if flags:
|
||||
raise ValueError('Неизвестные флаги SAL.')
|
||||
encoded = reader.take(length)
|
||||
if initial not in (0, 1) or end <= begin or (previous_end is not None and begin != previous_end):
|
||||
raise ValueError('Некорректные границы или уровень блока SAL.')
|
||||
if chunk == 0:
|
||||
first = begin
|
||||
channel.initial = initial
|
||||
elif initial != level:
|
||||
channel.edges.append(begin / rate)
|
||||
level = initial
|
||||
position = begin
|
||||
for run_index, run in enumerate(_runs(encoded)):
|
||||
if run_index % 8192 == 0:
|
||||
_check(cancel)
|
||||
position += run
|
||||
if position > end:
|
||||
raise ValueError('RLE выходит за границы блока SAL.')
|
||||
if position < end:
|
||||
channel.edges.append(position / rate)
|
||||
level ^= 1
|
||||
if position != end:
|
||||
raise ValueError('Неполный RLE блока SAL.')
|
||||
previous_end = end
|
||||
if reader.pos != len(data):
|
||||
raise ValueError('Неизвестные дополнительные данные SAL.')
|
||||
return channel, first / rate, previous_end / rate, rate
|
||||
|
||||
|
||||
def _sal(path, archive, progress, cancel):
|
||||
meta = json.loads(archive.read('meta.json'))
|
||||
names = {}
|
||||
for row in meta.get('data', {}).get('rowsSettings', []):
|
||||
channel = row.get('channel', {})
|
||||
if channel.get('type') == 'Digital':
|
||||
names[channel.get('deviceChannel')] = row.get('name')
|
||||
files = sorted((int(m[1]), name) for name in archive.namelist()
|
||||
for m in [re.fullmatch(r'digital-(\d+)\.bin', name)] if m)
|
||||
if not files:
|
||||
raise ValueError('В SAL нет цифровых каналов.')
|
||||
channels, bounds = [], []
|
||||
for i, (index, filename) in enumerate(files):
|
||||
_check(cancel)
|
||||
channel, start, end, rate = _sal_channel(archive.read(filename), names.get(index) or 'D%d' % index, cancel)
|
||||
channels.append(channel)
|
||||
bounds.append((start, end, rate))
|
||||
progress(int((i + 1) * 100 / len(files)))
|
||||
if any(bound != bounds[0] for bound in bounds):
|
||||
raise ValueError('SAL с разными временными границами каналов не поддерживается.')
|
||||
return DigitalCapture(str(path), 'Logic 2 SAL', channels, *bounds[0])
|
||||
|
||||
|
||||
def _dsl(path, archive, progress, cancel):
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read_string(archive.read('header').decode('utf-8-sig'))
|
||||
version = config.getint('version', 'version')
|
||||
header = config['header']
|
||||
if version not in (2, 3) or header.getint('device mode', 0) != 0:
|
||||
raise ValueError('Поддерживаются цифровые сессии DSView DSL версий 2 и 3.')
|
||||
match = re.fullmatch(r'\s*([\d.]+)\s*([kKmMgG]?)(?:Hz)?\s*', header['samplerate'])
|
||||
if not match:
|
||||
raise ValueError('Неверная частота DSL.')
|
||||
rate = float(match[1]) * {'': 1, 'k': 1e3, 'm': 1e6, 'g': 1e9}[match[2].lower()]
|
||||
samples = header.getint('total samples')
|
||||
blocks = header.getint('total blocks')
|
||||
if not math.isfinite(rate) or rate <= 0 or samples <= 0 or blocks <= 0:
|
||||
raise ValueError('Некорректный размер или частота DSL.')
|
||||
probes = sorted((int(m[1]), name) for key, name in header.items()
|
||||
for m in [re.fullmatch(r'probe(\d+)', key)] if m)
|
||||
channels = []
|
||||
for i, (index, name) in enumerate(probes):
|
||||
channel = DigitalChannel(name, 0, array('d'))
|
||||
position, level = 0, 0
|
||||
for block in range(blocks):
|
||||
_check(cancel)
|
||||
# DSView v3 stores enabled channels in sequential directories.
|
||||
directory = i if version == 3 else index
|
||||
data = archive.read('L-%d/%d' % (directory, block))
|
||||
for offset, byte in enumerate(data):
|
||||
if offset % 65536 == 0:
|
||||
_check(cancel)
|
||||
if position >= samples:
|
||||
break
|
||||
if position == 0:
|
||||
channel.initial = level = byte & 1
|
||||
changes = byte ^ ((byte << 1) & 255 | level)
|
||||
while changes:
|
||||
bit = (changes & -changes).bit_length() - 1
|
||||
if position + bit < samples:
|
||||
channel.edges.append((position + bit) / rate)
|
||||
changes &= changes - 1
|
||||
level = byte >> 7
|
||||
position += 8
|
||||
if position < samples:
|
||||
raise ValueError('Обрезанные данные DSL, канал %s.' % name)
|
||||
channels.append(channel)
|
||||
progress(int((i + 1) * 100 / max(1, len(probes))))
|
||||
return DigitalCapture(str(path), 'DSLogic DSL', channels, 0, samples / rate, rate)
|
||||
155
python/logic_analyzer/saleae.py
Normal file
155
python/logic_analyzer/saleae.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Local Logic 2 acquisition through the optional Saleae Automation API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from logic_analyzer.files import DigitalCapture, DigitalChannel, ImportCancelled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SaleaeSettings:
|
||||
device_id: str
|
||||
channels: tuple = (0, 1)
|
||||
sample_rate: int = 24000000
|
||||
duration: float = 1.0 # Wall-clock stop timer; zero means manual stop.
|
||||
threshold: float = None
|
||||
buffer_mb: int = 256
|
||||
port: int = 10430
|
||||
|
||||
def validate(self):
|
||||
if not self.device_id:
|
||||
raise ValueError('Выберите устройство Saleae.')
|
||||
if (not self.channels or len(set(self.channels)) != len(self.channels)
|
||||
or any(type(c) is not int or not 0 <= c <= 15 for c in self.channels)):
|
||||
raise ValueError('Выберите цифровые каналы D0…D15 без повторений.')
|
||||
if not 1 <= self.sample_rate <= 500000000:
|
||||
raise ValueError('Частота должна быть от 1 до 500000000 выборок/с.')
|
||||
if not math.isfinite(self.duration) or not 0 <= self.duration <= 3600:
|
||||
raise ValueError('Таймер должен быть от 0 до 3600 с.')
|
||||
if self.threshold not in (None, 1.2, 1.8, 3.3):
|
||||
raise ValueError('Неподдерживаемый порог цифрового входа.')
|
||||
if not 16 <= self.buffer_mb <= 4096 or not 1 <= self.port <= 65535:
|
||||
raise ValueError('Некорректный буфер или порт Automation API.')
|
||||
|
||||
|
||||
def automation_api():
|
||||
try:
|
||||
from saleae import automation
|
||||
except ImportError as exc:
|
||||
raise RuntimeError('Не установлен logic2-automation. Для запуска из Python: '
|
||||
'pip install logic2-automation; для EXE нужна сборка с этим пакетом.') from exc
|
||||
return automation
|
||||
|
||||
|
||||
def connect(api, port):
|
||||
# Bound every RPC, including stop/export/close, when Logic 2 stops responding.
|
||||
config = json.dumps({'methodConfig': [{'name': [{}], 'timeout': '30s'}]})
|
||||
try:
|
||||
return api.Manager.connect(port=port, connect_timeout_seconds=3,
|
||||
grpc_channel_arguments=[('grpc.service_config', config)])
|
||||
except Exception as exc:
|
||||
raise RuntimeError('Нет связи с Logic 2 на localhost:%d. Запустите Logic 2 и '
|
||||
'включите Preferences → Enable Automation Server. %s' % (port, exc)) from exc
|
||||
|
||||
|
||||
def list_devices(port=10430, simulation=False):
|
||||
api = automation_api()
|
||||
with connect(api, port) as manager:
|
||||
return manager.get_devices(include_simulation_devices=simulation)
|
||||
|
||||
|
||||
def read_binary(directory, channels, cancel=lambda: False):
|
||||
"""Read documented Saleae digital binary v0, preserving constant tails."""
|
||||
result, bounds = [], None
|
||||
header = struct.Struct('<8siiIddQ')
|
||||
for channel in channels:
|
||||
path = Path(directory) / ('digital_%d.bin' % channel)
|
||||
with path.open('rb') as stream:
|
||||
raw = stream.read(header.size)
|
||||
if len(raw) != header.size:
|
||||
raise ValueError('Обрезанный заголовок Saleae: ' + path.name)
|
||||
magic, version, kind, initial, start, end, count = header.unpack(raw)
|
||||
if magic != b'<SALEAE>' or version != 0 or kind != 0:
|
||||
raise ValueError('Неподдерживаемый бинарный экспорт Saleae (нужен digital v0).')
|
||||
if (initial not in (0, 1) or not math.isfinite(start) or not math.isfinite(end)
|
||||
or end < start or path.stat().st_size != header.size + count * 8):
|
||||
raise ValueError('Повреждённый бинарный экспорт Saleae.')
|
||||
if bounds is not None and bounds != (start, end):
|
||||
raise ValueError('Границы каналов Saleae не совпадают.')
|
||||
bounds = start, end
|
||||
edges = array('d')
|
||||
previous = start
|
||||
while count:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
size = min(count, 65536)
|
||||
block = array('d')
|
||||
block.frombytes(stream.read(size * 8))
|
||||
if sys.byteorder != 'little':
|
||||
block.byteswap()
|
||||
for edge in block:
|
||||
if not math.isfinite(edge) or edge < previous or edge > end:
|
||||
raise ValueError('Некорректное время фронта Saleae.')
|
||||
previous = edge
|
||||
edges.extend(block)
|
||||
count -= size
|
||||
result.append(DigitalChannel('D%d' % channel, initial, edges))
|
||||
if not result:
|
||||
raise ValueError('Нет цифровых каналов Saleae.')
|
||||
return DigitalCapture('', 'Saleae ONLINE', result, *bounds)
|
||||
|
||||
|
||||
def acquire(settings, stop, cancel, status=lambda text: None):
|
||||
"""Stop exactly once; cancel discards data, stop imports the captured window."""
|
||||
settings.validate()
|
||||
api = automation_api()
|
||||
status('Подключение к Logic 2…')
|
||||
with connect(api, settings.port) as manager:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
device = api.LogicDeviceConfiguration(
|
||||
enabled_digital_channels=list(settings.channels),
|
||||
digital_sample_rate=settings.sample_rate,
|
||||
digital_threshold_volts=settings.threshold)
|
||||
configuration = api.CaptureConfiguration(buffer_size_megabytes=settings.buffer_mb,
|
||||
capture_mode=api.ManualCaptureMode())
|
||||
capture = manager.start_capture(device_id=settings.device_id,
|
||||
device_configuration=device,
|
||||
capture_configuration=configuration)
|
||||
stopped = False
|
||||
try:
|
||||
status('Захват Saleae… Нажмите «Стоп и показать» для завершения.')
|
||||
deadline = time.monotonic() + settings.duration if settings.duration else None
|
||||
while not stop() and not cancel():
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(.02)
|
||||
stopped = True # Never retry stop(), even if it raises.
|
||||
capture.stop()
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
status('Получение цифровых каналов…')
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-saleae-') as directory:
|
||||
capture.export_raw_data_binary(directory, digital_channels=list(settings.channels),
|
||||
analog_channels=[])
|
||||
result = read_binary(directory, settings.channels, cancel)
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
result.source = 'Saleae %s · %g MS/s' % (settings.device_id, settings.sample_rate / 1e6)
|
||||
result.sample_rate = settings.sample_rate
|
||||
return result
|
||||
finally:
|
||||
if not stopped:
|
||||
with suppress(Exception):
|
||||
capture.stop()
|
||||
with suppress(Exception):
|
||||
capture.close()
|
||||
Reference in New Issue
Block a user