Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
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