From f8b6f084d449054a49f7924cd2f98431f3445c66 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 18 May 2026 13:48:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=86=99=E5=96=B7=E7=A0=81?= =?UTF-8?q?=E9=80=9A=E8=AE=AF=E4=B8=BA=E4=BC=9F=E8=BF=AA=E6=8D=B71880=20AS?= =?UTF-8?q?CII=E5=8D=8F=E8=AE=AE,=20=E9=85=8D=E7=BD=AE=E6=94=B9=E7=94=B1?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BC=A0=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原 CoderClient 用的是 Domino 1000 系列 STX/ETX 二进制帧, 不适用于 1880 - 改为 ASCII '|' 分隔 + 结尾, 实现 SLA/JDA/CQI/JDI/SST/GST 等指令 - send_to_coder 把多条 commands 合并成单帧 JDA 一次下发 - 配合模型字段移除, IP/端口/用户区由请求 body 提供, 不再走模板回退 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cm/coder.py | 160 +++++++++++++++++++++++++++++++---------- apps/cm/serializers.py | 6 +- apps/cm/views.py | 27 ++++--- 3 files changed, 140 insertions(+), 53 deletions(-) diff --git a/apps/cm/coder.py b/apps/cm/coder.py index cbb86f95..374cbc26 100644 --- a/apps/cm/coder.py +++ b/apps/cm/coder.py @@ -1,14 +1,14 @@ """ -1000 系列喷码机网口通讯封装 -协议: STX(0x02) + 指令 + 数据 + ETX(0x03) -反馈: $XX 成功 / !XX 失败 (XX 为两位校验) +伟迪捷 1880 喷码机 ASCII 协议封装 + +帧格式: 纯 ASCII, 字段以 '|' 分隔, 整帧以 (0x0D) 结尾 +- 写指令(SLA/JDA/CQI/JDI/SST/SNO) 通常无回包 +- 查询指令(GST/GFT/GJD) 回包同样以 结尾 """ import socket from rest_framework.exceptions import ParseError -STX = b"\x02" -ETX = b"\x03" -LF = b"\x0a" +CR = b"\x0d" class CoderError(ParseError): @@ -18,53 +18,135 @@ class CoderError(ParseError): class CoderClient: def __init__(self, ip: str, port: int = 3100, timeout: float = 2.0): if not ip: - raise CoderError("打码器IP未配置") + raise CoderError("喷码IP未配置") self.ip = ip self.port = port self.timeout = timeout - def _send(self, frame: bytes) -> bytes: + def _send(self, frame: bytes, expect_reply: bool = False) -> bytes: try: with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s: s.sendall(frame) - return s.recv(64) + if not expect_reply: + return b"" + buf = b"" + while CR not in buf: + chunk = s.recv(256) + if not chunk: + break + buf += chunk + return buf except (socket.timeout, OSError) as e: - raise CoderError(f"打码器通讯失败 {self.ip}:{self.port} - {e}") + raise CoderError(f"喷码通讯失败 {self.ip}:{self.port} - {e}") @staticmethod - def _check_ack(resp: bytes): - if not resp: - raise CoderError("打码器无响应") - head = resp[:1] - if head == b"$": - return True - if head == b"!": - raise CoderError(f"打码器返回失败: {resp!r}") - raise CoderError(f"打码器响应不识别: {resp!r}") - - @staticmethod - def _encode_payload(value: str, label: str) -> bytes: - if any(c in value for c in ("\x02", "\x03", "\x0a")): - raise CoderError(f"{label}含控制字符(STX/ETX/LF), 会破坏帧结构") + def _encode(text: str, label: str) -> bytes: + if "\r" in text or "\n" in text: + raise CoderError(f"{label}含 CR/LF, 会破坏帧结构") + if "|" in text: + raise CoderError(f"{label}含 '|', 与 1880 分隔符冲突") try: - return value.encode("latin-1") + return text.encode("latin-1") except UnicodeEncodeError as e: raise CoderError(f"{label}含喷码机不支持的字符: {e}") - def update_field(self, field_name: str, content: str) -> bool: - """更新用户区: 02 55 0A 03""" - frame = STX + b"\x55" + self._encode_payload(field_name, "用户区名") + LF + self._encode_payload(content, "喷印内容") + ETX - return self._check_ack(self._send(frame)) + def send_command(self, command: str, expect_reply: bool = False) -> bytes: + """直接下发一条已拼装好的 1880 指令(不含末尾 CR), 由本方法统一补 CR。""" + if "\r" in command or "\n" in command: + raise CoderError("指令含 CR/LF, 由 send_command 统一补 CR, 不要手动拼") + return self._send(command.encode("latin-1") + CR, expect_reply=expect_reply) - def select_message(self, message_name: str) -> bool: - """选择信息: 02 4D 03""" - frame = STX + b"\x4d" + self._encode_payload(message_name, "信息名") + ETX - return self._check_ack(self._send(frame)) + def select_job(self, job_name: str): + """SLA|| 选择信息模板""" + self._send(b"SLA|" + self._encode(job_name, "模板名") + b"|" + CR) - def get_status(self) -> bytes: - """获取喷码机状态: 02 45 03""" - return self._send(STX + b"\x45" + ETX) + def update_fields(self, fields: dict): + """JDA|=|...| 一帧更新多个用户区""" + if not fields: + return + parts = [b"JDA"] + for k, v in fields.items(): + parts.append(self._encode(str(k), "用户区名") + b"=" + self._encode(str(v), "喷印内容")) + self._send(b"|".join(parts) + b"|" + CR) - def send_raw(self, frame_str: str) -> bytes: - """发送任意已拼好的帧字符串(不含 STX/ETX), 自动补头尾""" - return self._send(STX + frame_str.encode("ascii") + ETX) + def clear_queue(self): + """CQI 清空喷码机内部缓存队列""" + self._send(b"CQI" + CR) + + def push_queue(self, fields: dict): + """JDI|1|=|...| 入队列数据""" + if not fields: + return + parts = [b"JDI", b"1"] + for k, v in fields.items(): + parts.append(self._encode(str(k), "用户区名") + b"=" + self._encode(str(v), "喷印内容")) + self._send(b"|".join(parts) + b"|" + CR) + + def set_state(self, state: int): + """SST|| 0关机 1开机 3运行 4离线""" + self._send(b"SST|" + str(int(state)).encode("ascii") + b"|" + CR) + + def register_print_callback(self): + """SNO|PRC|1| 注册打印完成反馈, 之后喷码机每打印一次会回 PRC""" + self._send(b"SNO|PRC|1|" + CR) + + def get_status(self) -> dict: + """GST| -> STS||||||""" + resp = self._send(b"GST|" + CR, expect_reply=True) + parts = resp.rstrip(b"\r\n").decode("latin-1").split("|") + if not parts or parts[0] != "STS" or len(parts) < 6: + raise CoderError(f"GST 响应不识别: {resp!r}") + try: + return { + "overall": int(parts[1]), + "error": int(parts[2]), + "current_job": parts[3], + "batch_count": int(parts[4]), + "total_count": int(parts[5]), + } + except ValueError as e: + raise CoderError(f"GST 响应解析失败: {resp!r} - {e}") + + def get_faults(self) -> list: + """GFT| -> FLT||[|||]*<CR>""" + resp = self._send(b"GFT|" + CR, expect_reply=True) + parts = resp.rstrip(b"\r\n").decode("latin-1").split("|") + if not parts or parts[0] != "FLT" or len(parts) < 2: + raise CoderError(f"GFT 响应不识别: {resp!r}") + try: + count = int(parts[1]) + except ValueError as e: + raise CoderError(f"GFT 响应解析失败: {resp!r} - {e}") + faults = [] + for i in range(count): + base = 2 + i * 3 + if base + 2 >= len(parts): + break + faults.append({ + "code": parts[base], + "clearable": parts[base + 1] == "1", + "title": parts[base + 2], + }) + return faults + + def get_current_job(self) -> dict: + """GJD|<CR> -> JDL|<count>|<field>=<value>|...|<CR>""" + resp = self._send(b"GJD|" + CR, expect_reply=True) + parts = resp.rstrip(b"\r\n").decode("latin-1").split("|") + if not parts or parts[0] != "JDL" or len(parts) < 2: + raise CoderError(f"GJD 响应不识别: {resp!r}") + try: + count = int(parts[1]) + except ValueError as e: + raise CoderError(f"GJD 响应解析失败: {resp!r} - {e}") + fields = {} + for i in range(count): + idx = 2 + i + if idx >= len(parts): + break + pair = parts[idx] + if "=" not in pair: + continue + k, v = pair.split("=", 1) + fields[k] = v + return fields diff --git a/apps/cm/serializers.py b/apps/cm/serializers.py index d80db2d4..f383e3ea 100644 --- a/apps/cm/serializers.py +++ b/apps/cm/serializers.py @@ -19,9 +19,9 @@ class Tid2Serializer(serializers.Serializer): class CoderSendSerializer(serializers.Serializer): tdata = serializers.JSONField(label='模板数据', required=False, default=dict) - coder_ip = serializers.IPAddressField(label='打码器IP') - coder_port = serializers.IntegerField(label='打码器端口', required=False, allow_null=True) - coder_field = serializers.CharField(label='用户区名', required=False, allow_null=True) + coder_ip = serializers.IPAddressField(label='喷码IP') + coder_port = serializers.IntegerField(label='喷码端口', required=False, allow_null=True) + coder_field = serializers.CharField(label='默认用户区名', required=False, allow_null=True) class LabelMatSerializer(serializers.ModelSerializer): diff --git a/apps/cm/views.py b/apps/cm/views.py index d4dc0816..a7bff705 100644 --- a/apps/cm/views.py +++ b/apps/cm/views.py @@ -118,10 +118,11 @@ class LabelTemplateViewSet(CustomModelViewSet): @action(methods=["post"], detail=True, serializer_class=CoderSendSerializer, perms_map={"post": "*"}) def send_to_coder(self, request, pk=None, *args, **kwargs): """ - 下发指令到打码器 + 下发指令到伟迪捷 1880 喷码机 - 按模板 commands 用 tdata 格式化后, 通过模板上配置的 IP/端口下发到 1000 系列喷码机用户区。 - body 中的 coder_ip/coder_port/coder_field 可临时覆盖模板配置。 + 按模板 commands 用 tdata 格式化后, 解析为 <字段>=<内容> 对, 合并成单帧 JDA 下发。 + commands 中不含 '=' 的元素, 会绑定到默认 coder_field 上。 + IP/端口/字段全部由请求 body 提供。 """ sr = CoderSendSerializer(data=request.data) sr.is_valid(raise_exception=True) @@ -129,18 +130,22 @@ class LabelTemplateViewSet(CustomModelViewSet): lt: LabelTemplate = self.get_object() ip = vdata.get("coder_ip") - port = vdata.get("coder_port") or 3000 + port = vdata.get("coder_port") or 3100 field = vdata.get("coder_field") or "1" - if not ip: - raise ParseError("模板未配置打码器IP, 也未在请求中提供 coder_ip") commands = LabelTemplate.gen_commands(lt.id, None, vdata.get("tdata") or {}) if not commands: raise ParseError("模板未生成任何指令") + fields = {} + for item in commands: + text = str(item) + if "=" in text: + k, v = text.split("=", 1) + fields[k.strip()] = v + else: + fields[field] = text + client = CoderClient(ip=ip, port=port) - results = [] - for content in commands: - client.update_field(field, content) - results.append(content) - return Response({"sent": results, "ip": ip, "port": port, "field": field}) + client.update_fields(fields) + return Response({"sent": fields, "ip": ip, "port": port})