Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
253
python/set_devices/protocol_capture.py
Normal file
253
python/set_devices/protocol_capture.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Bounded host traffic recording and synthetic DSView v3 logic captures.
|
||||
|
||||
No hardware timing is inferred: timestamps are host observations and overlapping
|
||||
packets on one wire are serialized. Original events remain in the archive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from time import monotonic_ns
|
||||
import zipfile
|
||||
|
||||
|
||||
def uart_event(source: str, direction: str, data: bytes, rate: int) -> dict:
|
||||
return dict(source=source, kind="uart", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns())
|
||||
|
||||
|
||||
def can_event(source: str, direction: str, identifier: int, data: bytes,
|
||||
rate: int, extended: bool = True, remote: bool = False) -> dict:
|
||||
return dict(source=source, kind="can", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns(), identifier=identifier,
|
||||
extended=extended, remote=remote)
|
||||
|
||||
|
||||
def validate_event(event: dict) -> None:
|
||||
if event['kind'] not in ('uart', 'can') or event['direction'] not in ('RX', 'TX'):
|
||||
raise ValueError('Unknown traffic type/direction')
|
||||
if not isinstance(event['rate'], int) or not 1 <= event['rate'] <= 10_000_000:
|
||||
raise ValueError('Invalid line rate')
|
||||
if not isinstance(event['time_ns'], int) or event['time_ns'] < 0:
|
||||
raise ValueError('Invalid event timestamp')
|
||||
if not event['source'] or any(c in event['source'] for c in '\r\n=[]'):
|
||||
raise ValueError('Invalid source name')
|
||||
if event['kind'] == 'can':
|
||||
if len(event['data']) > 8 or not 0 <= event['identifier'] <= (0x1fffffff if event['extended'] else 0x7ff):
|
||||
raise ValueError('Only classic CAN IDs and DLC 0..8 are supported')
|
||||
|
||||
|
||||
class TrafficRecording:
|
||||
MAX_EVENTS = 100_000
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[dict] = []
|
||||
self.byte_count = 0
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
validate_event(event)
|
||||
data = bytes(event['data'])
|
||||
if event['kind'] == 'uart' and not data:
|
||||
return
|
||||
if len(self.events) >= self.MAX_EVENTS or self.byte_count + len(data) > self.MAX_BYTES:
|
||||
raise ValueError('Достигнут лимит записи: 100 000 событий / 8 МиБ данных')
|
||||
self.events.append(dict(event, data=data))
|
||||
self.byte_count += len(data)
|
||||
|
||||
|
||||
def bits(value: int, count: int):
|
||||
return [(value >> i) & 1 for i in range(count-1, -1, -1)]
|
||||
|
||||
|
||||
def uart_bits(data: bytes):
|
||||
for byte in data:
|
||||
yield 0
|
||||
for i in range(8):
|
||||
yield (byte >> i) & 1
|
||||
yield 1
|
||||
|
||||
|
||||
def can_bits(event: dict):
|
||||
"""CAN 2.0 data/remote frame, CRC15, stuffing, synthetic successful ACK."""
|
||||
ident, remote, payload = event['identifier'], event['remote'], event['data']
|
||||
if event['extended']:
|
||||
raw = [0] + bits(ident >> 18, 11) + [1, 1] + bits(ident & 0x3ffff, 18) + [int(remote), 0, 0]
|
||||
else:
|
||||
raw = [0] + bits(ident, 11) + [int(remote), 0, 0]
|
||||
raw += bits(len(payload), 4)
|
||||
if not remote:
|
||||
for byte in payload:
|
||||
raw += bits(byte, 8)
|
||||
crc = 0
|
||||
for bit in raw:
|
||||
feedback = ((crc >> 14) & 1) ^ bit
|
||||
crc = (crc << 1) & 0x7fff
|
||||
if feedback:
|
||||
crc ^= 0x4599
|
||||
raw += bits(crc, 15)
|
||||
last, run = None, 0
|
||||
for bit in raw:
|
||||
yield bit
|
||||
run = run + 1 if bit == last else 1
|
||||
last = bit
|
||||
if run == 5:
|
||||
last = 1 - bit
|
||||
yield last
|
||||
run = 1
|
||||
# CRC delimiter, reconstructed ACK, ACK delimiter, EOF, intermission.
|
||||
yield from [1, 0, 1] + [1] * 10
|
||||
|
||||
|
||||
def channel_name(event):
|
||||
# A reconnect at another line rate must remain independently decodable.
|
||||
return event['source'] + ('_' + event['direction'] if event['kind'] == 'uart' else '_CAN') + '_' + str(event['rate'])
|
||||
|
||||
|
||||
class _PackedWriter:
|
||||
"""LSB-first samples streamed in bounded chunks, including long idle runs."""
|
||||
def __init__(self, stream, cancelled):
|
||||
self.stream, self.cancelled = stream, cancelled
|
||||
self.partial = self.used = 0
|
||||
self.buffer = bytearray()
|
||||
|
||||
def _flush(self):
|
||||
if self.cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
self.stream.write(self.buffer)
|
||||
self.buffer.clear()
|
||||
|
||||
def run(self, level: int, count: int):
|
||||
if count < 0:
|
||||
raise ValueError('Non-monotonic waveform')
|
||||
if self.used:
|
||||
take = min(count, 8 - self.used)
|
||||
if level:
|
||||
self.partial |= ((1 << take)-1) << self.used
|
||||
self.used += take
|
||||
count -= take
|
||||
if self.used == 8:
|
||||
self.buffer.append(self.partial)
|
||||
self.partial = self.used = 0
|
||||
full, tail = divmod(count, 8)
|
||||
while full:
|
||||
take = min(full, 65536)
|
||||
self.buffer.extend(bytes([255 if level else 0]) * take)
|
||||
full -= take
|
||||
if len(self.buffer) >= 65536:
|
||||
self._flush()
|
||||
if tail:
|
||||
self.partial = (1 << tail)-1 if level else 0
|
||||
self.used = tail
|
||||
|
||||
def finish(self):
|
||||
if self.used:
|
||||
raise ValueError('Unaligned sample count')
|
||||
self._flush()
|
||||
|
||||
|
||||
def export_dsl(path, events, samplerate=20_000_000, *, overwrite=False,
|
||||
cancelled=lambda: False, progress=lambda value: None):
|
||||
"""Atomic export. Existing destination survives failed/cancelled exports."""
|
||||
path = Path(path)
|
||||
if not events:
|
||||
raise ValueError('Нет записанного трафика')
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(path)
|
||||
if not isinstance(samplerate, int) or not 1 <= samplerate <= 100_000_000:
|
||||
raise ValueError('Частота дискретизации должна быть от 1 до 100 МГц')
|
||||
groups = {}
|
||||
origin = min(event['time_ns'] for event in events)
|
||||
shifted = 0
|
||||
for event in events:
|
||||
validate_event(event)
|
||||
if samplerate < event['rate'] * 8:
|
||||
raise ValueError('Частота дискретизации должна быть не меньше 8× скорости линии')
|
||||
groups.setdefault(channel_name(event), []).append(event)
|
||||
if len(groups) > 32:
|
||||
raise ValueError('DSView: максимум 32 цифровых канала')
|
||||
schedule, maximum = {}, 0
|
||||
for name, channel_events in groups.items():
|
||||
cursor = 16
|
||||
scheduled = []
|
||||
for event in sorted(channel_events, key=lambda item: item['time_ns']):
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
data_bits = list(can_bits(event)) if event['kind'] == 'can' else None
|
||||
bit_count = len(data_bits) if data_bits is not None else len(event['data']) * 10
|
||||
wanted = 16 + (event['time_ns'] - origin) * samplerate // 1_000_000_000
|
||||
start = max(cursor, wanted)
|
||||
shifted += int(start > wanted)
|
||||
count = (bit_count * samplerate + event['rate'] - 1) // event['rate']
|
||||
scheduled.append((start, event, data_bits, bit_count))
|
||||
cursor = start + count
|
||||
maximum = max(maximum, cursor + 16)
|
||||
schedule[name] = scheduled
|
||||
padded = (maximum + 63) // 64 * 64
|
||||
if padded > 2_000_000_000:
|
||||
raise ValueError('Запись превышает 2 млрд отсчётов: сократите длительность или частоту')
|
||||
names = list(groups)
|
||||
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 = 1\n'
|
||||
f'samplerate = {samplerate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||||
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
|
||||
session = {'Device': 'virtual-session', 'DeviceMode': 0, 'Version': 3,
|
||||
'Title': 'DSView v1.3.2', 'Sample count': str(padded),
|
||||
'Sample rate': str(samplerate), 'Max Height': '1X', 'decoder': [],
|
||||
'channel': [{'colour': 'default', 'enabled': True, 'index': i,
|
||||
'name': name, 'strigger': 0, 'type': 10000, 'view_index': i}
|
||||
for i, name in enumerate(names)]}
|
||||
fd, temporary = tempfile.mkstemp(prefix='.setgui-dsl-', suffix='.tmp', dir=path.parent)
|
||||
os.close(fd)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('header', header)
|
||||
archive.writestr('session', json.dumps(session, ensure_ascii=False))
|
||||
archive.writestr('decoders', '[]')
|
||||
metadata = dict(schema='setgui.protocol-capture.v1', synthetic=True,
|
||||
timing='host observation; overlapping events serialized per wire',
|
||||
uart='8N1, non-inverted', can_ack='synthetic dominant ACK',
|
||||
shifted_events=shifted, samplerate=samplerate,
|
||||
origin_monotonic_ns=origin,
|
||||
events=[dict(event, data=event['data'].hex()) for event in events])
|
||||
archive.writestr('protocol-log.json', json.dumps(metadata, ensure_ascii=False))
|
||||
done, last_progress = 0, -1
|
||||
for index, name in enumerate(names):
|
||||
with archive.open(f'L-{index}/0', 'w', force_zip64=True) as output:
|
||||
writer = _PackedWriter(output, cancelled)
|
||||
cursor = 0
|
||||
for start, event, data_bits, bit_count in schedule[name]:
|
||||
writer.run(1, start - cursor)
|
||||
bitstream = data_bits if data_bits is not None else uart_bits(event['data'])
|
||||
previous = 0
|
||||
for i, level in enumerate(bitstream, 1):
|
||||
boundary = (i * samplerate + event['rate'] - 1) // event['rate']
|
||||
writer.run(level, boundary - previous)
|
||||
previous = boundary
|
||||
cursor = start + previous
|
||||
done += 1
|
||||
value = done * 95 // len(events)
|
||||
if value != last_progress:
|
||||
progress(value)
|
||||
last_progress = value
|
||||
writer.run(1, padded - cursor)
|
||||
writer.finish()
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
with zipfile.ZipFile(temporary) as archive:
|
||||
if archive.testzip() is not None:
|
||||
raise ValueError('Ошибка проверки архива DSL')
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
if overwrite:
|
||||
os.replace(temporary, path)
|
||||
else:
|
||||
# Atomic no-clobber publication on the same filesystem.
|
||||
os.link(temporary, path)
|
||||
progress(100)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return dict(samples=padded, samplerate=samplerate, channels=names, shifted_events=shifted)
|
||||
Reference in New Issue
Block a user