feat(cm): 新增大族激光打标机(Hanslaser)单件打标接口
新增 HanslaserClient(STX/ETX 帧, gbk 编码, 每条命令校验回包)与 send_to_hanslaser action, 走 $Initialize_/$Data_/$MarkStart_ 单件流程。 该协议无队列能力, 与 1880 喷码机协议独立, 各用各的 serializer 与接口。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
301e886a9e
commit
652555ef01
|
|
@ -167,3 +167,96 @@ class CoderClient:
|
|||
k, v = pair.split("=", 1)
|
||||
fields[k] = v
|
||||
return fields
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Hanslaser(大族激光) 老款打标机 标准通讯协议(Winsocket/TCP)
|
||||
# 帧格式: STX(0x02) + 内容 + ETX(0x03), 每条命令下位机都回一帧
|
||||
# 单件打标流程: $Initialize_<文件> -> $Data_<v1,v2,...> -> $MarkStart_
|
||||
# 该协议无队列/批量能力, 只能逐件: 一组 Data 对应一次 MarkStart
|
||||
# ============================================================================
|
||||
|
||||
STX = b"\x02"
|
||||
ETX = b"\x03"
|
||||
|
||||
|
||||
class HanslaserClient:
|
||||
encoding = "gbk" # 大族激光为国产机, 文档 code page 936
|
||||
|
||||
def __init__(self, ip: str, port: int, timeout: float = 5.0):
|
||||
if not ip:
|
||||
raise CoderError("打标机IP未配置")
|
||||
if not port:
|
||||
raise CoderError("打标机端口未配置")
|
||||
self.ip = ip
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
|
||||
def _encode(self, text: str, label: str) -> bytes:
|
||||
if "\x02" in text or "\x03" in text:
|
||||
raise CoderError(f"{label}含 STX/ETX, 会破坏帧结构")
|
||||
if "," in text:
|
||||
raise CoderError(f"{label}含 ',', 与 Hanslaser $Data 分隔符冲突")
|
||||
if "\r" in text or "\n" in text:
|
||||
raise CoderError(f"{label}含 CR/LF")
|
||||
try:
|
||||
return text.encode(self.encoding)
|
||||
except UnicodeEncodeError as e:
|
||||
raise CoderError(f"{label}含打标机不支持的字符: {e}")
|
||||
|
||||
def _send_recv(self, body: bytes) -> str:
|
||||
"""发 STX+body+ETX, 读回一整帧, 返回去掉 STX/ETX 后的内容字符串。"""
|
||||
frame = STX + body + ETX
|
||||
try:
|
||||
with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s:
|
||||
s.sendall(frame)
|
||||
buf = b""
|
||||
while ETX not in buf:
|
||||
chunk = s.recv(256)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
except (socket.timeout, OSError) as e:
|
||||
raise CoderError(f"打标通讯失败 {self.ip}:{self.port} - {e}")
|
||||
if ETX not in buf:
|
||||
raise CoderError(f"打标机无完整回包(缺 ETX): {buf!r}")
|
||||
start = buf.find(STX)
|
||||
end = buf.find(ETX, start + 1 if start >= 0 else 0)
|
||||
content = buf[(start + 1 if start >= 0 else 0):end]
|
||||
return content.decode(self.encoding, errors="replace")
|
||||
|
||||
def initialize(self, filename: str):
|
||||
"""$Initialize_<文件名> -> $Initialize_OK / $Initialize_FALSE"""
|
||||
resp = self._send_recv(b"$Initialize_" + self._encode(filename, "打标文件名"))
|
||||
if resp != "$Initialize_OK":
|
||||
raise CoderError(f"打标文件初始化失败: {resp}")
|
||||
|
||||
def send_data(self, values: list):
|
||||
"""$Data_<v1,v2,...> -> $Receive_OK / $Receive_ERROR / $SysNoReady"""
|
||||
if not values:
|
||||
raise CoderError("打标数据为空")
|
||||
payload = b",".join(self._encode(str(v), "打标内容") for v in values)
|
||||
resp = self._send_recv(b"$Data_" + payload)
|
||||
if resp == "$Receive_OK":
|
||||
return
|
||||
if resp == "$SysNoReady":
|
||||
raise CoderError("打标机未准备好($SysNoReady)")
|
||||
raise CoderError(f"打标数据被拒绝: {resp}")
|
||||
|
||||
def mark_start(self):
|
||||
"""$MarkStart_ -> $MarkStart_OK / $MarkStart_OK_<次数> / $MarkStart_ERROR
|
||||
|
||||
下位机开始打标即回 $MarkStart_OK; 打标完成会再回 $MarkStart_OK_<次数>。
|
||||
单件场景只确认开始成功, 不阻塞等待打标完成。
|
||||
"""
|
||||
resp = self._send_recv(b"$MarkStart_")
|
||||
if not resp.startswith("$MarkStart_OK"):
|
||||
raise CoderError(f"启动打标失败: {resp}")
|
||||
return resp
|
||||
|
||||
def mark_stop(self):
|
||||
"""$MarkStop_ -> $MarkStop_OK / $MarkStop_ERROR"""
|
||||
resp = self._send_recv(b"$MarkStop_")
|
||||
if not resp.startswith("$MarkStop_OK"):
|
||||
raise CoderError(f"停止打标失败: {resp}")
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ class CoderSendSerializer(serializers.Serializer):
|
|||
coder_jobname = serializers.CharField(label='信息模板名', required=False, allow_null=True)
|
||||
|
||||
|
||||
class HanslaserSendSerializer(serializers.Serializer):
|
||||
tdata_list = serializers.ListField(child=serializers.JSONField(), label='模板数据列表', allow_empty=False)
|
||||
coder_ip = serializers.IPAddressField(label='打标机IP')
|
||||
coder_port = serializers.IntegerField(label='打标机端口')
|
||||
coder_jobname = serializers.CharField(label='打标文件名', required=False, allow_null=True)
|
||||
|
||||
|
||||
class LabelMatSerializer(serializers.ModelSerializer):
|
||||
material_name = serializers.StringRelatedField(source='material', read_only=True)
|
||||
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from apps.cm.models import LableMat, LabelTemplate
|
||||
from rest_framework.decorators import action
|
||||
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer
|
||||
from apps.cm.coder import CoderClient
|
||||
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer, HanslaserSendSerializer
|
||||
from apps.cm.coder import CoderClient, HanslaserClient
|
||||
from apps.inm.models import MaterialBatch, MIOItem
|
||||
from apps.wpm.models import WMaterial
|
||||
from rest_framework.exceptions import ParseError, NotFound
|
||||
|
|
@ -152,10 +152,50 @@ class LabelTemplateViewSet(CustomModelViewSet):
|
|||
|
||||
client = CoderClient(ip=ip, port=port)
|
||||
coder_jobname = vdata.get("coder_jobname")
|
||||
client.clear_queue()
|
||||
client.clear_queue()
|
||||
if coder_jobname:
|
||||
client.select_job(coder_jobname)
|
||||
client.clear_queue()
|
||||
for fields in batched:
|
||||
client.push_queue(fields)
|
||||
return Response({"queued": len(batched), "fields": batched, "ip": ip, "port": port, "jobname": coder_jobname})
|
||||
|
||||
@action(methods=["post"], detail=True, serializer_class=HanslaserSendSerializer, perms_map={"post": "*"})
|
||||
def send_to_hanslaser(self, request, pk=None, *args, **kwargs):
|
||||
"""
|
||||
下发指令到大族激光(Hanslaser)老款打标机 (单件打标, 协议无队列能力)
|
||||
|
||||
body 传 tdata_list, 可选 coder_jobname 先选打标文件。
|
||||
对 tdata_list 逐件执行 $Data_<逗号值> -> $MarkStart_, 每步等下位机回包;
|
||||
模板 commands 取每个元素作为位置值(含 '=' 时取等号右侧), 按顺序逗号拼接。
|
||||
传多条会同步逐件打完, 单件场景传 1 条即可。
|
||||
"""
|
||||
sr = HanslaserSendSerializer(data=request.data)
|
||||
sr.is_valid(raise_exception=True)
|
||||
vdata = sr.validated_data
|
||||
lt: LabelTemplate = self.get_object()
|
||||
|
||||
ip = vdata.get("coder_ip")
|
||||
port = vdata.get("coder_port")
|
||||
coder_jobname = vdata.get("coder_jobname")
|
||||
|
||||
batched = []
|
||||
for tdata in vdata["tdata_list"]:
|
||||
commands = LabelTemplate.gen_commands(lt.id, None, tdata)
|
||||
if not commands:
|
||||
raise ParseError("模板未生成任何指令")
|
||||
values = []
|
||||
for item in commands:
|
||||
text = str(item)
|
||||
values.append(text.split("=", 1)[1] if "=" in text else text)
|
||||
batched.append(values)
|
||||
|
||||
client = HanslaserClient(ip=ip, port=port)
|
||||
if coder_jobname:
|
||||
client.initialize(coder_jobname)
|
||||
marked = 0
|
||||
for values in batched:
|
||||
client.send_data(values)
|
||||
client.mark_start()
|
||||
marked += 1
|
||||
return Response({"marked": marked, "values": batched, "ip": ip, "port": port, "jobname": coder_jobname})
|
||||
|
|
|
|||
Loading…
Reference in New Issue