1104更新
parent
57b755f1e6
commit
9fd733fa8a
Binary file not shown.
@ -0,0 +1,23 @@
|
||||
/* ==================== HART Tab标签页样式 ==================== */
|
||||
QTabBar#hartTabBar::tab {
|
||||
font-family: "PingFangSC-Medium", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
width: 160px;
|
||||
height: 40px;
|
||||
padding: 8px 16px;
|
||||
margin: 2px;
|
||||
border-radius: 6px;
|
||||
background-color: #F3F4F6;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
QTabBar#hartTabBar::tab:hover {
|
||||
background-color: #E3F2FD;
|
||||
color: #2277EF;
|
||||
}
|
||||
|
||||
QTabBar#hartTabBar::tab:selected {
|
||||
background-color: #2277EF;
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
"""
|
||||
HART界面组件模块
|
||||
提供HART协议相关的PyQt界面组件
|
||||
"""
|
||||
|
||||
from .HartMainWindow import HartMainWindow
|
||||
from .HartConnectionWidget import HartConnectionWidget
|
||||
from .HartDeviceInfoWidget import HartDeviceInfoWidget
|
||||
from .HartDeviceConfigWidget import HartDeviceConfigWidget
|
||||
from .HartSensorConfigWidget import HartSensorConfigWidget
|
||||
from .HartCalibrationWidget import HartCalibrationWidget
|
||||
from .HartVariableInfoWidget import HartVariableInfoWidget
|
||||
|
||||
__all__ = [
|
||||
'HartMainWindow',
|
||||
'HartConnectionWidget',
|
||||
'HartDeviceInfoWidget',
|
||||
'HartDeviceConfigWidget',
|
||||
'HartSensorConfigWidget',
|
||||
'HartCalibrationWidget',
|
||||
'HartVariableInfoWidget'
|
||||
]
|
||||
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
HART通信工具主程序
|
||||
基于PyQt的现代化HART通信界面应用程序
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtCore import QFile, QTextStream
|
||||
from UI.HartWidgets.HartMainWindow import HartMainWindow
|
||||
|
||||
def loadStyleSheet(sheetName):
|
||||
"""
|
||||
加载样式表
|
||||
|
||||
Args:
|
||||
sheetName: 样式表文件名
|
||||
|
||||
Returns:
|
||||
样式表内容
|
||||
"""
|
||||
file = QFile(sheetName)
|
||||
file.open(QFile.ReadOnly | QFile.Text)
|
||||
stream = QTextStream(file)
|
||||
return stream.readAll()
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
# 创建应用程序
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# 设置应用程序样式
|
||||
styleSheetPath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style.qss")
|
||||
if os.path.exists(styleSheetPath):
|
||||
app.setStyleSheet(loadStyleSheet(styleSheetPath))
|
||||
|
||||
# 创建主窗口
|
||||
mainWindow = HartMainWindow()
|
||||
mainWindow.setWindowTitle("HART通信工具")
|
||||
mainWindow.resize(800, 600)
|
||||
mainWindow.show()
|
||||
|
||||
# 运行应用程序
|
||||
sys.exit(app.exec_())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,3 @@
|
||||
"""
|
||||
UI模块初始化文件
|
||||
"""
|
||||
@ -0,0 +1 @@
|
||||
__version__ = "2023.6.0"
|
||||
@ -0,0 +1,28 @@
|
||||
"""A sans-io python implementation of the Highway Addressable Remote Transducer Protocol."""
|
||||
|
||||
from .__version__ import *
|
||||
|
||||
# 延迟导入以避免循环依赖
|
||||
def _get_hart_communication():
|
||||
from .HARTCommunication import HARTCommunication, BurstModeON, BurstModeOFF, PrimaryMasterMode, SecondaryMasterMode
|
||||
return HARTCommunication, BurstModeON, BurstModeOFF, PrimaryMasterMode, SecondaryMasterMode
|
||||
|
||||
# 导出常用模块
|
||||
from . import common
|
||||
from . import universal
|
||||
from . import tools
|
||||
from ._unpacker import *
|
||||
|
||||
# 导出主要类和常量
|
||||
HARTCommunication, BurstModeON, BurstModeOFF, PrimaryMasterMode, SecondaryMasterMode = _get_hart_communication()
|
||||
|
||||
__all__ = [
|
||||
'HARTCommunication',
|
||||
'BurstModeON',
|
||||
'BurstModeOFF',
|
||||
'PrimaryMasterMode',
|
||||
'SecondaryMasterMode',
|
||||
'common',
|
||||
'universal',
|
||||
'tools'
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
"""Define version."""
|
||||
|
||||
import pathlib
|
||||
import subprocess
|
||||
from .VERSION import __version__
|
||||
|
||||
|
||||
here = pathlib.Path(__file__).resolve().parent
|
||||
|
||||
|
||||
__all__ = ["__version__", "__branch__"]
|
||||
|
||||
try:
|
||||
__branch__ = (
|
||||
subprocess.run(["git", "branch", "--show-current"], capture_output=True, cwd=here)
|
||||
.stdout.strip()
|
||||
.decode()
|
||||
)
|
||||
except:
|
||||
__branch__ = ""
|
||||
|
||||
if __branch__:
|
||||
__version__ += "+" + __branch__
|
||||
@ -0,0 +1,261 @@
|
||||
import struct
|
||||
from typing import MutableMapping, Union
|
||||
from . import tools
|
||||
|
||||
|
||||
def parse(response: bytes) -> MutableMapping[str, Union[int, bytes, str, float]]:
|
||||
# print(response, 1111111111111)
|
||||
out: MutableMapping[str, Union[int, bytes, str, float]] = dict()
|
||||
out["full_response"] = response
|
||||
if response[0] & 0x80: # long address
|
||||
out["address"] = int.from_bytes(response[1:6], "big")
|
||||
response = response[6:]
|
||||
else: # short address
|
||||
out["address"] = response[1]
|
||||
response = response[2:]
|
||||
command, bytecount, response_code, device_status = struct.unpack_from(">BBBB", response)
|
||||
out["device_status"] = device_status
|
||||
out["response_code"] = response_code
|
||||
|
||||
if response_code == 0:
|
||||
out["status"] = "success"
|
||||
else:
|
||||
out["status"] = "error"
|
||||
out["error"] = f"Device responded with error code: {response_code}"
|
||||
|
||||
data = response[4 : 4 + bytecount]
|
||||
out["command"] = command
|
||||
out["command_name"] = f"hart_command_{command}"
|
||||
out["bytecount"] = bytecount
|
||||
out["data"] = data
|
||||
|
||||
# handle error return
|
||||
if bytecount == 2:
|
||||
return out
|
||||
|
||||
# universal commands
|
||||
if command in [0, 11]:
|
||||
out["command_name"] = "read_unique_identifier"
|
||||
out["manufacturer_id"] = data[1]
|
||||
out["manufacturer_device_type"] = data[2]
|
||||
out["number_response_preamble_characters"] = data[3]
|
||||
out["universal_command_revision_level"] = data[4]
|
||||
out["transmitter_specific_command_revision_level"] = data[5]
|
||||
out["software_revision_level"] = data[6]
|
||||
out["hardware_revision_level"] = data[7]
|
||||
out["device_id"] = int.from_bytes(data[9:12], "big")
|
||||
elif command in [1]:
|
||||
out["command_name"] = "read_primary_variable"
|
||||
units, variable = struct.unpack_from(">Bf", data)
|
||||
out["primary_variable_units"] = units
|
||||
out["primary_variable"] = variable
|
||||
elif command in [2]:
|
||||
out["command_name"] = "read_loop_current_and_percent"
|
||||
analog_signal, primary_variable = struct.unpack_from(">ff", data)
|
||||
out["analog_signal"] = analog_signal
|
||||
out["primary_variable"] = primary_variable
|
||||
elif command in [3]:
|
||||
out["command_name"] = "read_dynamic_variables_and_loop_current"
|
||||
if len(data) >= 4:
|
||||
analog_signal = struct.unpack_from(">f", data, 0)[0]
|
||||
out["analog_signal"] = analog_signal
|
||||
|
||||
# 计算剩余数据长度并确定支持的变量数量
|
||||
remaining_length = len(data) - 4
|
||||
variable_count = remaining_length // 5 # 每个变量占5字节 (1字节单位 + 4字节值)
|
||||
|
||||
# 动态解析变量数据
|
||||
offset = 4 # 从第4字节开始是变量数据
|
||||
for i in range(variable_count):
|
||||
if offset + 5 > len(data): # 确保有足够数据
|
||||
break
|
||||
|
||||
# 解析单位枚举值 (1字节)
|
||||
unit_enum = struct.unpack_from(">B", data, offset)[0]
|
||||
offset += 1
|
||||
|
||||
# 解析变量值 (4字节)
|
||||
variable_value = struct.unpack_from(">f", data, offset)[0]
|
||||
offset += 4
|
||||
|
||||
# 根据变量索引设置字段名
|
||||
if i == 0:
|
||||
out["primary_variable_units"] = unit_enum
|
||||
out["primary_variable"] = variable_value
|
||||
elif i == 1:
|
||||
out["secondary_variable_units"] = unit_enum
|
||||
out["secondary_variable"] = variable_value
|
||||
elif i == 2:
|
||||
out["tertiary_variable_units"] = unit_enum
|
||||
out["tertiary_variable"] = variable_value
|
||||
elif i == 3:
|
||||
out["quaternary_variable_units"] = unit_enum
|
||||
out["quaternary_variable"] = variable_value
|
||||
elif command in [6]:
|
||||
out["command_name"] = "write_polling_address"
|
||||
polling_address = struct.unpack_from(">B", data)[0]
|
||||
out["polling_address"] = polling_address
|
||||
elif command in [12]:
|
||||
# print(data)
|
||||
out["command_name"] = "read_message"
|
||||
out["message"] = data[0:24]
|
||||
# print(out, 111111111)
|
||||
elif command in [13]:
|
||||
out["command_name"] = "read_tag_descriptor_date"
|
||||
# Tag is 8 chars packed into 6 bytes
|
||||
out["device_tag_name"] = tools.unpack_packed_ascii(data[0:6], 8)
|
||||
# Descriptor is 16 chars packed into 12 bytes
|
||||
out["device_descriptor"] = tools.unpack_packed_ascii(data[6:18], 16)
|
||||
# Date is 3 bytes: day, month, year (year is offset from 1900)
|
||||
day, month, year_offset = struct.unpack_from(">BBB", data, 18)
|
||||
out["date"] = {"day": day, "month": month, "year": year_offset + 1900}
|
||||
elif command in [14]:
|
||||
out["command_name"] = "read_primary_variable_information"
|
||||
out["serial_no"] = data[0:3]
|
||||
sensor_limits_code, upper_limit, lower_limit, min_span = struct.unpack_from(
|
||||
">xxxBfff", data
|
||||
)
|
||||
out["sensor_limits_code"] = sensor_limits_code
|
||||
out["upper_limit"] = upper_limit
|
||||
out["lower_limit"] = lower_limit
|
||||
out["min_span"] = min_span
|
||||
elif command in [15]:
|
||||
out["command_name"] = "read_output_information"
|
||||
(
|
||||
alarm_code,
|
||||
transfer_fn_code,
|
||||
primary_variable_range_code,
|
||||
upper_range_value,
|
||||
lower_range_value,
|
||||
damping_value,
|
||||
write_protect,
|
||||
private_label,
|
||||
) = struct.unpack_from(">BBBfffBB", data)
|
||||
out["alarm_code"] = alarm_code
|
||||
out["transfer_fn_code"] = transfer_fn_code
|
||||
out["primary_variable_range_code"] = primary_variable_range_code
|
||||
out["upper_range_value"] = upper_range_value
|
||||
out["lower_range_value"] = lower_range_value
|
||||
out["damping_value"] = damping_value
|
||||
out["write_protect"] = write_protect
|
||||
out["private_label"] = private_label
|
||||
elif command in [16]:
|
||||
out["command_name"] = "read_final_assembly_number"
|
||||
# print(data)
|
||||
out["final_assembly_no"] = int.from_bytes(data[0:3], "big")
|
||||
elif command in [17]:
|
||||
out["command_name"] = "write_message"
|
||||
out["message"] = data[0:24]
|
||||
elif command in [18]:
|
||||
out["command_name"] = "write_tag_descriptor_date"
|
||||
out["device_tag_name"] = data[0:6]
|
||||
out["device_descriptor"] = data[6:18]
|
||||
out["date"] = data[18:21]
|
||||
elif command in [19]:
|
||||
out["command_name"] = "write_final_assembly_number"
|
||||
out["final_assembly_no"] = int.from_bytes(data[0:2], "big")
|
||||
elif command in [34]:
|
||||
out["command_name"] = "write_primary_variable_damping"
|
||||
out["damping_time"] = struct.unpack_from(">f", data)[0]
|
||||
elif command in [35]:
|
||||
out["command_name"] = "write_primary_variable_range"
|
||||
out["upper_range"] = struct.unpack_from(">f", data)[0]
|
||||
out["lower_range"] = struct.unpack_from(">f", data[4:])[0]
|
||||
elif command in [36]:
|
||||
out["command_name"] = "calibrate_20ma"
|
||||
# 20mA校准命令无返回数据
|
||||
elif command in [37]:
|
||||
out["command_name"] = "calibrate_4ma"
|
||||
# 4mA校准命令无返回数据
|
||||
elif command in [40]:
|
||||
out["command_name"] = "set_fixed_current_output"
|
||||
if len(data) > 0:
|
||||
out["fixed_output_enabled"] = data[0] == 1
|
||||
if len(data) > 1:
|
||||
out["fixed_current_value"] = struct.unpack_from(">f", data[1:])[0]
|
||||
elif command in [43]:
|
||||
out["command_name"] = "calibrate_zero_point"
|
||||
# 零点校准命令无返回数据
|
||||
elif command in [44]:
|
||||
out["command_name"] = "write_primary_variable_units"
|
||||
out["units_code"] = data[0]
|
||||
elif command in [45]:
|
||||
out["command_name"] = "trim_loop_current_4ma"
|
||||
out["measured_current"] = struct.unpack_from(">f", data)[0]
|
||||
elif command in [46]:
|
||||
out["command_name"] = "trim_loop_current_20ma"
|
||||
out["measured_current"] = struct.unpack_from(">f", data)[0]
|
||||
elif command in [47]:
|
||||
out["command_name"] = "write_primary_variable_output_function"
|
||||
out["transfer_function_code"] = data[0]
|
||||
|
||||
|
||||
# COMMON COMMANDS
|
||||
|
||||
# elif command in [37]:
|
||||
# out["command_name"] = "set_primary_variable_lower_range_value"
|
||||
# out[""] =
|
||||
# request data bytes = NONE, response data bytes = NONE
|
||||
|
||||
# elif command in [38]:
|
||||
# out["command_name"] = "reset_configuration_changed_flag"
|
||||
# out[""] =
|
||||
# request data bytes = NONE, response data bytes = NONE
|
||||
|
||||
# elif command in [42]:
|
||||
# out["command_name"] = "perform_master_reset"
|
||||
# out[""] =
|
||||
# request data bytes = NONE, response data bytes = NONE
|
||||
|
||||
# elif command in [48]:
|
||||
# out["command_name"] = "read_additional_transmitter_status"
|
||||
# out[""] =
|
||||
# request data bytes = NONE, response data bytes = NONE
|
||||
|
||||
elif command in [50]:
|
||||
out["command_name"] = "read_dynamic_variable_assignments"
|
||||
(
|
||||
primary_transmitter_variable,
|
||||
secondary_transmitter_variable,
|
||||
tertiary_transmitter_variable,
|
||||
quaternary_transmitter_variable,
|
||||
) = struct.unpack_from(">BBBB", data)
|
||||
out["primary_transmitter_variable"] = primary_transmitter_variable
|
||||
out["secondary_transmitter_variable"] = secondary_transmitter_variable
|
||||
out["tertiary_transmitter_variable"] = tertiary_transmitter_variable # NOT USED
|
||||
out["quaternary_transmitter_variable"] = quaternary_transmitter_variable # NOT USED
|
||||
elif command in [59]:
|
||||
out["command_name"] = "write_number_of_response_preambles"
|
||||
n_response_preambles = struct.unpack_from(">B", data)[0]
|
||||
out["n_response_preambles"] = n_response_preambles
|
||||
elif command in [66]:
|
||||
out["command_name"] = "toggle_analog_output_mode"
|
||||
(
|
||||
analog_output_selection,
|
||||
analog_output_units_code,
|
||||
fixed_analog_output,
|
||||
) = struct.unpack_from(">BBf", data)
|
||||
out["analog_output_selection"] = analog_output_selection
|
||||
out["analog_output_units_code"] = analog_output_units_code
|
||||
out["fixed_analog_output"] = fixed_analog_output
|
||||
elif command in [67]:
|
||||
out["command_name"] = "trim_analog_output_zero"
|
||||
analog_output_code, analog_output_units_code, measured_analog_output = struct.unpack_from(
|
||||
">BBf", data
|
||||
)
|
||||
out["analog_output_code"] = analog_output_code
|
||||
out["analog_output_units_code"] = analog_output_units_code
|
||||
out["measured_analog_output"] = measured_analog_output
|
||||
elif command in [68]:
|
||||
out["command_name"] = "trim_analog_output_span"
|
||||
analog_output_code, analog_output_units_code, measured_analog_output = struct.unpack_from(
|
||||
">BBf", data
|
||||
)
|
||||
out["analog_output_code"] = analog_output_code
|
||||
out["analog_output_units_code"] = analog_output_units_code
|
||||
out["measured_analog_output"] = measured_analog_output
|
||||
elif command in [123]:
|
||||
out["command_name"] = "select_baud_rate"
|
||||
out["baud_rate"] = int.from_bytes(data, "big")
|
||||
|
||||
return out
|
||||
@ -0,0 +1,140 @@
|
||||
__all__ = ["Unpacker"]
|
||||
|
||||
import asyncio
|
||||
from collections import namedtuple
|
||||
import io
|
||||
import struct
|
||||
from tabnanny import check
|
||||
import warnings
|
||||
|
||||
from ._parsing import parse
|
||||
from . import tools
|
||||
|
||||
|
||||
class Unpacker:
|
||||
"""
|
||||
Create an Unpacker to decode a byte stream into HART protocol messages.
|
||||
|
||||
The ``file_like`` parameter should be an object which data can be sourced from.
|
||||
It should support the ``read()`` method.
|
||||
|
||||
The ``on_error`` parameter selects the action to take if invalid data is detected.
|
||||
If set to ``"continue"`` (the default), bytes will be discarded if the byte sequence
|
||||
does not appear to be a valid message.
|
||||
If set to ``"warn"``, the behaviour is identical, but a warning message will be emitted.
|
||||
To instead immediately abort the stream decoding and raise a ``RuntimeError``, set to
|
||||
``"raise"``.
|
||||
|
||||
:param file_like: A file-like object which data can be `read()` from.
|
||||
:param on_error: Action to take if invalid data is detected.
|
||||
"""
|
||||
|
||||
def __init__(self, file_like=None, on_error="continue"):
|
||||
if file_like is None:
|
||||
self._file = io.BytesIO()
|
||||
else:
|
||||
self._file = file_like
|
||||
self.buf = b""
|
||||
self.on_error = on_error
|
||||
# print(self._file)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def _decoding_error(self, message="Error decoding message from buffer."):
|
||||
"""
|
||||
Take appropriate action if parsing of data stream fails.
|
||||
|
||||
:param message: Warning or error message string.
|
||||
"""
|
||||
if self.on_error == "raise":
|
||||
raise RuntimeError(message)
|
||||
if self.on_error == "warn":
|
||||
warnings.warn(message)
|
||||
|
||||
def _read_one_byte_if_possible(self):
|
||||
if self._file.in_waiting > 0:
|
||||
# print(1)
|
||||
return self._file.read(1)
|
||||
else:
|
||||
raise StopIteration
|
||||
|
||||
def __next__(self):
|
||||
# must work with at least two bytes to start with
|
||||
while len(self.buf) < 3:
|
||||
self.buf += self._read_one_byte_if_possible()
|
||||
# keep reading until we find a minimum preamble
|
||||
# print(self.buf, 11)
|
||||
while self.buf[:3] not in [b"\xFF\xFF\x06", b"\xFF\xFF\x86"]:
|
||||
self.buf += self._read_one_byte_if_possible()
|
||||
# print(self.buf)
|
||||
self.buf = self.buf[1:]
|
||||
self._decoding_error("Head of buffer not recognized as valid preamble")
|
||||
# now the head of our buffer is the start charachter plus two preamble
|
||||
# we will read all the way through status
|
||||
if self.buf[2] & 0x80:
|
||||
l = 12
|
||||
else:
|
||||
l = 8
|
||||
while len(self.buf) < l:
|
||||
self.buf += self._read_one_byte_if_possible()
|
||||
# now we can use the bytecount to read through the data and checksum
|
||||
#
|
||||
# print(self.buf)
|
||||
# print(type(self.buf[l - 4]), 222)
|
||||
if self.buf[l - 4] == 15: # 对command15进行特殊操作 修复报文错误
|
||||
# bytecount = 19
|
||||
self.buf = self.buf[:l - 3] + b'\x13\x00\x00\x00'
|
||||
bytecount = self.buf[l - 3]
|
||||
response_length = l + bytecount - 1
|
||||
# print(self.buf, bytecount)
|
||||
|
||||
while len(self.buf) < response_length:
|
||||
self.buf += self._read_one_byte_if_possible()
|
||||
# checksum
|
||||
# print(self.buf)
|
||||
checksum = int.from_bytes(
|
||||
tools.calculate_checksum(self.buf[2 : response_length - 1]), "big"
|
||||
)
|
||||
# print(self.buf)
|
||||
if checksum != self.buf[response_length - 1]:
|
||||
# print(66666666)
|
||||
self._decoding_error("Invalid checksum.")
|
||||
raise StopIteration
|
||||
# print(self.buf)
|
||||
# parse
|
||||
response = self.buf[2:response_length]
|
||||
# print(response, 'test')
|
||||
# print(response)
|
||||
dict_ = parse(response)
|
||||
# clear buffer
|
||||
if len(self.buf) == response_length:
|
||||
self.buf = b""
|
||||
else:
|
||||
self.buf = self.buf[response_length + 3 :]
|
||||
# return
|
||||
return dict_
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
while True:
|
||||
try:
|
||||
return next(self)
|
||||
except StopIteration:
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
def feed(self, data: bytes):
|
||||
"""
|
||||
Add byte data to the input stream.
|
||||
|
||||
The input stream must support random access, if it does not, must be fed externally
|
||||
(e.g. serial port data).
|
||||
|
||||
:param data: Byte array containing data to add.
|
||||
"""
|
||||
pos = self._file.tell()
|
||||
self._file.seek(0, 2)
|
||||
self._file.write(data)
|
||||
self._file.seek(pos)
|
||||
@ -0,0 +1,267 @@
|
||||
from . import tools
|
||||
|
||||
TRANSFER_FUNCTION_CODE = {
|
||||
0: "线性 (Linear)",
|
||||
1: "平方根 (Square Root)",
|
||||
2: "平方根三次方 (Square Root 3rd Power)",
|
||||
3: "平方根五次方 (Square Root 5th Power)",
|
||||
4: "特殊曲线 (Special Curve) - 不推荐使用",
|
||||
5: "平方 (Square)",
|
||||
6: "带截止的平方根 (Square root with cut-off)",
|
||||
10: "等百分比 1:25 (Equal Percentage 1:25)",
|
||||
11: "等百分比 1:33 (Equal Percentage 1:33)",
|
||||
12: "等百分比 1:50 (Equal Percentage 1:50)",
|
||||
15: "快开 1:25 (Quick Open 1:25)",
|
||||
16: "快开 1:33 (Quick Open 1:33)",
|
||||
17: "快开 1:50 (Quick Open 1:50)",
|
||||
30: "双曲线 (Hyperbolic) - Shape Factor 0.10",
|
||||
31: "双曲线 (Hyperbolic) - Shape Factor 0.20",
|
||||
32: "双曲线 (Hyperbolic) - Shape Factor 0.30",
|
||||
34: "双曲线 (Hyperbolic) - Shape Factor 0.50",
|
||||
37: "双曲线 (Hyperbolic) - Shape Factor 0.70",
|
||||
40: "双曲线 (Hyperbolic) - Shape Factor 1.00",
|
||||
41: "双曲线 (Hyperbolic) - Shape Factor 1.50",
|
||||
42: "双曲线 (Hyperbolic) - Shape Factor 2.00",
|
||||
43: "双曲线 (Hyperbolic) - Shape Factor 3.00",
|
||||
44: "双曲线 (Hyperbolic) - Shape Factor 4.00",
|
||||
45: "双曲线 (Hyperbolic) - Shape Factor 5.00",
|
||||
100: "平底罐 (Flat bottom tank)",
|
||||
101: "锥形或金字塔形底罐 (Conical or pyramidal bottom tank)",
|
||||
102: "抛物线形底罐 (Parabolic bottom tank)",
|
||||
103: "球形底罐 (Spherical bottom tank)",
|
||||
104: "斜底罐 (Angled bottom tank)",
|
||||
105: "平端圆柱罐 (Flat end cylinder tank)",
|
||||
106: "抛物线端圆柱罐 (Parabolic end cylinder tank)",
|
||||
107: "球形罐 (Spherical tank)",
|
||||
230: "离散/开关 (Discrete/Switch)",
|
||||
250: "未使用 (Not Used)",
|
||||
251: "无 (None)",
|
||||
252: "未知 (Unknown)",
|
||||
253: "特殊 (Special)"
|
||||
}
|
||||
REVERSE_TRANSFER_FUNCTION_CODE = {v: k for k, v in TRANSFER_FUNCTION_CODE.items()}
|
||||
|
||||
UNITS_CODE = {
|
||||
# Pressure
|
||||
1: "inH2O @ 68 F",
|
||||
2: "inHg @ 0 C",
|
||||
3: "ftH2O @ 68 F",
|
||||
4: "mmH2O @ 68 F",
|
||||
5: "mmHg @ 0 C",
|
||||
6: "psi",
|
||||
7: "bar",
|
||||
8: "mbar",
|
||||
9: "g/cm2",
|
||||
10: "kg/cm2",
|
||||
11: "Pa",
|
||||
12: "kPa",
|
||||
13: "torr",
|
||||
14: "atm",
|
||||
145: "inH2O @ 60F",
|
||||
237: "MPa",
|
||||
238: "inH2O @ 4C",
|
||||
239: "mmH2O @ 4C",
|
||||
# Temperature
|
||||
32: "deg C",
|
||||
33: "deg F",
|
||||
34: "deg R",
|
||||
35: "K",
|
||||
# Volumetric Flow
|
||||
15: "cu ft/min",
|
||||
16: "gal/min",
|
||||
17: "liter/min",
|
||||
18: "imp gal/min",
|
||||
19: "cu m/hr",
|
||||
22: "gal/sec",
|
||||
23: "Mgal/day",
|
||||
24: "liter/sec",
|
||||
25: "Ml/day",
|
||||
26: "cu ft/sec",
|
||||
27: "cu ft/day",
|
||||
28: "cu m/sec",
|
||||
29: "cu m/day",
|
||||
30: "imp gal/hr",
|
||||
31: "imp gal/day",
|
||||
121: "std cu m/hr",
|
||||
122: "std liter/hr",
|
||||
123: "std cu ft/min",
|
||||
130: "cu ft/hr",
|
||||
131: "cu m/min",
|
||||
132: "bbl/sec",
|
||||
133: "bbl/min",
|
||||
134: "bbl/hr",
|
||||
135: "bbl/day",
|
||||
136: "gal/hr",
|
||||
137: "imp gal/sec",
|
||||
235: "gal/day",
|
||||
# Velocity
|
||||
20: "ft/sec",
|
||||
21: "m/sec",
|
||||
114: "in/sec",
|
||||
115: "in/min",
|
||||
116: "ft/min",
|
||||
120: "m/hr",
|
||||
# Volume
|
||||
40: "gal",
|
||||
41: "liter",
|
||||
42: "imp gal",
|
||||
43: "cu m",
|
||||
46: "bbl",
|
||||
110: "bushel",
|
||||
111: "cu yd",
|
||||
112: "cu ft",
|
||||
113: "cu in",
|
||||
134: "bbl liq",
|
||||
166: "std cu m",
|
||||
167: "std l",
|
||||
168: "std cu ft",
|
||||
236: "hectoliter",
|
||||
# Mass
|
||||
60: "g",
|
||||
61: "kg",
|
||||
62: "metric ton",
|
||||
63: "lb",
|
||||
64: "short ton",
|
||||
65: "long ton",
|
||||
125: "oz",
|
||||
# Mass Flow
|
||||
70: "g/sec",
|
||||
71: "g/min",
|
||||
72: "g/hr",
|
||||
73: "kg/sec",
|
||||
74: "kg/min",
|
||||
75: "kg/hr",
|
||||
76: "kg/day",
|
||||
77: "metric ton/min",
|
||||
78: "metric ton/hr",
|
||||
79: "metric ton/day",
|
||||
80: "lb/sec",
|
||||
81: "lb/min",
|
||||
82: "lb/hr",
|
||||
83: "lb/day",
|
||||
84: "short ton/min",
|
||||
85: "short ton/hr",
|
||||
86: "short ton/day",
|
||||
87: "long ton/hr",
|
||||
88: "long ton/day",
|
||||
# Mass per Volume
|
||||
90: "SGU",
|
||||
91: "g/cu cm",
|
||||
92: "kg/cu m",
|
||||
93: "lb/gal",
|
||||
94: "lb/cu ft",
|
||||
95: "g/ml",
|
||||
96: "kg/liter",
|
||||
97: "g/liter",
|
||||
98: "lb/cu in",
|
||||
99: "short ton/cu yd",
|
||||
100: "deg Twaddell",
|
||||
102: "deg Baume",
|
||||
103: "deg API",
|
||||
104: "deg API",
|
||||
146: "ug/liter",
|
||||
147: "ug/cu m",
|
||||
# Viscosity
|
||||
54: "cSt",
|
||||
55: "cP",
|
||||
# Electric Potential
|
||||
36: "mV",
|
||||
58: "V",
|
||||
# Electric Current
|
||||
39: "mA",
|
||||
# Electric Resistance
|
||||
37: "ohm",
|
||||
163: "kohm",
|
||||
# Energy (includes Work)
|
||||
69: "J",
|
||||
89: "dtherm",
|
||||
126: "kWh",
|
||||
128: "MWh",
|
||||
162: "kcal",
|
||||
164: "MJ",
|
||||
165: "Btu",
|
||||
# Power
|
||||
127: "kW",
|
||||
129: "hp",
|
||||
140: "Mcal/hr",
|
||||
141: "MJ/hr",
|
||||
142: "Btu/hr",
|
||||
# Radial Velocity
|
||||
117: "deg/sec",
|
||||
118: "rev/sec",
|
||||
119: "rpm",
|
||||
# Miscellaneous
|
||||
38: "Hz",
|
||||
57: "percent",
|
||||
59: "pH",
|
||||
101: "deg Balling",
|
||||
105: "percent solids/wt",
|
||||
106: "percent solids/vol",
|
||||
107: "deg Plato",
|
||||
108: "proof/vol",
|
||||
109: "proof/mass",
|
||||
139: "ppm",
|
||||
148: "percent consistency",
|
||||
149: "vol percent",
|
||||
150: "percent steam qual",
|
||||
152: "cu ft/lb",
|
||||
153: "pF",
|
||||
154: "ml/liter",
|
||||
155: "ul/liter",
|
||||
156: "dB",
|
||||
160: "deg Brix",
|
||||
161: "percent LEL",
|
||||
169: "ppb",
|
||||
# Generic & Reserved
|
||||
240: "Manufacturer specific",
|
||||
241: "Manufacturer specific",
|
||||
242: "Manufacturer specific",
|
||||
243: "Manufacturer specific",
|
||||
244: "Manufacturer specific",
|
||||
245: "Manufacturer specific",
|
||||
246: "Manufacturer specific",
|
||||
247: "Manufacturer specific",
|
||||
248: "Manufacturer specific",
|
||||
249: "Manufacturer specific",
|
||||
250: "Not Used",
|
||||
251: "Unknown",
|
||||
252: "Unknown",
|
||||
253: "Special"
|
||||
}
|
||||
REVERSE_UNITS_CODE = {v: k for k, v in UNITS_CODE.items()}
|
||||
|
||||
def get_unit_description(code):
|
||||
"""根据代码获取单位描述"""
|
||||
return UNITS_CODE.get(code, f"未知代码({code})")
|
||||
|
||||
def set_primary_variable_lower_range_value(address: bytes, value) -> bytes:
|
||||
return tools.pack_command(address, command_id=37)
|
||||
|
||||
def reset_configuration_changed_flag(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=38)
|
||||
|
||||
def perform_master_reset(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=42)
|
||||
|
||||
def read_additional_transmitter_status(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=48)
|
||||
|
||||
def read_dynamic_variable_assignments(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=50)
|
||||
|
||||
def write_number_of_response_preambles(address: bytes, number: int) -> bytes:
|
||||
data = number.to_bytes(1, "big")
|
||||
return tools.pack_command(address, command_id=59, data=data)
|
||||
|
||||
def toggle_analog_output_mode(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=66)
|
||||
|
||||
def trim_analog_output_zero(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=67)
|
||||
|
||||
def trim_analog_output_span(address: bytes) -> bytes:
|
||||
return tools.pack_command(address, command_id=68)
|
||||
|
||||
def select_baud_rate(address: bytes, rate: int) -> bytes:
|
||||
data = rate.to_bytes(1, "big")
|
||||
return tools.pack_command(address, command_id=123, data=data)
|
||||
@ -0,0 +1,147 @@
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
|
||||
def calculate_checksum(command: Union[int, bytes]) -> bytes:
|
||||
# print(command)
|
||||
if type(command) == int:
|
||||
command = command.to_bytes(64, "big") # type: ignore
|
||||
lrc = 0
|
||||
for byte in command: # type: ignore
|
||||
lrc ^= byte
|
||||
out = lrc.to_bytes(1, "big")
|
||||
# print(out)
|
||||
return out
|
||||
|
||||
|
||||
def calculate_long_address(manufacturer_id: int, manufacturer_device_type: int, device_id: bytes):
|
||||
out = int.from_bytes(device_id, "big")
|
||||
out |= manufacturer_device_type << 24
|
||||
out |= manufacturer_id << 32
|
||||
return out.to_bytes(5, "big")
|
||||
|
||||
|
||||
def pack_command(address, command_id, data=None):
|
||||
# if type(address) == bytes:
|
||||
# address = int.from_bytes(address, "big")
|
||||
if type(command_id) == int:
|
||||
command_id = command_id.to_bytes(1, "big")
|
||||
command = b"\xFF\xFF\xFF\xFF\xFF\xFF" # preamble
|
||||
command += b"\x82" if len(address) >= 5 else b"\x02"
|
||||
command += address
|
||||
command += command_id
|
||||
if data is None:
|
||||
command += b"\x00" # byte count
|
||||
else:
|
||||
# print(len(data), 22222222222)
|
||||
command += len(data).to_bytes(1, "big") # byte count
|
||||
command += data # data
|
||||
# print(command[6:])
|
||||
command += calculate_checksum(command[6:])
|
||||
# print(command)
|
||||
return command
|
||||
|
||||
|
||||
PACKED_ASCII_MAP = {
|
||||
0x00: '@', 0x01: 'A', 0x02: 'B', 0x03: 'C', 0x04: 'D', 0x05: 'E', 0x06: 'F', 0x07: 'G',
|
||||
0x08: 'H', 0x09: 'I', 0x0A: 'J', 0x0B: 'K', 0x0C: 'L', 0x0D: 'M', 0x0E: 'N', 0x0F: 'O',
|
||||
0x10: 'P', 0x11: 'Q', 0x12: 'R', 0x13: 'S', 0x14: 'T', 0x15: 'U', 0x16: 'V', 0x17: 'W',
|
||||
0x18: 'X', 0x19: 'Y', 0x1A: 'Z', 0x1B: '[', 0x1C: '\\', 0x1D: ']', 0x1E: '^', 0x1F: '_', # 这里应该是 '_' 而不是 '-'
|
||||
0x20: ' ', 0x21: '!', 0x22: '"', 0x23: '=', 0x24: '$', 0x25: '%', 0x26: '&', 0x27: '`', # 注意这里是反引号 '`'
|
||||
0x28: '(', 0x29: ')', 0x2A: '*', 0x2B: '+', 0x2C: ',', 0x2D: '-', 0x2E: '.', 0x2F: '/',
|
||||
0x30: '0', 0x31: '1', 0x32: '2', 0x33: '3', 0x34: '4', 0x35: '5', 0x36: '6', 0x37: '7',
|
||||
0x38: '8', 0x39: '9', 0x3A: ':', 0x3B: ';', 0x3C: '<', 0x3D: '=', 0x3E: '>', 0x3F: '?'
|
||||
}
|
||||
|
||||
|
||||
|
||||
def unpack_packed_ascii(data: bytes, expected_len: int) -> str:
|
||||
"""Unpacks a byte array into a string using the HART Packed ASCII 6-bit encoding by processing in chunks."""
|
||||
chars = []
|
||||
# Process data in 3-byte chunks, as 4 characters (24 bits) fit into 3 bytes (24 bits)
|
||||
for i in range(0, len(data), 3):
|
||||
chunk = data[i:i+3]
|
||||
|
||||
# Pad chunk to 3 bytes if it's a partial chunk at the end
|
||||
if len(chunk) < 3:
|
||||
chunk += b'\x00' * (3 - len(chunk))
|
||||
|
||||
byte1, byte2, byte3 = chunk
|
||||
|
||||
# Unpack 4 6-bit characters from the 3 8-bit bytes
|
||||
c1 = byte1 >> 2
|
||||
c2 = ((byte1 & 0x03) << 4) | (byte2 >> 4)
|
||||
c3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6)
|
||||
c4 = byte3 & 0x3F
|
||||
|
||||
codes = [c1, c2, c3, c4]
|
||||
for code in codes:
|
||||
chars.append(PACKED_ASCII_MAP.get(code, '?'))
|
||||
|
||||
# Join and then trim to the exact expected length. Do not rstrip() as spaces can be valid.
|
||||
return "".join(chars[:expected_len])
|
||||
|
||||
|
||||
|
||||
|
||||
# Build a reverse map for packing, handling duplicates deterministically
|
||||
REVERSE_PACKED_ASCII_MAP = {}
|
||||
# Iterate through the original map, sorted by code value (key)
|
||||
for code, char in sorted(PACKED_ASCII_MAP.items(), key=lambda item: item[0]):
|
||||
# This ensures that for characters with multiple codes, the one with the highest code value is chosen.
|
||||
REVERSE_PACKED_ASCII_MAP[char] = code
|
||||
|
||||
|
||||
def pack_packed_ascii(text: Union[str, bytes], expected_len: int) -> bytes:
|
||||
"""
|
||||
Packs a string or bytes into a HART Packed ASCII byte array.
|
||||
This function is the symmetrical inverse of the unpack_packed_ascii function.
|
||||
"""
|
||||
# Defensively decode if bytes are passed in
|
||||
if isinstance(text, bytes):
|
||||
try:
|
||||
# Try decoding with ascii, fall back to latin-1 which never fails
|
||||
text = text.decode('ascii')
|
||||
except UnicodeDecodeError:
|
||||
text = text.decode('latin-1')
|
||||
|
||||
# 1. Pad/truncate the input string to the exact expected length
|
||||
padded_text = text.ljust(expected_len, ' ')
|
||||
padded_text = padded_text[:expected_len]
|
||||
|
||||
# 2. Convert all characters to their corresponding 6-bit codes
|
||||
codes = [REVERSE_PACKED_ASCII_MAP.get(c, 0x3F) for c in padded_text] # Default to '?' (0x3F)
|
||||
|
||||
packed_bytes = bytearray()
|
||||
|
||||
# 3. Process the codes in chunks of 4
|
||||
for i in range(0, len(codes), 4):
|
||||
# Get a chunk of up to 4 codes.
|
||||
chunk = codes[i:i+4]
|
||||
|
||||
# If it's a partial chunk at the end, pad with space codes to make a full chunk of 4.
|
||||
while len(chunk) < 4:
|
||||
chunk.append(REVERSE_PACKED_ASCII_MAP[' '])
|
||||
|
||||
c1, c2, c3, c4 = chunk
|
||||
|
||||
# 4. Pack the 4 6-bit codes into 3 8-bit bytes, mirroring the unpacking logic
|
||||
byte1 = (c1 << 2) | (c2 >> 4)
|
||||
byte2 = ((c2 & 0x0F) << 4) | (c3 >> 2)
|
||||
byte3 = ((c3 & 0x03) << 6) | c4
|
||||
|
||||
packed_bytes.extend([byte1, byte2, byte3])
|
||||
|
||||
# 5. Calculate the exact number of bytes required for the original expected length
|
||||
num_bytes = math.ceil(expected_len * 6 / 8)
|
||||
|
||||
# 6. Get the core byte array, trimmed to the precise required size
|
||||
result = bytes(packed_bytes[:num_bytes])
|
||||
|
||||
# For Command 17 (Write Message), the spec requires a fixed 24-byte data field.
|
||||
# This corresponds to an expected character length of 32.
|
||||
if expected_len == 32:
|
||||
# Pad the result to exactly 24 bytes.
|
||||
return result.ljust(24, b'\x00')
|
||||
|
||||
return result
|
||||
Loading…
Reference in New Issue