156 lines
7.3 KiB
Python
156 lines
7.3 KiB
Python
"""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()
|