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)
|
k, v = pair.split("=", 1)
|
||||||
fields[k] = v
|
fields[k] = v
|
||||||
return fields
|
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)
|
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):
|
class LabelMatSerializer(serializers.ModelSerializer):
|
||||||
material_name = serializers.StringRelatedField(source='material', read_only=True)
|
material_name = serializers.StringRelatedField(source='material', read_only=True)
|
||||||
material_origin_name = serializers.StringRelatedField(source='material_origin', 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 apps.cm.models import LableMat, LabelTemplate
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer
|
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer, HanslaserSendSerializer
|
||||||
from apps.cm.coder import CoderClient
|
from apps.cm.coder import CoderClient, HanslaserClient
|
||||||
from apps.inm.models import MaterialBatch, MIOItem
|
from apps.inm.models import MaterialBatch, MIOItem
|
||||||
from apps.wpm.models import WMaterial
|
from apps.wpm.models import WMaterial
|
||||||
from rest_framework.exceptions import ParseError, NotFound
|
from rest_framework.exceptions import ParseError, NotFound
|
||||||
|
|
@ -159,3 +159,43 @@ class LabelTemplateViewSet(CustomModelViewSet):
|
||||||
for fields in batched:
|
for fields in batched:
|
||||||
client.push_queue(fields)
|
client.push_queue(fields)
|
||||||
return Response({"queued": len(batched), "fields": batched, "ip": ip, "port": port, "jobname": coder_jobname})
|
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