Files
templates/python/logic_analyzer/saleae.py

156 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()