Объединить разработки CAN и STM32 в master
This commit is contained in:
@@ -139,6 +139,62 @@ class NativeProtocol:
|
||||
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
||||
lib.pcan_abi_periph28335_crc16.restype = ctypes.c_uint16
|
||||
lib.pcan_abi_periph28335_append_crc.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_append_crc.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_build_read.argtypes = [
|
||||
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
|
||||
ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_build_read.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_build_write.argtypes = [
|
||||
ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16,
|
||||
ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_build_write.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_build_command.argtypes = [
|
||||
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_build_command.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_expected_read_size.argtypes = [ctypes.c_uint16]
|
||||
lib.pcan_abi_periph28335_expected_read_size.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_decode_read.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint16,
|
||||
ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_decode_read.restype = ctypes.c_int
|
||||
lib.pcan_abi_periph28335_validate_write.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_validate_write.restype = ctypes.c_int
|
||||
lib.pcan_abi_periph28335_project_count.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_periph28335_project_name.argtypes = [ctypes.c_size_t]
|
||||
lib.pcan_abi_periph28335_project_name.restype = ctypes.c_char_p
|
||||
lib.pcan_abi_periph28335_command_name.argtypes = [
|
||||
ctypes.c_size_t, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_periph28335_command_name.restype = ctypes.c_char_p
|
||||
lib.pcan_abi_tms2812_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
||||
lib.pcan_abi_tms2812_crc16.restype = ctypes.c_uint16
|
||||
lib.pcan_abi_tms2812_build_upload.argtypes = [
|
||||
ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32,
|
||||
ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_tms2812_build_upload.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_tms2812_expected_upload_size.argtypes = [ctypes.c_uint32]
|
||||
lib.pcan_abi_tms2812_expected_upload_size.restype = ctypes.c_size_t
|
||||
lib.pcan_abi_tms2812_validate_upload.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint32,
|
||||
]
|
||||
lib.pcan_abi_tms2812_validate_upload.restype = ctypes.c_int
|
||||
lib.pcan_abi_tms2812_decode_upload.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint8, ctypes.c_uint32,
|
||||
ctypes.c_void_p, ctypes.c_size_t,
|
||||
]
|
||||
lib.pcan_abi_tms2812_decode_upload.restype = ctypes.c_int
|
||||
lib.pcan_abi_frame_encode.argtypes = [
|
||||
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32,
|
||||
ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t,
|
||||
@@ -220,6 +276,112 @@ class NativeProtocol:
|
||||
device, address, output, len(output))
|
||||
return output.value.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _bytes_buffer(data: bytes):
|
||||
return (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None
|
||||
|
||||
def periph28335_crc16(self, data: bytes) -> int:
|
||||
return int(self.lib.pcan_abi_periph28335_crc16(
|
||||
self._bytes_buffer(data), len(data)))
|
||||
|
||||
def periph28335_append_crc(self, payload: bytes) -> bytes:
|
||||
source = self._bytes_buffer(payload)
|
||||
output = (ctypes.c_uint8 * (len(payload) + 2))()
|
||||
size = int(self.lib.pcan_abi_periph28335_append_crc(
|
||||
source, len(payload), output, len(output)))
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol rejected PM35 payload")
|
||||
return bytes(output[:size])
|
||||
|
||||
def periph28335_build_read(self, controller: int, start: int,
|
||||
count: int) -> bytes:
|
||||
output = (ctypes.c_uint8 * 8)()
|
||||
size = int(self.lib.pcan_abi_periph28335_build_read(
|
||||
controller, start, count, output, len(output)))
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol rejected PM35 read request")
|
||||
return bytes(output[:size])
|
||||
|
||||
def periph28335_build_write(self, controller: int, address: int,
|
||||
value: int) -> bytes:
|
||||
output = (ctypes.c_uint8 * 8)()
|
||||
size = int(self.lib.pcan_abi_periph28335_build_write(
|
||||
controller, address, value, output, len(output)))
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol rejected PM35 write request")
|
||||
return bytes(output[:size])
|
||||
|
||||
def periph28335_build_command(self, controller: int,
|
||||
command_index: int) -> bytes:
|
||||
output = (ctypes.c_uint8 * 8)()
|
||||
size = int(self.lib.pcan_abi_periph28335_build_command(
|
||||
controller, command_index, output, len(output)))
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol rejected PM35 command")
|
||||
return bytes(output[:size])
|
||||
|
||||
def periph28335_expected_read_size(self, count: int) -> int:
|
||||
return int(self.lib.pcan_abi_periph28335_expected_read_size(count))
|
||||
|
||||
def periph28335_decode_read(self, data: bytes, controller: int,
|
||||
count: int) -> tuple[int, tuple[int, ...]]:
|
||||
source = self._bytes_buffer(data)
|
||||
output = (ctypes.c_uint16 * count)()
|
||||
status = int(self.lib.pcan_abi_periph28335_decode_read(
|
||||
source, len(data), controller, count, output, count))
|
||||
return status, tuple(int(value) for value in output) if status == 0 else ()
|
||||
|
||||
def periph28335_validate_write(self, response: bytes,
|
||||
request: bytes) -> int:
|
||||
return int(self.lib.pcan_abi_periph28335_validate_write(
|
||||
self._bytes_buffer(response), len(response),
|
||||
self._bytes_buffer(request), len(request)))
|
||||
|
||||
def periph28335_catalog(self) -> dict[str, tuple[str, ...]]:
|
||||
result: dict[str, tuple[str, ...]] = {}
|
||||
for project in range(int(self.lib.pcan_abi_periph28335_project_count())):
|
||||
raw_name = self.lib.pcan_abi_periph28335_project_name(project)
|
||||
if not raw_name:
|
||||
raise NativeProtocolUnavailable("invalid PM35 project catalog")
|
||||
commands = []
|
||||
for command in range(17):
|
||||
raw_command = self.lib.pcan_abi_periph28335_command_name(
|
||||
project, command)
|
||||
if raw_command is None:
|
||||
raise NativeProtocolUnavailable("invalid PM35 command catalog")
|
||||
commands.append(raw_command.decode("utf-8"))
|
||||
result[raw_name.decode("utf-8")] = tuple(commands)
|
||||
return result
|
||||
|
||||
def tms2812_crc16(self, data: bytes) -> int:
|
||||
return int(self.lib.pcan_abi_tms2812_crc16(
|
||||
self._bytes_buffer(data), len(data)))
|
||||
|
||||
def tms2812_build_upload(self, controller: int, word_address: int,
|
||||
byte_count: int) -> bytes:
|
||||
output = (ctypes.c_uint8 * 12)()
|
||||
size = int(self.lib.pcan_abi_tms2812_build_upload(
|
||||
controller, word_address, byte_count, output, len(output)))
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol rejected PM67 upload request")
|
||||
return bytes(output[:size])
|
||||
|
||||
def tms2812_expected_upload_size(self, byte_count: int) -> int:
|
||||
return int(self.lib.pcan_abi_tms2812_expected_upload_size(byte_count))
|
||||
|
||||
def tms2812_validate_upload(self, data: bytes, controller: int,
|
||||
byte_count: int) -> int:
|
||||
return int(self.lib.pcan_abi_tms2812_validate_upload(
|
||||
self._bytes_buffer(data), len(data), controller, byte_count))
|
||||
|
||||
def tms2812_decode_upload(self, data: bytes, controller: int,
|
||||
byte_count: int) -> tuple[int, bytes]:
|
||||
output = (ctypes.c_uint8 * byte_count)()
|
||||
status = int(self.lib.pcan_abi_tms2812_decode_upload(
|
||||
self._bytes_buffer(data), len(data), controller, byte_count,
|
||||
output, len(output)))
|
||||
return status, bytes(output) if status == 0 else b""
|
||||
|
||||
def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes:
|
||||
if len(data) > 8:
|
||||
raise ValueError("DLC cannot exceed 8 bytes")
|
||||
|
||||
@@ -1,66 +1,85 @@
|
||||
"""Portable RS command helpers from Set_Terminal_28335.
|
||||
|
||||
The byte order and CRC match ``DTrans.pas``/``UNiiefa.pas``. This module is
|
||||
deliberately UI- and serial-port-independent so desktop and Android clients
|
||||
can share the same request builders.
|
||||
"""
|
||||
"""Thin Python port of the shared C99 PM35/TMS320F28335 protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .native import get_native_protocol
|
||||
|
||||
|
||||
REGISTER_COUNT = 128
|
||||
DEFAULT_CONTROLLER = 16
|
||||
DEFAULT_BAUD_RATE = 115_200
|
||||
|
||||
|
||||
def _core():
|
||||
return get_native_protocol()
|
||||
|
||||
|
||||
def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc & 0xFFFF
|
||||
if crc != 0xFFFF:
|
||||
raise ValueError("Произвольное начальное значение CRC не входит в протокол ПМ35")
|
||||
return _core().periph28335_crc16(bytes(data))
|
||||
|
||||
|
||||
def with_crc(payload: bytes) -> bytes:
|
||||
crc = crc16_modbus(payload)
|
||||
return bytes(payload) + crc.to_bytes(2, "little")
|
||||
return _core().periph28335_append_crc(bytes(payload))
|
||||
|
||||
|
||||
def build_read_registers(controller: int, start: int, count: int) -> bytes:
|
||||
_range("Адрес контроллера", controller, 0xFF)
|
||||
_range("Начальный регистр", start, 0xFFFF)
|
||||
if not 1 <= count <= 128 or start + count > 128:
|
||||
if not 1 <= count <= REGISTER_COUNT or start + count > REGISTER_COUNT:
|
||||
raise ValueError("Диапазон регистров должен находиться в 0..127")
|
||||
return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big")
|
||||
+ count.to_bytes(2, "big"))
|
||||
return _core().periph28335_build_read(controller, start, count)
|
||||
|
||||
|
||||
def build_write_register(controller: int, address: int, value: int) -> bytes:
|
||||
_range("Адрес контроллера", controller, 0xFF)
|
||||
_range("Адрес регистра", address, 127)
|
||||
_range("Адрес регистра", address, REGISTER_COUNT - 1)
|
||||
_range("Значение", value, 0xFFFF)
|
||||
return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big")
|
||||
+ value.to_bytes(2, "big"))
|
||||
return _core().periph28335_build_write(controller, address, value)
|
||||
|
||||
|
||||
def build_command(controller: int, command_index: int) -> bytes:
|
||||
_range("Адрес контроллера", controller, 0xFF)
|
||||
if not 0 <= command_index <= 16:
|
||||
raise ValueError("Номер команды должен быть в диапазоне 0..16")
|
||||
value = 1 << command_index if command_index < 16 else 0
|
||||
return build_write_register(controller, 127, value)
|
||||
return _core().periph28335_build_command(controller, command_index)
|
||||
|
||||
|
||||
def expected_read_response_size(count: int) -> int:
|
||||
return count * 2 + 5
|
||||
if not 1 <= count <= REGISTER_COUNT:
|
||||
raise ValueError("Число регистров должно быть в диапазоне 1..128")
|
||||
size = _core().periph28335_expected_read_size(count)
|
||||
if size == 0:
|
||||
raise ValueError("SETProtocol отклонил размер ответа ПМ35")
|
||||
return size
|
||||
|
||||
|
||||
def decode_read_response(data: bytes, count: int) -> tuple[int, ...]:
|
||||
def decode_read_response(data: bytes, count: int,
|
||||
controller: int | None = None) -> tuple[int, ...]:
|
||||
expected = expected_read_response_size(count)
|
||||
if len(data) != expected:
|
||||
raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}")
|
||||
if crc16_modbus(data[:-2]) != int.from_bytes(data[-2:], "little"):
|
||||
raise ValueError("Ошибка CRC ответа")
|
||||
# Historical replies have a three-byte header; registers are big-endian.
|
||||
body = data[3:-2]
|
||||
if len(body) != count * 2:
|
||||
raise ValueError("Неверная длина данных ответа")
|
||||
return tuple(int.from_bytes(body[offset:offset + 2], "big")
|
||||
for offset in range(0, len(body), 2))
|
||||
expected_controller = data[0] if controller is None and data else controller
|
||||
if expected_controller is None:
|
||||
raise ValueError("Пустой ответ ПМ35")
|
||||
status, values = _core().periph28335_decode_read(
|
||||
bytes(data), expected_controller, count)
|
||||
errors = {
|
||||
-1: "Неверные аргументы ответа",
|
||||
-2: "Диапазон регистров вне 0..127",
|
||||
-3: "Неверная длина данных ответа",
|
||||
-4: "Ошибка CRC ответа",
|
||||
-5: "Неверный заголовок ответа",
|
||||
-6: "Недостаточный буфер ответа",
|
||||
}
|
||||
if status != 0:
|
||||
raise ValueError(errors.get(status, f"Ошибка ответа ПМ35: {status}"))
|
||||
return values
|
||||
|
||||
|
||||
def validate_write_response(data: bytes, request: bytes) -> bool:
|
||||
return _core().periph28335_validate_write(bytes(data), bytes(request)) == 0
|
||||
|
||||
|
||||
def bits_lsb_first(value: int) -> tuple[bool, ...]:
|
||||
@@ -85,14 +104,4 @@ def _range(name: str, value: int, maximum: int) -> None:
|
||||
raise ValueError(f"{name} вне диапазона 0..{maximum}")
|
||||
|
||||
|
||||
PROJECT_COMMANDS = {
|
||||
"По умолчанию": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"23470": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"23550": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"23550.2": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"),
|
||||
"ICE 22220.1-3": ("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"ICE 22220.4-5": ("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"Бальзам 161": ("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"Бальзам 162": ("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
"Бальзам 163": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"),
|
||||
}
|
||||
PROJECT_COMMANDS = _core().periph28335_catalog()
|
||||
|
||||
@@ -67,6 +67,10 @@ class PlotMath:
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
|
||||
"""Validate absolute axis limits in the shared core."""
|
||||
return Bounds(*self.call(8, left, right, bottom, top))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Viewport:
|
||||
@@ -92,6 +96,9 @@ class Bounds:
|
||||
bottom: float
|
||||
top: float
|
||||
|
||||
def validated(self, core: PlotMath) -> "Bounds":
|
||||
return core.limits(self.left, self.right, self.bottom, self.top)
|
||||
|
||||
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
|
||||
return core.call(2, value, self.bottom if horizontal else self.left,
|
||||
self.top if horizontal else self.right, int(horizontal))[0]
|
||||
|
||||
@@ -37,6 +37,12 @@ class Spectrum:
|
||||
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectrumPeak:
|
||||
frequency_hz: float
|
||||
amplitude: float
|
||||
|
||||
|
||||
class NativeSpectrum:
|
||||
def __init__(self, library: ctypes.CDLL):
|
||||
self.lib = library
|
||||
@@ -46,6 +52,11 @@ class NativeSpectrum:
|
||||
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
|
||||
pointer, ctypes.c_size_t, pointer]
|
||||
self._analyze.restype = ctypes.c_int
|
||||
self._peak = library.set_spectrum_dominant_peak
|
||||
self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
|
||||
ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
|
||||
pointer, ctypes.c_size_t]
|
||||
self._peak.restype = ctypes.c_int
|
||||
|
||||
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
|
||||
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
|
||||
@@ -69,3 +80,15 @@ class NativeSpectrum:
|
||||
raise ValueError(message.get(status, "FFT failed"))
|
||||
n = int(meta[0])
|
||||
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
|
||||
|
||||
def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
|
||||
absolute_floor: float = 1e-6) -> SpectrumPeak | None:
|
||||
amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
|
||||
scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
|
||||
output = (ctypes.c_double * 2)()
|
||||
status = self._peak(amplitudes, len(spectrum.amplitudes),
|
||||
spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
|
||||
scratch, len(scratch), output, 2)
|
||||
if status < 0:
|
||||
raise ValueError("Invalid spectrum peak input")
|
||||
return SpectrumPeak(output[0], output[1]) if status else None
|
||||
|
||||
60
python/protocan/tms2812.py
Normal file
60
python/protocan/tms2812.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Thin Python adapter for the shared C99 PM67/TMS320F2812 protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .native import get_native_protocol
|
||||
|
||||
|
||||
CMD_UPLOAD = 52
|
||||
UPLOAD_RESPONSE_OVERHEAD = 8
|
||||
|
||||
|
||||
def _core():
|
||||
return get_native_protocol()
|
||||
|
||||
|
||||
def crc16_modbus(data: bytes, initial: int = 0xFFFF) -> int:
|
||||
if initial != 0xFFFF:
|
||||
raise ValueError("Произвольное начальное значение CRC не входит в протокол ПМ67")
|
||||
return _core().tms2812_crc16(bytes(data))
|
||||
|
||||
|
||||
def build_upload_request(controller: int, word_address: int,
|
||||
byte_count: int) -> bytes:
|
||||
_range("адрес контроллера", controller, 0xFF)
|
||||
_range("адрес слова", word_address, 0xFFFFFFFF)
|
||||
if not 1 <= byte_count <= 0xFFFFFFFF:
|
||||
raise ValueError("размер блока вне диапазона 1..0xFFFFFFFF")
|
||||
return _core().tms2812_build_upload(controller, word_address, byte_count)
|
||||
|
||||
|
||||
def expected_upload_response_size(byte_count: int) -> int:
|
||||
if not 1 <= byte_count <= 0xFFFFFFFF:
|
||||
raise ValueError("размер блока вне диапазона 1..0xFFFFFFFF")
|
||||
return _core().tms2812_expected_upload_size(byte_count)
|
||||
|
||||
|
||||
def decode_upload_reply(raw: bytes, controller: int, byte_count: int) -> bytes:
|
||||
expected = expected_upload_response_size(byte_count)
|
||||
if len(raw) != expected:
|
||||
raise ValueError(
|
||||
f"ответ CMD_UPLOAD: ожидалось {expected} байт, получено {len(raw)}"
|
||||
)
|
||||
status, data = _core().tms2812_decode_upload(
|
||||
bytes(raw), controller, byte_count)
|
||||
errors = {
|
||||
-1: "неверные аргументы CMD_UPLOAD",
|
||||
-2: "размер CMD_UPLOAD вне диапазона",
|
||||
-3: "неверная длина ответа CMD_UPLOAD",
|
||||
-4: "CRC ответа CMD_UPLOAD не совпадает",
|
||||
-5: "ответ CMD_UPLOAD имеет неверный адрес или номер команды",
|
||||
-6: "недостаточный буфер CMD_UPLOAD",
|
||||
}
|
||||
if status != 0:
|
||||
raise ValueError(errors.get(status, f"ошибка ответа CMD_UPLOAD: {status}"))
|
||||
return data
|
||||
|
||||
|
||||
def _range(name: str, value: int, maximum: int) -> None:
|
||||
if not 0 <= value <= maximum:
|
||||
raise ValueError(f"{name} вне диапазона 0..{maximum}")
|
||||
@@ -54,6 +54,8 @@ class TrendSignal:
|
||||
deviceType: int = 7
|
||||
device: int = 13
|
||||
byteOffset: int = 0
|
||||
multiplier: float = 1.0
|
||||
iq: int = 0
|
||||
extended: bool = True
|
||||
|
||||
@classmethod
|
||||
@@ -72,9 +74,11 @@ class TrendSignal:
|
||||
for field in ("id", "name", "source", "address", "color", "valueType"):
|
||||
if type(getattr(self, field)) is not str:
|
||||
raise ValueError(f"{field} must be a string")
|
||||
for field in ("order", "deviceType", "device", "byteOffset"):
|
||||
for field in ("order", "deviceType", "device", "byteOffset", "iq"):
|
||||
if type(getattr(self, field)) is not int:
|
||||
raise ValueError(f"{field} must be an integer")
|
||||
if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier):
|
||||
raise ValueError("multiplier must be a finite number")
|
||||
if type(self.visible) is not bool or type(self.extended) is not bool:
|
||||
raise ValueError("Visibility and CAN format must be boolean")
|
||||
if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999:
|
||||
@@ -98,12 +102,11 @@ class TrendSignal:
|
||||
raise ValueError("Invalid ProtoCAN device")
|
||||
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
|
||||
raise ValueError("Word offset must be 0..6")
|
||||
if not 0 <= self.iq <= 30:
|
||||
raise ValueError("IQ must be 0..30")
|
||||
|
||||
def word_value(self, word: int) -> float:
|
||||
if not 0 <= word <= 65535:
|
||||
raise ValueError("Not a 16-bit word")
|
||||
return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word)
|
||||
|
||||
def display_value(self, raw_value: float) -> float:
|
||||
return raw_value * self.multiplier / (1 << self.iq)
|
||||
|
||||
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
|
||||
ids = set()
|
||||
@@ -138,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]:
|
||||
raise ValueError("profiles must be an object")
|
||||
result = {}
|
||||
fields = set(TrendSignal.__dataclass_fields__)
|
||||
required_fields = fields - {"multiplier", "iq"}
|
||||
for profile, items in profiles.items():
|
||||
if not isinstance(items, list) or len(items) > MAX_SIGNALS:
|
||||
raise ValueError("Expected up to 64 signals")
|
||||
signals = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not fields <= item.keys():
|
||||
if not isinstance(item, dict) or not required_fields <= item.keys():
|
||||
raise ValueError("Missing signal fields")
|
||||
signals.append(TrendSignal(**{key: item[key] for key in fields}))
|
||||
signals.append(TrendSignal(**{key: item[key] for key in fields if key in item}))
|
||||
result[profile] = signals
|
||||
validate_settings(result)
|
||||
return result
|
||||
@@ -178,6 +182,20 @@ class NativeTrends:
|
||||
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8,
|
||||
ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
|
||||
self.decode.restype = ctypes.c_int32
|
||||
self._word = library.set_trend_word_value
|
||||
self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
|
||||
self._word.restype = ctypes.c_int32
|
||||
self._watch_request = library.set_trend_watch_request
|
||||
self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
|
||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
|
||||
self._watch_request.restype = ctypes.c_size_t
|
||||
self._watch_ack = library.set_trend_watch_ack
|
||||
self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
|
||||
self._watch_ack.restype = ctypes.c_int
|
||||
self._watch_decode = library.set_trend_watch_decode
|
||||
self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
|
||||
self._watch_decode.restype = ctypes.c_int
|
||||
|
||||
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
|
||||
if signal.source not in ("CAN_GAS", "CAN_RAW"):
|
||||
@@ -191,3 +209,33 @@ class NativeTrends:
|
||||
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
|
||||
payload, len(data))
|
||||
return None if value == -2147483648 else float(value)
|
||||
|
||||
def word_value(self, signal: TrendSignal, word: int) -> float:
|
||||
if not 0 <= word <= 65535:
|
||||
raise ValueError("Not a 16-bit word")
|
||||
return float(self._word(word, signal.valueType == "INT16"))
|
||||
|
||||
def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
|
||||
if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
|
||||
type(address) is not int or not 0 <= address <= 65535 for address in addresses):
|
||||
raise ValueError("Invalid GAS watch request")
|
||||
source = (ctypes.c_uint16 * len(addresses))(*addresses)
|
||||
output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
|
||||
size = self._watch_request(period_ms, source, len(addresses), output, len(output))
|
||||
if not size:
|
||||
raise ValueError("Invalid GAS watch request")
|
||||
return bytes(output[:size])
|
||||
|
||||
def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
|
||||
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
|
||||
if not self._watch_ack(data, len(payload), period_ms, count):
|
||||
raise ValueError("Device did not accept the complete GAS subscription")
|
||||
|
||||
def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
|
||||
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
|
||||
timestamp = ctypes.c_uint32()
|
||||
words = (ctypes.c_uint16 * MAX_SIGNALS)()
|
||||
count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
|
||||
if count < 0 or expected_count is not None and count != expected_count:
|
||||
raise ValueError("Invalid GAS watch data")
|
||||
return timestamp.value, list(words[:count])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"""Cross-platform Python facade for the canonical SETProtocol core."""
|
||||
|
||||
from .core import * # noqa: F401,F403
|
||||
from .can import CanAddress, CanFrame, CanReassembler, segment
|
||||
|
||||
173
python/setprotocol/can.py
Normal file
173
python/setprotocol/can.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Classic-CAN segmentation for canonical SETProtocol v2 frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .core import (
|
||||
CRC_SIZE,
|
||||
FRAME_MAX,
|
||||
HEADER_SIZE,
|
||||
SOF,
|
||||
FrameFlag,
|
||||
SetProtocolError,
|
||||
)
|
||||
|
||||
CAN_ID_MASK = 0x1FFFFFFF
|
||||
CAN_ID_PREFIX = 0x12
|
||||
CAN_ID_PREFIX_MASK = 0x1F000000
|
||||
PCI_FIRST = 0x10
|
||||
PCI_CONSECUTIVE = 0x20
|
||||
PCI_FLOW_CONTROL = 0x30
|
||||
PCI_TYPE_MASK = 0xF0
|
||||
PCI_VALUE_MASK = 0x0F
|
||||
FIRST_DATA_SIZE = 5
|
||||
CONSECUTIVE_DATA_SIZE = 7
|
||||
REASSEMBLY_TIMEOUT_MS = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanAddress:
|
||||
destination: int
|
||||
source: int
|
||||
priority: int = 0
|
||||
channel: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0 <= self.destination <= 0xFF:
|
||||
raise SetProtocolError("CAN destination вне диапазона u8")
|
||||
if not 0 <= self.source <= 0xFF:
|
||||
raise SetProtocolError("CAN source вне диапазона u8")
|
||||
if self.priority not in (0, 1):
|
||||
raise SetProtocolError("CAN priority должен быть 0 или 1")
|
||||
if not 0 <= self.channel <= 0x7F:
|
||||
raise SetProtocolError("CAN channel вне диапазона 0..127")
|
||||
|
||||
def pack(self) -> int:
|
||||
return (
|
||||
(CAN_ID_PREFIX << 24)
|
||||
| (self.destination << 16)
|
||||
| (self.source << 8)
|
||||
| (self.priority << 7)
|
||||
| self.channel
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def unpack(cls, can_id: int) -> "CanAddress":
|
||||
if not 0 <= can_id <= CAN_ID_MASK:
|
||||
raise SetProtocolError("CAN ID вне 29-битного диапазона")
|
||||
if can_id & CAN_ID_PREFIX_MASK != CAN_ID_PREFIX << 24:
|
||||
raise SetProtocolError("CAN ID не принадлежит SETProtocol v2")
|
||||
return cls(
|
||||
destination=(can_id >> 16) & 0xFF,
|
||||
source=(can_id >> 8) & 0xFF,
|
||||
priority=(can_id >> 7) & 1,
|
||||
channel=can_id & 0x7F,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanFrame:
|
||||
can_id: int
|
||||
data: bytes
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
CanAddress.unpack(self.can_id)
|
||||
if not 1 <= len(self.data) <= 8:
|
||||
raise SetProtocolError("classic CAN data должен содержать 1..8 байт")
|
||||
|
||||
|
||||
def segment(packet: bytes, address: CanAddress) -> list[CanFrame]:
|
||||
"""Split one complete encoded SETP frame into classic-CAN frames."""
|
||||
if not HEADER_SIZE + CRC_SIZE <= len(packet) <= FRAME_MAX:
|
||||
raise SetProtocolError("неверная длина SETP-пакета для CAN")
|
||||
can_id = address.pack()
|
||||
result = [
|
||||
CanFrame(
|
||||
can_id,
|
||||
bytes((PCI_FIRST,))
|
||||
+ len(packet).to_bytes(2, "little")
|
||||
+ packet[:FIRST_DATA_SIZE],
|
||||
)
|
||||
]
|
||||
sequence = 1
|
||||
for offset in range(FIRST_DATA_SIZE, len(packet), CONSECUTIVE_DATA_SIZE):
|
||||
chunk = packet[offset : offset + CONSECUTIVE_DATA_SIZE]
|
||||
result.append(CanFrame(can_id, bytes((PCI_CONSECUTIVE | sequence,)) + chunk))
|
||||
sequence = (sequence + 1) & PCI_VALUE_MASK
|
||||
return result
|
||||
|
||||
|
||||
class CanReassembler:
|
||||
"""Reassemble one SETP packet from one active source/channel."""
|
||||
|
||||
def __init__(self, timeout_ms: int = REASSEMBLY_TIMEOUT_MS) -> None:
|
||||
if timeout_ms <= 0:
|
||||
raise ValueError("timeout_ms должен быть положительным")
|
||||
self.timeout_ms = timeout_ms
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._expected_length = 0
|
||||
self._can_id: int | None = None
|
||||
self._deadline_ms = 0
|
||||
self._next_sequence = 1
|
||||
|
||||
def feed(self, frame: CanFrame, now_ms: int) -> bytes | None:
|
||||
if self._can_id is not None and now_ms >= self._deadline_ms:
|
||||
self.reset()
|
||||
raise SetProtocolError("таймаут сборки SETProtocol CAN")
|
||||
pci = frame.data[0] & PCI_TYPE_MASK
|
||||
if pci == PCI_FIRST:
|
||||
if len(frame.data) != 8:
|
||||
raise SetProtocolError("первый CAN-сегмент должен иметь DLC 8")
|
||||
total = int.from_bytes(frame.data[1:3], "little")
|
||||
if not HEADER_SIZE + CRC_SIZE <= total <= FRAME_MAX:
|
||||
raise SetProtocolError("неверная полная длина SETP CAN")
|
||||
self._buffer = bytearray(frame.data[3:])
|
||||
self._expected_length = total
|
||||
self._can_id = frame.can_id
|
||||
self._deadline_ms = now_ms + self.timeout_ms
|
||||
self._next_sequence = 1
|
||||
return None
|
||||
if pci == PCI_CONSECUTIVE:
|
||||
sequence = frame.data[0] & PCI_VALUE_MASK
|
||||
if (
|
||||
self._can_id is None
|
||||
or frame.can_id != self._can_id
|
||||
or sequence != self._next_sequence
|
||||
or len(frame.data) < 2
|
||||
):
|
||||
self.reset()
|
||||
raise SetProtocolError("нарушена последовательность SETP CAN")
|
||||
remaining = self._expected_length - len(self._buffer)
|
||||
chunk = frame.data[1:]
|
||||
if len(chunk) > remaining:
|
||||
self.reset()
|
||||
raise SetProtocolError("CAN-сегмент длиннее остатка SETP-пакета")
|
||||
self._buffer.extend(chunk)
|
||||
self._next_sequence = (self._next_sequence + 1) & PCI_VALUE_MASK
|
||||
self._deadline_ms = now_ms + self.timeout_ms
|
||||
if len(self._buffer) != self._expected_length:
|
||||
return None
|
||||
packet = bytes(self._buffer)
|
||||
can_id = self._can_id
|
||||
self.reset()
|
||||
assert can_id is not None
|
||||
address = CanAddress.unpack(can_id)
|
||||
if packet[:2] != SOF:
|
||||
raise SetProtocolError("SETP CAN packet не содержит SOF")
|
||||
source = int.from_bytes(packet[6:8], "little")
|
||||
destination = int.from_bytes(packet[8:10], "little")
|
||||
priority = int(bool(packet[3] & int(FrameFlag.PRIORITY)))
|
||||
if (
|
||||
source != address.source
|
||||
or destination != address.destination
|
||||
or priority != address.priority
|
||||
):
|
||||
raise SetProtocolError("SETP header не совпадает с CAN ID")
|
||||
return packet
|
||||
if pci == PCI_FLOW_CONTROL:
|
||||
return None
|
||||
raise SetProtocolError("неизвестный тип SETP CAN-сегмента")
|
||||
145
python/setprotocol/firmware_publish.py
Normal file
145
python/setprotocol/firmware_publish.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Reusable helpers for publishing the shared firmware release catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .firmware_catalog import (
|
||||
MAX_MANIFEST_BYTES,
|
||||
SUPPORTED_TRANSPORTS,
|
||||
parse_firmware_catalog,
|
||||
)
|
||||
|
||||
MAX_FIRMWARE_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwarePublication:
|
||||
"""Metadata required to publish one firmware image."""
|
||||
|
||||
path: Path
|
||||
product: str
|
||||
version_name: str
|
||||
version_code: int
|
||||
transport: str
|
||||
base_address: int | None = None
|
||||
notes: str = ""
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.path.is_file():
|
||||
raise ValueError(f"Firmware file is missing: {self.path}")
|
||||
if self.path.suffix.lower() not in {".bin", ".hex"}:
|
||||
raise ValueError("Firmware file must have a .bin or .hex extension")
|
||||
size = self.path.stat().st_size
|
||||
if size <= 0:
|
||||
raise ValueError("Firmware file is empty")
|
||||
if size > MAX_FIRMWARE_BYTES:
|
||||
raise ValueError("Firmware file exceeds the maximum size")
|
||||
if not self.product.strip():
|
||||
raise ValueError("Firmware product is empty")
|
||||
if not self.version_name.strip():
|
||||
raise ValueError("Firmware version name is empty")
|
||||
if not 0 <= self.version_code <= 0x7FFFFFFF:
|
||||
raise ValueError(
|
||||
"Firmware version code must be between 0 and 2147483647"
|
||||
)
|
||||
if self.transport not in SUPPORTED_TRANSPORTS:
|
||||
raise ValueError("Unsupported firmware transport")
|
||||
if (
|
||||
self.base_address is not None
|
||||
and not 0 <= self.base_address <= 0xFFFFFFFF
|
||||
):
|
||||
raise ValueError("Firmware base address is outside the uint32 range")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def safe_release_tag_part(value: str) -> str:
|
||||
source = value.strip()
|
||||
part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.")
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
|
||||
if not part:
|
||||
return digest
|
||||
return part if part == source else f"{part}-{digest}"
|
||||
|
||||
|
||||
def firmware_release_tag(publication: FirmwarePublication) -> str:
|
||||
return "firmware-%s-v%s" % (
|
||||
safe_release_tag_part(publication.product),
|
||||
safe_release_tag_part(publication.version_name),
|
||||
)
|
||||
|
||||
|
||||
def firmware_release_entry(
|
||||
publication: FirmwarePublication, image_url: str, sha256: str
|
||||
) -> dict:
|
||||
result = {
|
||||
"product": publication.product.strip(),
|
||||
"versionCode": publication.version_code,
|
||||
"versionName": publication.version_name.strip(),
|
||||
"imageUrl": image_url,
|
||||
"fileName": publication.path.name,
|
||||
"sha256": sha256,
|
||||
"transport": publication.transport,
|
||||
"notes": publication.notes.strip(),
|
||||
}
|
||||
if publication.base_address is not None:
|
||||
result["baseAddress"] = f"0x{publication.base_address:08X}"
|
||||
return result
|
||||
|
||||
|
||||
def firmware_entry_identity(entry: dict) -> tuple[str, int, str]:
|
||||
return (
|
||||
str(entry.get("product", entry.get("device", ""))).strip().casefold(),
|
||||
int(entry.get("versionCode", 0)),
|
||||
str(entry.get("transport", "rs485")).strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
def update_firmware_manifest(manifest: dict, entry: dict) -> dict:
|
||||
"""Insert or replace one release without disturbing other manifest data."""
|
||||
result = dict(manifest)
|
||||
existing = manifest.get("firmware")
|
||||
firmware = dict(existing) if isinstance(existing, dict) else {}
|
||||
rows = firmware.get("releases") if isinstance(existing, dict) else existing
|
||||
releases = (
|
||||
[dict(row) for row in rows if isinstance(row, dict)]
|
||||
if isinstance(rows, list)
|
||||
else []
|
||||
)
|
||||
identity = firmware_entry_identity(entry)
|
||||
releases = [
|
||||
row for row in releases if firmware_entry_identity(row) != identity
|
||||
]
|
||||
releases.append(dict(entry))
|
||||
releases.sort(
|
||||
key=lambda row: (
|
||||
str(row.get("product", row.get("device", ""))).casefold(),
|
||||
-int(row.get("versionCode", 0)),
|
||||
str(row.get("transport", "rs485")),
|
||||
)
|
||||
)
|
||||
previous_rows = firmware.get("releases")
|
||||
changed = releases != previous_rows
|
||||
firmware["catalogVersion"] = (
|
||||
int(firmware.get("catalogVersion", 0)) + int(changed)
|
||||
)
|
||||
firmware["releases"] = releases
|
||||
result["firmware"] = firmware
|
||||
|
||||
encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
if len(encoded) > MAX_MANIFEST_BYTES:
|
||||
raise ValueError("Updated update.json exceeds the maximum size")
|
||||
parse_firmware_catalog(encoded, "https://catalog.invalid/update.json")
|
||||
return result
|
||||
|
||||
93
python/tests/test_firmware_publish.py
Normal file
93
python/tests/test_firmware_publish.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from setprotocol.firmware_publish import (
|
||||
FirmwarePublication,
|
||||
firmware_release_entry,
|
||||
firmware_release_tag,
|
||||
update_firmware_manifest,
|
||||
)
|
||||
|
||||
|
||||
class FirmwarePublishTests(unittest.TestCase):
|
||||
def publication(self, path: Path, **overrides) -> FirmwarePublication:
|
||||
fields = {
|
||||
"path": path,
|
||||
"product": "F103DS18",
|
||||
"version_name": "1.1.0",
|
||||
"version_code": 0x00010100,
|
||||
"transport": "can",
|
||||
"base_address": 0x08003000,
|
||||
"notes": "Verified release",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return FirmwarePublication(**fields)
|
||||
|
||||
def test_publication_validates_file_and_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
image = Path(temporary) / "image.hex"
|
||||
image.write_text(":00000001FF\n", encoding="ascii")
|
||||
self.publication(image).validate()
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||
self.publication(image, transport="unknown").validate()
|
||||
|
||||
def test_entry_and_tag_are_deterministic(self) -> None:
|
||||
publication = self.publication(Path("image.hex"))
|
||||
entry = firmware_release_entry(
|
||||
publication, "https://example.test/image.hex", "ab" * 32
|
||||
)
|
||||
self.assertEqual(
|
||||
firmware_release_tag(publication), "firmware-F103DS18-v1.1.0"
|
||||
)
|
||||
self.assertEqual(entry["baseAddress"], "0x08003000")
|
||||
|
||||
def test_update_preserves_sections_and_replaces_same_release(self) -> None:
|
||||
first = {
|
||||
"product": "Device",
|
||||
"versionCode": 7,
|
||||
"versionName": "1.2.3",
|
||||
"imageUrl": "https://example.test/old.bin",
|
||||
"fileName": "old.bin",
|
||||
"sha256": "11" * 32,
|
||||
"transport": "rs485",
|
||||
}
|
||||
manifest = update_firmware_manifest(
|
||||
{"windows": {"versionCode": 8}}, first
|
||||
)
|
||||
replacement = {
|
||||
**first,
|
||||
"imageUrl": "https://example.test/new.bin",
|
||||
"fileName": "new.bin",
|
||||
"sha256": "22" * 32,
|
||||
}
|
||||
updated = update_firmware_manifest(manifest, replacement)
|
||||
self.assertEqual(updated["windows"], {"versionCode": 8})
|
||||
self.assertEqual(len(updated["firmware"]["releases"]), 1)
|
||||
self.assertEqual(
|
||||
updated["firmware"]["releases"][0]["sha256"], "22" * 32
|
||||
)
|
||||
self.assertEqual(updated["firmware"]["catalogVersion"], 2)
|
||||
|
||||
def test_legacy_array_is_migrated_without_data_loss(self) -> None:
|
||||
legacy = {
|
||||
"product": "Legacy",
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"imageUrl": "https://example.test/legacy.bin",
|
||||
"fileName": "legacy.bin",
|
||||
"sha256": "33" * 32,
|
||||
"transport": "rs485",
|
||||
}
|
||||
current = {**legacy, "product": "Current", "versionCode": 2}
|
||||
updated = update_firmware_manifest({"firmware": [legacy]}, current)
|
||||
self.assertCountEqual(
|
||||
[row["product"] for row in updated["firmware"]["releases"]],
|
||||
["Legacy", "Current"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -56,6 +56,9 @@ class PlotTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(20, self.core.db_delta(1, 10))
|
||||
self.assertAlmostEqual(-20, self.core.db_delta(10, 1))
|
||||
self.assertIsNone(self.core.db_delta(0, 1))
|
||||
self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2))
|
||||
with self.assertRaises(ValueError):
|
||||
self.core.limits(1, 1, -2, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
42
python/tests/test_setprotocol_can.py
Normal file
42
python/tests/test_setprotocol_can.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from setprotocol import Frame, FrameFlag, MessageType, build_frame, decode_datagram
|
||||
from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment
|
||||
|
||||
|
||||
def test_can_id_roundtrip():
|
||||
value = CanAddress(destination=13, source=0, priority=1, channel=7)
|
||||
assert CanAddress.unpack(value.pack()) == value
|
||||
|
||||
|
||||
def test_segmented_setp_frame_roundtrip():
|
||||
packet = build_frame(
|
||||
Frame(
|
||||
message_type=MessageType.FW_DATA,
|
||||
sequence=0x1234,
|
||||
payload=bytes(range(76)),
|
||||
flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY,
|
||||
source=0,
|
||||
destination=13,
|
||||
)
|
||||
)
|
||||
frames = segment(packet, CanAddress(13, 0, priority=1, channel=1))
|
||||
assert len(frames) == 14
|
||||
reassembler = CanReassembler()
|
||||
restored = None
|
||||
for now_ms, frame in enumerate(frames):
|
||||
restored = reassembler.feed(frame, now_ms)
|
||||
assert restored == packet
|
||||
assert decode_datagram(restored).payload == bytes(range(76))
|
||||
|
||||
|
||||
def test_reassembler_rejects_wrong_sequence():
|
||||
packet = build_frame(Frame(MessageType.PING, 1, source=0, destination=13))
|
||||
frames = segment(packet, CanAddress(13, 0))
|
||||
reassembler = CanReassembler()
|
||||
assert reassembler.feed(frames[0], 0) is None
|
||||
damaged = CanFrame(frames[1].can_id, bytes((0x22,)) + frames[1].data[1:])
|
||||
try:
|
||||
reassembler.feed(damaged, 1)
|
||||
except ValueError as error:
|
||||
assert "последовательность" in str(error)
|
||||
else:
|
||||
raise AssertionError("wrong sequence must fail")
|
||||
@@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase):
|
||||
peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__)
|
||||
self.assertEqual(64, result.frequencies[peak])
|
||||
self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9)
|
||||
detected = self.core.dominant_peak(result)
|
||||
self.assertAlmostEqual(64, detected.frequency_hz, places=9)
|
||||
self.assertAlmostEqual(3.25, detected.amplitude, places=9)
|
||||
|
||||
def test_dc_and_nyquist_are_not_doubled(self):
|
||||
times, _ = self.sample()
|
||||
|
||||
@@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t
|
||||
|
||||
|
||||
class TrendTests(unittest.TestCase):
|
||||
def test_multiplier_and_iq_scale_display_and_old_json_defaults(self):
|
||||
signal = TrendSignal("scaled", multiplier=2.5, iq=3)
|
||||
self.assertEqual(10.0, signal.display_value(32.0))
|
||||
signal.validate("TMS2812")
|
||||
payload = json.loads(encode_settings({"TMS2812": [signal]}))
|
||||
payload["profiles"]["TMS2812"][0].pop("multiplier")
|
||||
payload["profiles"]["TMS2812"][0].pop("iq")
|
||||
restored = decode_settings(json.dumps(payload))["TMS2812"][0]
|
||||
self.assertEqual((1.0, 0), (restored.multiplier, restored.iq))
|
||||
|
||||
def test_shared_kotlin_fixture_and_round_trip(self):
|
||||
settings = decode_settings(FIXTURE.read_text(encoding="utf-8"))
|
||||
self.assertEqual(5, sum(map(len, settings.values())))
|
||||
@@ -57,7 +67,6 @@ class TrendTests(unittest.TestCase):
|
||||
for value in ("-1", "+1", "FF", "0x", "1.0", "256"):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_address(value, 255)
|
||||
self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534))
|
||||
|
||||
def test_bounded_history(self):
|
||||
history = TrendHistory()
|
||||
@@ -81,6 +90,13 @@ class TrendTests(unittest.TestCase):
|
||||
self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data))
|
||||
raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2)
|
||||
self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data))
|
||||
self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534))
|
||||
self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]),
|
||||
core.watch_request(1000, [0x1234, 0xFFFF]))
|
||||
core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2)
|
||||
timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2)
|
||||
self.assertEqual(0x04030201, timestamp)
|
||||
self.assertEqual([0x1234, 0xFFFF], words)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user