feat: 重写喷码通讯为伟迪捷1880 ASCII协议, 配置改由前端传入
- 原 CoderClient 用的是 Domino 1000 系列 STX/ETX 二进制帧, 不适用于 1880 - 改为 ASCII '|' 分隔 + <CR> 结尾, 实现 SLA/JDA/CQI/JDI/SST/GST 等指令 - send_to_coder 把多条 commands 合并成单帧 JDA 一次下发 - 配合模型字段移除, IP/端口/用户区由请求 body 提供, 不再走模板回退 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43b2a4c7f7
commit
f8b6f084d4
160
apps/cm/coder.py
160
apps/cm/coder.py
|
|
@ -1,14 +1,14 @@
|
|||
"""
|
||||
1000 系列喷码机网口通讯封装
|
||||
协议: STX(0x02) + 指令 + 数据 + ETX(0x03)
|
||||
反馈: $XX 成功 / !XX 失败 (XX 为两位校验)
|
||||
伟迪捷 1880 喷码机 ASCII 协议封装
|
||||
|
||||
帧格式: 纯 ASCII, 字段以 '|' 分隔, 整帧以 <CR>(0x0D) 结尾
|
||||
- 写指令(SLA/JDA/CQI/JDI/SST/SNO) 通常无回包
|
||||
- 查询指令(GST/GFT/GJD) 回包同样以 <CR> 结尾
|
||||
"""
|
||||
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 <field> 0A <content> 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 <name> 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|<jobname>|<CR> 选择信息模板"""
|
||||
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|<field>=<value>|...|<CR> 一帧更新多个用户区"""
|
||||
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<CR> 清空喷码机内部缓存队列"""
|
||||
self._send(b"CQI" + CR)
|
||||
|
||||
def push_queue(self, fields: dict):
|
||||
"""JDI|1|<field>=<value>|...|<CR> 入队列数据"""
|
||||
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|<State>|<CR> 0关机 1开机 3运行 4离线"""
|
||||
self._send(b"SST|" + str(int(state)).encode("ascii") + b"|" + CR)
|
||||
|
||||
def register_print_callback(self):
|
||||
"""SNO|PRC|1|<CR> 注册打印完成反馈, 之后喷码机每打印一次会回 PRC<CR>"""
|
||||
self._send(b"SNO|PRC|1|" + CR)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""GST|<CR> -> STS|<overall>|<error>|<currentjob>|<batchcount>|<totalcount>|<CR>"""
|
||||
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|<CR> -> FLT|<count>|[<code>|<clearable>|<title>|]*<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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Reference in New Issue