Add new PyQt UI

This commit is contained in:
Ayzen
2026-04-26 18:39:55 +03:00
parent c92745d2bc
commit 0ec504ffa9
33 changed files with 3284 additions and 3789 deletions

View File

@ -1,260 +1,151 @@
"""
Communication protocol for laser control module.
"""Codec for the UART protocol implemented by the current firmware."""
Encodes commands to bytes and decodes device responses.
Faithful re-implementation of the logic in device_commands.py,
refactored into a clean, testable class-based API.
"""
from __future__ import annotations
import struct
from typing import Optional
from enum import IntEnum
from datetime import datetime
import serial
import serial.tools.list_ports
import struct
from .constants import (
BAUDRATE, SERIAL_TIMEOUT_SEC,
AD9102_FLAG_ENABLE,
AD9102_FLAG_SRAM,
AD9102_FLAG_SRAM_FORMAT_ALT,
AD9102_FLAG_TRIANGLE,
AD9102_WAVE_MAX_CHUNK_SAMPLES,
AD9102_WAVE_OPCODE_BEGIN,
AD9102_WAVE_OPCODE_CANCEL,
AD9102_WAVE_OPCODE_COMMIT,
AD9102_WAVE_SAMPLE_MAX,
AD9102_WAVE_SAMPLE_MIN,
AD9833_FLAG_ENABLE,
AD9833_FLAG_TRIANGLE,
CMD_DECODE_ENABLE,
CMD_DEFAULT_ENABLE,
CMD_PROFILE_SAVE_CONTROL,
CMD_PROFILE_SAVE_DATA,
CMD_AD9102_CONTROL,
CMD_AD9102_WAVE_CONTROL,
CMD_AD9102_WAVE_DATA,
CMD_AD9833_CONTROL,
CMD_DS1809_CONTROL,
CMD_STATE,
CMD_STM32_DAC_CONTROL,
CMD_TRANS_ENABLE,
DEFAULT_SETUP_WORD,
DS1809_FLAG_DECREMENT,
DS1809_FLAG_INCREMENT,
GET_DATA_TOTAL_LENGTH,
PROFILE_NAME_MAX_LENGTH,
PROFILE_SAVE_CONTROL_TOTAL_LENGTH,
PROFILE_SAVE_DATA_CHUNK_BYTES,
PROFILE_SAVE_DATA_TOTAL_LENGTH,
PROFILE_SAVE_OPCODE_BEGIN,
PROFILE_SAVE_OPCODE_CANCEL,
PROFILE_SAVE_OPCODE_COMMIT,
PROFILE_SAVE_SECTION_PROFILE_TEXT,
PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
SEND_PARAMS_TOTAL_LENGTH,
TASK_ENABLE_COMMAND_LENGTH,
CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE,
CMD_TRANS_ENABLE, CMD_REMOVE_FILE,
CMD_STATE, CMD_TASK_ENABLE,
STATE_DESCRIPTIONS, STATE_OK,
SHORT_CONTROL_TOTAL_LENGTH,
STM32_DAC_FLAG_ENABLE,
STATUS_DESCRIPTIONS,
STATUS_RESPONSE_LENGTH,
WAVE_DATA_TOTAL_LENGTH,
)
from .conversions import (
temp_c_to_n, temp_n_to_c,
current_ma_to_n,
current_n_to_ma,
temp_c_to_n,
temp_ext_n_to_c,
current_ma_to_n, current_n_to_ma,
voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v,
)
from .models import Measurements, VariationType
from .exceptions import (
CommunicationError,
PortNotFoundError,
CRCError,
ProtocolError,
temp_n_to_c,
voltage_3v3_n_to_v,
voltage_5v_n_to_v,
voltage_7v_n_to_v,
)
from .exceptions import CRCError, ProtocolError
from .models import DeviceState, Measurements
# Re-export enums so tests can import from protocol module
class CommandCode(IntEnum):
DECODE_ENABLE = CMD_DECODE_ENABLE
DEFAULT_ENABLE = CMD_DEFAULT_ENABLE
TRANS_ENABLE = CMD_TRANS_ENABLE
REMOVE_FILE = CMD_REMOVE_FILE
STATE = CMD_STATE
TASK_ENABLE = CMD_TASK_ENABLE
class TaskType(IntEnum):
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
CHANGE_CURRENT_LD2 = 0x02
CHANGE_TEMPERATURE_LD1 = 0x03
CHANGE_TEMPERATURE_LD2 = 0x04
class DeviceState(IntEnum):
IDLE = 0x0000
RUNNING = 0x0001
BUSY = 0x0002
ERROR = 0x00FF
ERROR_OVERHEAT = 0x0100
ERROR_POWER = 0x0200
ERROR_COMMUNICATION = 0x0400
ERROR_INVALID_COMMAND = 0x0800
# ---- Low-level helpers --------------------------------------------------
def _int_to_hex4(value: int) -> str:
"""Return 4-character lowercase hex string (065535)."""
if value < 0 or value > 65535:
raise ValueError(f"Value {value} out of uint16 range [0, 65535]")
"""Return a zero-padded four-digit lowercase hex string."""
if value < 0 or value > 0xFFFF:
raise ValueError(f"Value {value} out of uint16 range")
return f"{value:04x}"
def _flipfour(s: str) -> str:
"""Swap two byte-pairs: 'aabb''bbaa' (little-endian word)."""
if len(s) != 4:
raise ValueError(f"Expected 4-char hex string, got '{s}'")
return s[2:4] + s[0:2]
def _xor_crc(words: list) -> str:
"""XOR all 16-bit hex words and return 4-char hex CRC."""
result = int(words[0], 16)
for w in words[1:]:
result ^= int(w, 16)
return _int_to_hex4(result)
def _flipfour(value: str) -> str:
"""Swap byte pairs in a four-character hex word."""
if len(value) != 4:
raise ValueError(f"Expected 4 hex chars, got {value!r}")
return value[2:4] + value[0:2]
def _build_crc(data_hex: str) -> str:
"""Calculate XOR CRC over words 1..N of a hex string (skip word 0)."""
words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)]
return _xor_crc(words[1:])
"""Return the checksum word for a wire-order hex packet without CRC."""
if len(data_hex) % 4 != 0:
raise ValueError("Packet hex string must contain complete 16-bit words")
words = [data_hex[index:index + 4] for index in range(0, len(data_hex), 4)]
checksum = 0
for word in words[1:]:
checksum ^= int(word, 16)
return _int_to_hex4(checksum)
def _encode_setup() -> str:
"""Build the 16-bit setup word (all subsystems enabled, SD save off)."""
bits = ['0'] * 16
bits[15] = '1' # enable work
bits[14] = '1' # enable 5v1
bits[13] = '1' # enable 5v2
bits[12] = '1' # enable LD1
bits[11] = '1' # enable LD2
bits[10] = '1' # enable REF1
bits[9] = '1' # enable REF2
bits[8] = '1' # enable TEC1
bits[7] = '1' # enable TEC2
bits[6] = '1' # enable temp stab 1
bits[5] = '1' # enable temp stab 2
bits[4] = '0' # enable sd save (disabled)
bits[3] = '1' # enable PI1 coef read
bits[2] = '1' # enable PI2 coef read
bits[1] = '0' # reserved
bits[0] = '0' # reserved
return f"{int(''.join(bits), 2):04x}"
def _pack_words(words: list[int]) -> bytes:
return struct.pack("<" + "H" * len(words), *words)
# ---- Response dataclass --------------------------------------------------
def _unpack_words(data: bytes) -> tuple[int, ...]:
if len(data) % 2 != 0:
raise ProtocolError(f"Packet length must be even, got {len(data)} bytes")
return struct.unpack("<" + "H" * (len(data) // 2), data)
class Response:
"""Decoded device DATA response."""
__slots__ = [
'current1', 'current2',
'temp1', 'temp2',
'temp_ext1', 'temp_ext2',
'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0',
'to6_lsb', 'to6_msb',
'message_id',
'header',
]
def to_measurements(self) -> Measurements:
return Measurements(
current1=self.current1,
current2=self.current2,
temp1=self.temp1,
temp2=self.temp2,
temp_ext1=self.temp_ext1,
temp_ext2=self.temp_ext2,
voltage_3v3=self.voltage_3v3,
voltage_5v1=self.voltage_5v1,
voltage_5v2=self.voltage_5v2,
voltage_7v0=self.voltage_7v0,
timestamp=datetime.now(),
message_id=self.message_id,
to6_counter_lsb=self.to6_lsb,
to6_counter_msb=self.to6_msb,
def _payload_checksum(words: list[int]) -> int:
checksum = 0
for word in words:
checksum ^= word
return checksum & 0xFFFF
def _ensure_uint(value: int, name: str, minimum: int, maximum: int) -> int:
if not isinstance(value, int):
raise ValueError(f"{name} must be an integer")
if not minimum <= value <= maximum:
raise ValueError(f"{name} must be in range [{minimum}, {maximum}]")
return value
def _encode_ascii_name_words(profile_name: str) -> tuple[list[int], int]:
if not isinstance(profile_name, str):
raise ValueError("profile_name must be a string")
try:
encoded = profile_name.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError("profile_name must contain ASCII characters only") from exc
if not 1 <= len(encoded) <= PROFILE_NAME_MAX_LENGTH:
raise ValueError(
f"profile_name length must be in range [1, {PROFILE_NAME_MAX_LENGTH}]"
)
padded = encoded + (b"\x00" * (PROFILE_NAME_MAX_LENGTH - len(encoded)))
words = [
padded[index] | (padded[index + 1] << 8)
for index in range(0, PROFILE_NAME_MAX_LENGTH, 2)
]
return words, len(encoded)
# ---- Message builder --------------------------------------------------
class Message:
"""Named container for an encoded command byte array."""
def __init__(self, data: bytearray):
self._data = data
def to_bytes(self) -> bytes:
return bytes(self._data)
def __len__(self):
return len(self._data)
# ---- Protocol class --------------------------------------------------
class Protocol:
"""
Encodes commands and decodes responses for the laser control board.
Can also manage a serial port connection when port is provided.
"""
def __init__(self, port: Optional[str] = None):
self._port_name = port
self._serial: Optional[serial.Serial] = None
# ---- Connection management
def connect(self) -> None:
"""Open the serial port. Auto-detects if port is None."""
port = self._port_name or self._detect_port()
try:
self._serial = serial.Serial(
port=port,
baudrate=BAUDRATE,
timeout=SERIAL_TIMEOUT_SEC,
)
except Exception as exc:
raise CommunicationError(
f"Cannot connect to port '{port}': {exc}"
) from exc
def disconnect(self) -> None:
"""Close the serial port if open."""
if self._serial and self._serial.is_open:
self._serial.close()
@property
def is_connected(self) -> bool:
return self._serial is not None and self._serial.is_open
def _detect_port(self) -> str:
"""Return first available serial port device path."""
ports = list(serial.tools.list_ports.comports())
if not ports:
raise PortNotFoundError()
return ports[0].device
# ---- Raw I/O
def send_raw(self, data: bytes) -> None:
if self._serial is None or not self._serial.is_open:
raise CommunicationError("Serial port is not connected")
self._serial.write(data)
def receive_raw(self, length: int) -> bytes:
if self._serial is None or not self._serial.is_open:
raise CommunicationError("Serial port is not connected")
return self._serial.read(length)
# ---- Static encoding helpers (no connection required) ---------------
@staticmethod
def flipfour(value: int) -> int:
"""Byte-swap a 16-bit integer (little-endian word swap)."""
return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF)
@staticmethod
def pack_float(value: float) -> bytes:
return struct.pack('<f', value)
@staticmethod
def pack_uint16(value: int) -> bytes:
return struct.pack('<H', value)
"""Static helpers for encoding commands and decoding responses."""
@staticmethod
def calculate_crc(data: bytes) -> int:
"""
XOR CRC over all 16-bit words except the last two bytes (CRC field).
Mirrors the original CalculateCRC logic.
"""
hex_str = data.hex()
words = [hex_str[i:i+4] for i in range(0, len(hex_str), 4)]
# Skip word 0 (command code) per original firmware expectation
crc_words = words[1:]
result = int(crc_words[0], 16)
for w in crc_words[1:]:
result ^= int(w, 16)
return result
# ---- Command encoders -----------------------------------------------
"""Calculate XOR checksum over all words except the first header word."""
words = _unpack_words(data)
if len(words) <= 1:
return 0
return _payload_checksum(list(words[1:]))
@staticmethod
def encode_decode_enable(
@ -268,188 +159,328 @@ class Protocol:
pi_coeff2_i: int,
message_id: int,
) -> bytes:
"""
Build DECODE_ENABLE command (0x1111).
Sets temperature and current setpoints for both lasers.
Returns 30-byte bytearray.
"""
if current1 < 0 or current2 < 0:
raise ValueError("Current values must not be negative")
data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2
data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3
data += _flipfour('0000') * 3 # Words 4-6
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10
data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11
data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12
data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13
data += _build_crc(data) # Word 14
result = bytearray.fromhex(data)
assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \
f"DECODE_ENABLE length mismatch: {len(result)}"
return bytes(result)
"""Build the 30-byte DECODE_ENABLE command."""
words = [
CMD_DECODE_ENABLE,
DEFAULT_SETUP_WORD,
temp_c_to_n(temp1),
temp_c_to_n(temp2),
0,
0,
0,
pi_coeff1_p & 0xFFFF,
pi_coeff1_i & 0xFFFF,
pi_coeff2_p & 0xFFFF,
pi_coeff2_i & 0xFFFF,
message_id & 0xFFFF,
current_ma_to_n(current1),
current_ma_to_n(current2),
]
words.append(_payload_checksum(words[1:]))
packet = _pack_words(words)
if len(packet) != SEND_PARAMS_TOTAL_LENGTH:
raise ProtocolError(
f"DECODE_ENABLE length mismatch: {len(packet)} bytes"
)
return packet
@staticmethod
def encode_task_enable(
task_type: TaskType,
static_temp1: float,
static_temp2: float,
static_current1: float,
static_current2: float,
min_value: float,
max_value: float,
step: float,
time_step: int,
delay_time: int,
message_id: int,
pi_coeff1_p: int = 1,
pi_coeff1_i: int = 1,
pi_coeff2_p: int = 1,
pi_coeff2_i: int = 1,
def encode_trans_enable() -> bytes:
"""Build the short TRANS_ENABLE command."""
return _pack_words([CMD_TRANS_ENABLE])
@staticmethod
def encode_state() -> bytes:
"""Build the short STATE command."""
return _pack_words([CMD_STATE])
@staticmethod
def encode_default_enable() -> bytes:
"""Build the short DEFAULT_ENABLE command."""
return _pack_words([CMD_DEFAULT_ENABLE])
@staticmethod
def encode_ad9102_control(
*,
enabled: bool,
triangle: bool,
sram_mode: bool,
param0: int,
param1: int,
alt_format: bool = False,
) -> bytes:
"""
Build TASK_ENABLE command (0x7777).
Starts a measurement task (current or temperature variation).
Returns 32-byte bytearray.
"""
if not isinstance(task_type, TaskType):
try:
task_type = TaskType(task_type)
except ValueError:
raise ValueError(f"Invalid task_type: {task_type}")
data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(task_type.value)) # Word 2
match task_type:
case TaskType.CHANGE_CURRENT_LD1:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9
case TaskType.CHANGE_CURRENT_LD2:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
data += _flipfour(_int_to_hex4(int(step * 100))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9
case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2:
raise NotImplementedError("Temperature variation is not yet implemented in firmware")
case _:
raise ValueError(f"Unsupported task type: {task_type}")
data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10)
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14
data += _build_crc(data) # Word 15
result = bytearray.fromhex(data)
assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \
f"TASK_ENABLE length mismatch: {len(result)}"
return bytes(result)
"""Build an AD9102 control packet."""
flags = 0
if enabled:
flags |= AD9102_FLAG_ENABLE
if triangle:
flags |= AD9102_FLAG_TRIANGLE
if sram_mode:
flags |= AD9102_FLAG_SRAM
if alt_format:
flags |= AD9102_FLAG_SRAM_FORMAT_ALT
return Protocol._encode_short_control(
CMD_AD9102_CONTROL,
flags,
_ensure_uint(param0, "param0", 0, 0xFFFF),
_ensure_uint(param1, "param1", 0, 0xFFFF),
)
@staticmethod
def encode_trans_enable(message_id: int = 0) -> bytes:
"""Build TRANS_ENABLE command (0x4444) — request last data."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
def encode_ad9833_control(*, enabled: bool, triangle: bool, frequency_word: int) -> bytes:
"""Build an AD9833 control packet."""
flags = 0
if enabled:
flags |= AD9833_FLAG_ENABLE
if triangle:
flags |= AD9833_FLAG_TRIANGLE
frequency_word = _ensure_uint(frequency_word, "frequency_word", 0, 0x0FFFFFFF)
return Protocol._encode_short_control(
CMD_AD9833_CONTROL,
flags,
frequency_word & 0x3FFF,
(frequency_word >> 14) & 0x3FFF,
)
@staticmethod
def encode_state(message_id: int = 0) -> bytes:
"""Build STATE command (0x6666) — request device state."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
def encode_ds1809_control(*, increment: bool, decrement: bool, count: int, pulse_ms: int) -> bytes:
"""Build a DS1809 control packet."""
if increment and decrement:
raise ValueError("increment and decrement cannot both be true")
flags = 0
if increment:
flags |= DS1809_FLAG_INCREMENT
if decrement:
flags |= DS1809_FLAG_DECREMENT
return Protocol._encode_short_control(
CMD_DS1809_CONTROL,
flags,
_ensure_uint(count, "count", 0, 0xFFFF),
_ensure_uint(pulse_ms, "pulse_ms", 0, 0xFFFF),
)
@staticmethod
def encode_default_enable(message_id: int = 0) -> bytes:
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
def encode_stm32_dac_control(*, enabled: bool, dac_code: int) -> bytes:
"""Build an STM32 DAC control packet."""
flags = STM32_DAC_FLAG_ENABLE if enabled else 0
return Protocol._encode_short_control(
CMD_STM32_DAC_CONTROL,
flags,
_ensure_uint(dac_code, "dac_code", 0, 0x0FFF),
0,
)
@staticmethod
def encode_remove_file() -> bytes:
"""Build REMOVE_FILE command (0x5555) — delete saved data."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_REMOVE_FILE)))
# ---- Response decoders -----------------------------------------------
def encode_ad9102_wave_begin(sample_count: int) -> bytes:
"""Build an AD9102 custom-wave upload BEGIN packet."""
return Protocol._encode_short_control(
CMD_AD9102_WAVE_CONTROL,
AD9102_WAVE_OPCODE_BEGIN,
_ensure_uint(sample_count, "sample_count", 0, 0xFFFF),
0,
)
@staticmethod
def decode_response(data: bytes) -> Response:
"""
Decode a 30-byte DATA response from the device.
def encode_ad9102_wave_commit() -> bytes:
"""Build an AD9102 custom-wave upload COMMIT packet."""
return Protocol._encode_short_control(
CMD_AD9102_WAVE_CONTROL,
AD9102_WAVE_OPCODE_COMMIT,
0,
0,
)
Raises:
ProtocolError: If data length is wrong.
CRCError: If CRC check fails.
"""
@staticmethod
def encode_ad9102_wave_cancel() -> bytes:
"""Build an AD9102 custom-wave upload CANCEL packet."""
return Protocol._encode_short_control(
CMD_AD9102_WAVE_CONTROL,
AD9102_WAVE_OPCODE_CANCEL,
0,
0,
)
@staticmethod
def encode_ad9102_wave_data(samples: list[int]) -> bytes:
"""Build one fixed-size AD9102 custom-wave data chunk packet."""
if not samples:
raise ValueError("samples must not be empty")
if len(samples) > AD9102_WAVE_MAX_CHUNK_SAMPLES:
raise ValueError(
f"samples length must be <= {AD9102_WAVE_MAX_CHUNK_SAMPLES}"
)
encoded_samples = []
for index, sample in enumerate(samples):
if not isinstance(sample, int):
raise ValueError(f"sample[{index}] must be an integer")
if not AD9102_WAVE_SAMPLE_MIN <= sample <= AD9102_WAVE_SAMPLE_MAX:
raise ValueError(
f"sample[{index}] must be in range "
f"[{AD9102_WAVE_SAMPLE_MIN}, {AD9102_WAVE_SAMPLE_MAX}]"
)
encoded_samples.append(sample & 0xFFFF)
padded_samples = encoded_samples + [0] * (AD9102_WAVE_MAX_CHUNK_SAMPLES - len(samples))
words = [CMD_AD9102_WAVE_DATA, len(samples), *padded_samples]
words.append(_payload_checksum(words[1:]))
packet = _pack_words(words)
if len(packet) != WAVE_DATA_TOTAL_LENGTH:
raise ProtocolError(f"AD9102_WAVE_DATA length mismatch: {len(packet)} bytes")
return packet
@staticmethod
def encode_profile_save_begin(
*,
profile_name: str,
profile_text_bytes: int,
waveform_text_bytes: int,
) -> bytes:
"""Build the fixed-size BEGIN packet for a streamed SD profile save."""
name_words, name_length = _encode_ascii_name_words(profile_name)
payload_words = [
PROFILE_SAVE_OPCODE_BEGIN,
_ensure_uint(profile_text_bytes, "profile_text_bytes", 1, 0xFFFF),
_ensure_uint(waveform_text_bytes, "waveform_text_bytes", 0, 0xFFFF),
name_length,
*name_words,
0,
]
payload_words.append(_payload_checksum(payload_words))
packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words])
if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH:
raise ProtocolError(
f"PROFILE_SAVE_BEGIN length mismatch: {len(packet)} bytes"
)
return packet
@staticmethod
def encode_profile_save_commit() -> bytes:
"""Build the fixed-size COMMIT packet for a streamed SD profile save."""
payload_words = [PROFILE_SAVE_OPCODE_COMMIT] + ([0] * 12)
payload_words.append(_payload_checksum(payload_words))
packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words])
if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH:
raise ProtocolError(
f"PROFILE_SAVE_COMMIT length mismatch: {len(packet)} bytes"
)
return packet
@staticmethod
def encode_profile_save_cancel() -> bytes:
"""Build the fixed-size CANCEL packet for a streamed SD profile save."""
payload_words = [PROFILE_SAVE_OPCODE_CANCEL] + ([0] * 12)
payload_words.append(_payload_checksum(payload_words))
packet = _pack_words([CMD_PROFILE_SAVE_CONTROL, *payload_words])
if len(packet) != PROFILE_SAVE_CONTROL_TOTAL_LENGTH:
raise ProtocolError(
f"PROFILE_SAVE_CANCEL length mismatch: {len(packet)} bytes"
)
return packet
@staticmethod
def encode_profile_save_data(*, section_id: int, chunk: bytes) -> bytes:
"""Build one fixed-size data packet carrying profile or waveform text."""
if not isinstance(chunk, (bytes, bytearray)):
raise ValueError("chunk must be bytes")
if not chunk:
raise ValueError("chunk must not be empty")
if len(chunk) > PROFILE_SAVE_DATA_CHUNK_BYTES:
raise ValueError(
f"chunk length must be <= {PROFILE_SAVE_DATA_CHUNK_BYTES}"
)
if section_id not in (
PROFILE_SAVE_SECTION_PROFILE_TEXT,
PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
):
raise ValueError("section_id is invalid")
padded = bytes(chunk) + (b"\x00" * (PROFILE_SAVE_DATA_CHUNK_BYTES - len(chunk)))
data_words = [
padded[index] | (padded[index + 1] << 8)
for index in range(0, PROFILE_SAVE_DATA_CHUNK_BYTES, 2)
]
payload_words = [section_id, len(chunk), *data_words]
payload_words.append(_payload_checksum(payload_words))
packet = _pack_words([CMD_PROFILE_SAVE_DATA, *payload_words])
if len(packet) != PROFILE_SAVE_DATA_TOTAL_LENGTH:
raise ProtocolError(
f"PROFILE_SAVE_DATA length mismatch: {len(packet)} bytes"
)
return packet
@staticmethod
def _encode_short_control(header: int, word0: int, word1: int, word2: int) -> bytes:
words = [header, word0 & 0xFFFF, word1 & 0xFFFF, word2 & 0xFFFF]
words.append(_payload_checksum(words[1:]))
packet = _pack_words(words)
if len(packet) != SHORT_CONTROL_TOTAL_LENGTH:
raise ProtocolError(f"Short control length mismatch: {len(packet)} bytes")
return packet
@staticmethod
def decode_response(data: bytes) -> Measurements:
"""Decode a 30-byte telemetry frame into a Measurements object."""
if len(data) != GET_DATA_TOTAL_LENGTH:
raise ProtocolError(
f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes"
)
hex_str = data.hex()
words = _unpack_words(data)
expected_crc = _payload_checksum(list(words[1:14]))
if words[14] != expected_crc:
raise CRCError(expected=expected_crc, received=words[14])
def get_word(num: int) -> str:
return _flipfour(hex_str[num*4: num*4+4])
return Measurements(
current1=current_n_to_ma(words[1]),
current2=current_n_to_ma(words[2]),
temp1=temp_n_to_c(words[5]),
temp2=temp_n_to_c(words[6]),
temp_ext1=temp_ext_n_to_c(words[7]),
temp_ext2=temp_ext_n_to_c(words[8]),
voltage_3v3=voltage_3v3_n_to_v(words[9]),
voltage_5v1=voltage_5v_n_to_v(words[10]),
voltage_5v2=voltage_5v_n_to_v(words[11]),
voltage_7v0=voltage_7v_n_to_v(words[12]),
message_id=words[13],
to6_counter_lsb=words[3],
to6_counter_msb=words[4],
timestamp=datetime.now(),
)
def get_int_word(num: int) -> int:
return int(get_word(num), 16)
# CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order)
crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)]
computed = int(crc_words[0], 16)
for w in crc_words[1:]:
computed ^= int(w, 16)
stored = int(hex_str[56:60], 16)
if computed != stored:
raise CRCError(expected=computed, received=stored)
resp = Response()
resp.header = get_word(0)
resp.current1 = current_n_to_ma(get_int_word(1))
resp.current2 = current_n_to_ma(get_int_word(2))
resp.to6_lsb = get_int_word(3)
resp.to6_msb = get_int_word(4)
resp.temp1 = temp_n_to_c(get_int_word(5))
resp.temp2 = temp_n_to_c(get_int_word(6))
resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7))
resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8))
resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9))
resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10))
resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11))
resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12))
resp.message_id = get_int_word(13)
return resp
@staticmethod
def decode_status(data: bytes) -> tuple[DeviceState, int]:
"""Decode the two-byte firmware status response into flags and detail."""
if len(data) != STATUS_RESPONSE_LENGTH:
raise ProtocolError(
f"Expected {STATUS_RESPONSE_LENGTH} status bytes, got {len(data)}"
)
raw_word = _unpack_words(data)[0]
flags = DeviceState(raw_word & 0x00FF)
detail = (raw_word >> 8) & 0x00FF
return flags, detail
@staticmethod
def decode_state(data: bytes) -> int:
"""
Decode a 2-byte STATE response from the device.
Returns:
Integer state code (compare with DeviceState enum).
"""
if len(data) < 2:
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
hex_str = data.hex()
state_hex = _flipfour(hex_str[0:4])
return int(state_hex, 16)
"""Compatibility helper returning only the low-byte status mask."""
flags, _detail = Protocol.decode_status(data)
return int(flags)
@staticmethod
def state_to_description(state_hex_str: str) -> str:
"""Return human-readable description for a state hex string."""
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
def state_to_description(state: DeviceState | int) -> str:
"""Return a readable description for a status mask."""
state = DeviceState(int(state))
if state == DeviceState.OK:
return "All ok."
parts = [
text
for mask, text in STATUS_DESCRIPTIONS.items()
if (state & DeviceState(mask)) == DeviceState(mask)
]
if parts:
return "; ".join(parts)
return f"Unknown status mask: 0x{int(state):02X}"
__all__ = ["Protocol", "_build_crc", "_flipfour", "_int_to_hex4"]