Добавить протокол Altera Logic и общие клиенты прошивки

This commit is contained in:
2026-09-19 07:12:09 +03:00
parent def3eb08f3
commit 80ba17d77d
38 changed files with 3271 additions and 10 deletions

1
python/altera_logic/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
native/

View File

@@ -0,0 +1,4 @@
"""Portable Altera analyzer client. Wire protocol and sequencing live in C99."""
from .native import Capture, NativeAnalyzer
__all__ = ["Capture", "NativeAnalyzer"]

View File

@@ -0,0 +1,89 @@
"""Presentation state for waveform rulers; no transport or wire processing."""
from dataclasses import dataclass
def drag_zoom_factor(delta_pixels):
"""Visual gesture mapping: 200 pixels doubles the selected axis."""
return 2 ** (max(-1000., min(1000., delta_pixels)) / 200.)
@dataclass
class Marker:
name: str
axis: str
value: float
color: str
visible: bool = False
class WaveformMarkers:
def __init__(self):
self.items = [Marker('X1', 'x', .2, '#ffce63'),
Marker('X2', 'x', .4, '#ffce63'),
Marker('X3', 'x', .6, '#e794ff'),
Marker('X4', 'x', .8, '#e794ff'),
Marker('Y1', 'y', .2, '#72e7ff'),
Marker('Y2', 'y', .4, '#72e7ff'),
Marker('Y3', 'y', .6, '#a9df79'),
Marker('Y4', 'y', .8, '#a9df79')]
self._math = None
self.session = None
self.bounds = None
def window(self, session, start, end):
if session != self.session or self.bounds is None:
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
marker.value = start + (end-start)*fraction
self.session, self.bounds = session, (start, end)
def reset(self):
if self.bounds:
start, end = self.bounds
for marker, fraction in zip(self.items[:4], (.2, .4, .6, .8)):
marker.value = start + (end-start)*fraction
for marker, fraction in zip(self.items[4:], (.2, .4, .6, .8)):
marker.value = fraction
def measurements(self, period_ns):
"""Use the same native delta operation as Android's plotDelta."""
if self._math is None:
from protocan.plot import PlotMath
from .native import NativeAnalyzer
self._math = PlotMath(NativeAnalyzer().lib)
result = []
for index in range(0, 8, 2):
a, b = self.items[index:index+2]
if not (a.visible and b.visible):
continue
if a.axis == 'x':
if self.bounds is None:
continue
factor = period_ns / 1e9
av, bv = a.value*factor, b.value*factor
delta = self._math.delta(a.value, b.value, factor)
frequency = 1/abs(delta) if delta else None
else:
av, bv = 100*(1-a.value), 100*(1-b.value)
delta = self._math.delta(av, bv)
frequency = None
result.append((a, b, av, bv, delta, frequency))
return result
def place(self, index, fraction):
marker = self.items[index]
fraction = min(1., max(0., fraction))
if marker.axis == 'x':
if self.bounds is None:
return
start, end = self.bounds
marker.value = start + (end-start)*fraction
else:
marker.value = fraction
def fraction(self, marker):
if marker.axis == 'y':
return marker.value
if self.bounds is None:
return 0.
start, end = self.bounds
return (marker.value-start)/max(1, end-start)

View File

