390 lines
19 KiB
Python
390 lines
19 KiB
Python
"""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
|
||
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
|
||
|
||
|
||
@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('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
|
||
# 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 активен в начале диапазона: начало импульса не записано')
|
||
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.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', '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.')
|
||
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)
|