From 652555ef01ef7ee8c50177bba79cb402d24d498f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 8 Jun 2026 10:42:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(cm):=20=E6=96=B0=E5=A2=9E=E5=A4=A7?= =?UTF-8?q?=E6=97=8F=E6=BF=80=E5=85=89=E6=89=93=E6=A0=87=E6=9C=BA(Hanslase?= =?UTF-8?q?r)=E5=8D=95=E4=BB=B6=E6=89=93=E6=A0=87=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 HanslaserClient(STX/ETX 帧, gbk 编码, 每条命令校验回包)与 send_to_hanslaser action, 走 $Initialize_/$Data_/$MarkStart_ 单件流程。 该协议无队列能力, 与 1880 喷码机协议独立, 各用各的 serializer 与接口。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cm/coder.py | 93 ++++++++++++++++++++++++++++++++++++++++++ apps/cm/serializers.py | 7 ++++ apps/cm/views.py | 46 +++++++++++++++++++-- 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/apps/cm/coder.py b/apps/cm/coder.py index 360b63f7..c7125783 100644 --- a/apps/cm/coder.py +++ b/apps/cm/coder.py @@ -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_ -> $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_ -> $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 diff --git a/apps/cm/serializers.py b/apps/cm/serializers.py index 9a6d491d..61e58a80 100644 --- a/apps/cm/serializers.py +++ b/apps/cm/serializers.py @@ -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) diff --git a/apps/cm/views.py b/apps/cm/views.py index 39ba0a7e..60b9e8b5 100644 --- a/apps/cm/views.py +++ b/apps/cm/views.py @@ -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})