@@ -0,0 +1,131 @@
"""ctypes binding only: no Python packet codec or protocol fallback."""
from __future__ import annotations
import csv
import ctypes as C
import os
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Capture:
samples: tuple[int, ...]
trigger_index: int
sample_rate: float
demo: bool = False
def save_csv(self, path):
with open(path, "w", newline="", encoding="utf-8") as stream:
writer = csv.writer(stream)
writer.writerow(["index", "time_s", "sample_hex", "demo"] +
["D%d" % n for n in range(16)])
for i, sample in enumerate(self.samples):
writer.writerow([i, (i-self.trigger_index)/self.sample_rate,
"%04X" % sample, int(self.demo)] +
[(sample >> n) & 1 for n in range(16)])
class NativeAnalyzer:
CONNECTING, READY, CAPTURING, DONE, ERROR = range(5)
ERROR_TEXT = {
1: "Неверный формат ответа FPGA",
2: "Ошибка контрольной суммы ответа",
3: "FPGA отклонила команду",
4: "Тайм-аут UART. При потере синхронизации нужен RESET_N и повторная настройка",
5: "Неподдерживаемая конфигурация FPGA: ожидается 16 каналов, 4096 выборок, версия 1",
6: "Недопустимые или противоречивые настройки триггера",
7: "Ошибка последовательного порта",
}
def __init__(self, library=None):
if library is None:
explicit = os.environ.get("ALTERA_LOGIC_LIBRARY")
name = "setprotocol.dll" if sys.platform == "win32" else "libsetprotocol.so"
bundled = Path(getattr(sys, "_MEIPASS", "")) / "gui_desktop/native" / name
local = Path(__file__).resolve().parent / "native" / name
path = Path(explicit) if explicit else (bundled if bundled.is_file() else local)
try:
library = C.CDLL(str(path))
except OSError as exc:
raise RuntimeError("Не загружено C-ядро Altera Logic. Соберите templates/"
"c/set-protocol/tools/build_host.py; " + str(exc)) from exc
self.lib = library
signatures = {
"la_context_size": ([], C.c_size_t),
"la_init": ([C.c_void_p], None),
"la_start": ([C.c_void_p] + [C.c_uint32]*5, C.c_int),
"la_next": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"la_feed": ([C.c_void_p, C.c_void_p, C.c_size_t], None),
"la_tick": ([C.c_void_p, C.c_uint32], None),
"la_fail": ([C.c_void_p, C.c_uint32], None),
"la_get": ([C.c_void_p, C.c_uint32], C.c_uint32),
"la_samples": ([C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"la_demo_init": ([C.c_void_p], None),
"la_demo_capture": ([C.c_void_p, C.c_uint32], C.c_int),
}
try:
for name, (args, result) in signatures.items():
fn = getattr(self.lib, name)
fn.argtypes, fn.restype = args, result
except AttributeError as exc:
raise RuntimeError("C-ядро устарело: пересоберите templates с Altera Logic") from exc
# Explicitly aligned caller-owned storage; C never allocates memory.
self.ctx = (C.c_uint64 * ((self.lib.la_context_size()+7)//8))()
self.demo = False
self.reset()
def reset(self, demo=False):
self.demo = demo
(self.lib.la_demo_init if demo else self.lib.la_init)(self.ctx)
def get(self, field):
return int(self.lib.la_get(self.ctx, field))
@property
def state(self):
return self.get(0)
@property
def error(self):
return self.ERROR_TEXT.get(self.get(1), "Ошибка обмена")
@property
def progress(self):
return self.get(7)
@property
def flags(self):
return self.get(5)
def start(self, divider, mask=0, value=0, edge_mask=0, edge_value=0):
values = (divider, mask, value, edge_mask, edge_value)
if any(not isinstance(v, int) or not 0 <= v <= 65535 for v in values):
raise ValueError(self.ERROR_TEXT[6])
result = (self.lib.la_demo_capture(self.ctx, divider) if self.demo else
self.lib.la_start(self.ctx, *values))
if result:
raise ValueError(self.ERROR_TEXT.get(result, "Захват уже выполняется"))
def next_request(self):
out = (C.c_ubyte*6)()
size = self.lib.la_next(self.ctx, out, len(out))
return bytes(out[:size])
def feed(self, data):
self.lib.la_feed(self.ctx, data, len(data))
def tick(self, elapsed_ms):
self.lib.la_tick(self.ctx, max(0, min(int(elapsed_ms), 0xffffffff)))
def fail_io(self):
self.lib.la_fail(self.ctx, 7)
def capture(self):
out = (C.c_uint16*4096)()
count = self.lib.la_samples(self.ctx, out, len(out))
if not count:
raise RuntimeError("Запись ещё не завершена")
return Capture(tuple(out[:count]), self.get(6),
self.get(4)/(self.get(8)+1), self.demo)

View File

@@ -0,0 +1,27 @@
"""Qt presentation port: wheel pans X; Ctrl+wheel pans Y, never zooms."""
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QScrollArea
class PlotScrollArea(QScrollArea):
user_scrolled = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._remainder = {'x': 0., 'y': 0.}
def wheelEvent(self, event):
axis = 'y' if event.modifiers() & Qt.KeyboardModifier.ControlModifier else 'x'
bar = self.verticalScrollBar() if axis == 'y' else self.horizontalScrollBar()
pixels, angle = event.pixelDelta(), event.angleDelta()
if not pixels.isNull():
delta = pixels.y() if pixels.y() else pixels.x()
else:
delta = (angle.y() if angle.y() else angle.x()) / 120. * 60.
self._remainder[axis] -= delta
movement = int(self._remainder[axis])
self._remainder[axis] -= movement
if delta:
self.user_scrolled.emit(axis)
bar.setValue(bar.value()+movement)
event.accept()

View File

@@ -0,0 +1,114 @@
"""Qt serial/lifecycle port. Protocol decisions are exclusively in C99."""
from __future__ import annotations
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
from .native import NativeAnalyzer
class AnalyzerPort(QObject):
changed = Signal()
completed = Signal(object)
error = Signal(str)
log = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.core = None
self.active = False
self._last_done = False
self.serial = QSerialPort(self)
self.serial.readyRead.connect(self._read)
self.serial.errorOccurred.connect(self._serial_error)
self.timer = QTimer(self)
self.timer.setInterval(10)
self.timer.timeout.connect(self._tick)
self.clock = QElapsedTimer()
@staticmethod
def ports():
return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()]
def open(self, name, demo=False):
self.close()
try:
self.core = NativeAnalyzer()
self.core.reset(demo)
except RuntimeError as exc:
self.error.emit(str(exc))
return
if not demo:
self.serial.setPortName(name)
self.serial.setBaudRate(921600)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.error.emit(self.serial.errorString())
return
self.active = True
self._last_done = False
self.clock.start()
self.timer.start()
self.log.emit("ДЕМО: синтетические данные, триггер не моделируется" if demo
else "%s · 921600 8N1" % name)
self._pump()
def close(self):
self.active = False
self.timer.stop()
self.serial.close()
self.changed.emit()
def start(self, *settings):
if not self.active or not self.core:
return
try:
self.core.start(*settings)
except ValueError as exc:
self.error.emit(str(exc))
return
self._last_done = False
self._pump()
def _tick(self):
if not self.active:
return
self.core.tick(self.clock.restart())
self._pump()
def _read(self):
data = bytes(self.serial.readAll())
if self.active and data:
self.log.emit("RX " + data.hex(" ").upper())
self.core.feed(data)
self._pump()
def _pump(self):
if not self.active:
return
if self.core.state == NativeAnalyzer.ERROR:
message = self.core.error
self.close()
self.error.emit(message)
return
packet = self.core.next_request()
if packet:
self.log.emit("TX " + packet.hex(" ").upper())
if self.serial.write(packet) != len(packet):
self.core.fail_io()
self._pump()
return
self.clock.restart()
if self.core.state == NativeAnalyzer.DONE and not self._last_done:
self._last_done = True
self.completed.emit(self.core.capture())
self.changed.emit()
def _serial_error(self, code):
if self.active and code != QSerialPort.SerialPortError.NoError:
message = self.serial.errorString()
self.core.fail_io()
self.close()
self.error.emit(message)

View File

@@ -0,0 +1,108 @@
"""SETCAN streaming FFI and immutable display/export models. No wire codec."""
from __future__ import annotations
import csv
import ctypes as C
from dataclasses import dataclass
from .native import NativeAnalyzer
@dataclass(frozen=True)
class StreamSnapshot:
indices: tuple[int, ...]
samples: tuple[int, ...]
breaks: tuple[int, ...]
period_ns: int
session: int
demo: bool = False
traces: tuple = ()
def save_csv(self, path):
with open(path, "w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
writer.writerow(["session", "sample_index", "time_s", "gap_before", "sample_hex", "demo"] +
["D%d" % n for n in range(16)])
for index, sample, gap in zip(self.indices, self.samples, self.breaks):
writer.writerow([self.session, index, index*self.period_ns/1e9, gap,
"%04X" % sample, int(self.demo)] +
[(sample >> bit)&1 for bit in range(16)])
class NativeStream:
def __init__(self, device_id=None):
self._owner = NativeAnalyzer()
self.lib = self._owner.lib
signatures = {
"las_context_size": ([], C.c_size_t),
"las_init": ([C.c_void_p, C.c_uint32], C.c_int),
"las_can": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint32, C.c_uint32], None),
"las_uart": ([C.c_void_p, C.c_void_p, C.c_size_t], None),
"las_get": ([C.c_void_p, C.c_uint32], C.c_uint32),
"las_snapshot": ([C.c_void_p, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"las_trace": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_void_p, C.c_void_p, C.c_size_t], C.c_size_t),
"las_device_type": ([], C.c_uint32),
"las_device_id": ([], C.c_uint32),
"las_device_name": ([], C.c_char_p),
"las_demo_step": ([C.c_void_p, C.c_uint32], None),
"las_metadata": ([C.c_uint32]*4+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t),
"las_data": ([C.c_uint32]*5+[C.POINTER(C.c_uint32), C.c_void_p, C.c_size_t], C.c_size_t),
}
try:
for name, (args, result) in signatures.items():
fn = getattr(self.lib, name)
fn.argtypes, fn.restype = args, result
except AttributeError as exc:
raise RuntimeError("Обновите DLL templates: отсутствует SETCAN Altera stream") from exc
self.device_type = int(self.lib.las_device_type())
self.device_id = int(self.lib.las_device_id()) if device_id is None else device_id
self.device_name = self.lib.las_device_name().decode("utf-8")
self.ctx = (C.c_uint64*((self.lib.las_context_size()+7)//8))()
if not 0 <= self.device_id <= 15 or not self.lib.las_init(self.ctx, self.device_id):
raise ValueError("DeviceID должен быть 0…15")
@property
def stats(self):
fields = ("count", "period_ns", "session", "received", "missing", "duplicates",
"invalid", "ignored", "crc_errors", "has_meta")
return {name: int(self.lib.las_get(self.ctx, i)) for i, name in enumerate(fields)}
def feed_can(self, identifier, data, extended=True, remote=False):
if not 0 <= identifier <= 0xffffffff:
return
self.lib.las_can(self.ctx, identifier, data, len(data), int(extended), int(remote))
def feed_uart(self, data):
self.lib.las_uart(self.ctx, data, len(data))
def snapshot(self, demo=False):
stats = self.stats
count = stats["count"]
indices, samples, breaks = (C.c_uint64*count)(), (C.c_uint16*count)(), (C.c_ubyte*count)()
n = self.lib.las_snapshot(self.ctx, indices, samples, breaks, count)
traces = []
xs, ys, moves = (C.c_uint64*(2*count))(), (C.c_ubyte*(2*count))(), (C.c_ubyte*(2*count))()
for channel in range(16):
size = self.lib.las_trace(self.ctx, channel, xs, ys, moves, 2*count)
traces.append(tuple(zip(xs[:size], ys[:size], moves[:size])))
return StreamSnapshot(tuple(indices[:n]), tuple(samples[:n]), tuple(breaks[:n]),
stats["period_ns"], stats["session"], demo, tuple(traces))
def demo_step(self, count=50):
self.lib.las_demo_step(self.ctx, count)
def metadata_packet(self, session, period_ns, uart=False):
if not 0 <= session <= 65535 or not 1 <= period_ns <= 0xffffffff:
raise ValueError("Invalid stream metadata")
return self._packet(self.lib.las_metadata, session, period_ns, int(uart))
def data_packet(self, session, index, sample, uart=False):
if not 0 <= index <= 0xffffffff or any(not 0 <= v <= 65535 for v in (session, sample)):
raise ValueError("Invalid stream samples")
return self._packet(self.lib.las_data, session, index, sample, int(uart))
def _packet(self, fn, *args):
identifier = C.c_uint32()
out = (C.c_ubyte*32)()
n = fn(self.device_id, *args, C.byref(identifier), out, len(out))
if not n:
raise ValueError("Invalid SETCAN packet arguments")
return identifier.value, bytes(out[:n])

View File

@@ -0,0 +1,103 @@
"""Qt transport/lifecycle port for the C SETCAN stream receiver.
CAN ingress accepts canonical RX events from an existing bus connection.
UART uses the same SETCAN frames inside the shared AA55/CRC16 transport.
Reception and rendering clocks are separate; no packet-rate repainting.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
from .stream import NativeStream
class StreamPort(QObject):
updated = Signal(object, object)
changed = Signal(bool)
error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.core = None
self.active = False
self.mode = "demo"
self.paused = False
self.serial = QSerialPort(self)
self.serial.readyRead.connect(self._read)
self.serial.errorOccurred.connect(self._error)
self.timer = QTimer(self)
self.timer.setInterval(50)
self.timer.timeout.connect(self._tick)
self._previous = None
self._last_data = QElapsedTimer()
self._last_received = None
@staticmethod
def ports():
return [(p.portName(), p.description()) for p in QSerialPortInfo.availablePorts()]
def open(self, mode, port_name="", device_id=None):
self.close()
if mode not in ("demo", "can", "uart", "both"):
self.error.emit("Неизвестный транспорт")
return
try:
self.core = NativeStream(device_id)
except (RuntimeError, ValueError) as exc:
self.error.emit(str(exc))
return
self.mode = mode
if mode in ("uart", "both"):
self.serial.setPortName(port_name)
self.serial.setBaudRate(921600)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self.error.emit(self.serial.errorString())
return
self.active = True
self.paused = False
self._previous = None
self._last_received = None
self._last_data.start()
self.timer.start()
self.changed.emit(True)
def close(self):
self.active = False
self.timer.stop()
self.serial.close()
self.changed.emit(False)
def receive_event(self, event):
if (self.active and self.mode in ("can", "both") and event.get("kind") == "can"
and event.get("direction") == "RX"):
self.core.feed_can(event["identifier"], event["data"],
event.get("extended", False), event.get("remote", False))
def _read(self):
data = bytes(self.serial.readAll())
if self.active and self.mode in ("uart", "both"):
self.core.feed_uart(data)
def _tick(self):
if not self.active:
return
if self.mode == "demo":
self.core.demo_step()
stats = self.core.stats
signature = (stats["received"], stats["session"])
if signature != self._last_received:
self._last_received = signature
self._last_data.restart()
stats["stale"] = self._last_data.elapsed() > max(2000, stats["period_ns"]*3/1e6)
if not self.paused and stats != self._previous:
self._previous = stats
self.updated.emit(self.core.snapshot(self.mode == "demo"), stats)
def _error(self, code):
if self.active and code != QSerialPort.SerialPortError.NoError:
message = self.serial.errorString()
self.close()
self.error.emit(message)