359 lines
16 KiB
Python
359 lines
16 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
|
|
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)
|