⭐ Vision Engineer · Chặng 9Bài 5/9Bài học~75 phút

TCP socket Python: gửi kết quả và tọa độ cho robot

Viết server, client TCP; đóng khung tin bằng ký tự kết thúc, xử lý tin dính/vỡ, timeout, kết nối lại và gửi lại không gắp hai lần.

TCP socket Python: gửi kết quả và tọa độ cho robot

Học xong bài này, bạn sẽ

  • Viết server, client TCP bằng Python
  • Thiết kế giao thức tin nhắn có kết thúc rõ ràng
  • Xử lý mất kết nối, timeout

Nên học trước: OPC UA cho kỹ sư Vision

Nội dung bài viết
  1. 1. TCP là dòng byte, không phải gói tin
  2. 2. Đóng khung tin nhắn (framing)
  3. 3. Bộ đệm nhận: gom byte thành tin
  4. 4. Thiết kế tin nhắn cho robot
  5. 5. Cách thứ hai: tiền tố độ dài
  6. 6. Ai là server, ai là client?
  7. 7. Timeout, mất kết nối, kết nối lại và gửi lại
  8. 8. Lỗi thường gặp
  9. 9. Bài tập
  10. Tóm tắt

Modbus và OPC UA hợp với dữ liệu kiểu "ô nhớ": vài bit, vài thanh ghi. Nhưng khi vision cần gửi cho robot tọa độ gắp (x, y, góc) cho từng vật, hoặc nhận lệnh "chụp đi" từ robot, cách phổ biến nhất trong thực tế lại là TCP socket với tin nhắn văn bản tự định nghĩa: robot của gần như mọi hãng đều có lệnh mở socket, gửi và đọc chuỗi. Đơn giản đến mức rất nhiều trạm chạy "gần đúng" trong nhiều tháng rồi mới hỏng vì một lỗi kinh điển: coi mỗi lần recv là một tin. Bài này nối tiếp "Modbus TCP bằng Python với pymodbus" và "OPC UA cho kỹ sư Vision", dạy bạn viết client/server TCP đúng cách, thiết kế giao thức có kết thúc rõ ràng và chịu được mất kết nối. Bài sau, "Vision dẫn hướng robot gắp vật", sẽ dùng chính kênh này để gửi tọa độ đã đổi sang hệ robot.

Mục tiêu cuối bàiViết được TCP server và client bằng thư viện chuẩn socket; thiết kế tin nhắn có ký tự kết thúc, số thập phân cố định, số thứ tự và xác nhận ACK; xử lý tin dính/tin vỡ bằng bộ đệm; xử lý timeout, mất kết nối, kết nối lại và gửi lại mà robot không gắp hai lần.

1. TCP là dòng byte, không phải gói tin

TCP đảm bảo các byte đến đủ và đúng thứ tự, nhưng không giữ ranh giới giữa các lần gửi. Hai lần sendall có thể đến bên kia trong một lần recv (tin dính), một lần sendall có thể bị chia ra nhiều lần recv (tin vỡ). Trên mạng thật điều này phụ thuộc kích thước gói, thuật toán Nagle, tải của máy; trên bàn thử nghiệm thì hầu như không xảy ra, nên lỗi chỉ lộ ra ở hiện trường. Ví dụ dưới dùng socket.socketpair() (hai đầu của một kết nối dòng byte, không cần mạng) để tái hiện cả hai trường hợp một cách chắc chắn:

import socket

vision, robot = socket.socketpair()      # hai đầu của một kết nối dòng byte, không cần mạng

# Trường hợp 1: vision gửi hai tin liên tiếp, robot recv một lần
vision.sendall(b"OK,123.45,67.89,30.00\r\n")
vision.sendall(b"NG,0.00,0.00,0.00\r\n")
lan1 = robot.recv(1024)
print("recv 1:", lan1)

# Trường hợp 2: một tin đến thành hai mảnh
vision.sendall(b"OK,101.2")
print("recv 2:", robot.recv(1024))
vision.sendall(b"5,55.10,90.00\r\n")
print("recv 3:", robot.recv(1024))

# Code "ngây thơ": coi mỗi lần recv là một tin
truong = lan1.decode("ascii").strip().split(",")
print("Robot hiểu:", len(truong), "trường →", truong[:5], "...")
vision.close()
robot.close()
recv 1: b'OK,123.45,67.89,30.00\r\nNG,0.00,0.00,0.00\r\n'
recv 2: b'OK,101.2'
recv 3: b'5,55.10,90.00\r\n'
Robot hiểu: 7 trường → ['OK', '123.45', '67.89', '30.00\r\nNG', '0.00'] ...

Robot đọc theo kiểu "mỗi recv là một tin" sẽ thấy 7 trường, trường thứ tư là '30.00\r\nNG': tùy cách robot xử lý lỗi, nó dừng chương trình, hoặc tệ hơn, gắp ở góc sai. Ở trường hợp 2, nó nhận OK,101.2 và có thể hiểu x = 101.2 thay vì 101.25.

2. Đóng khung tin nhắn (framing)

Vì TCP không có ranh giới, chính giao thức của bạn phải nói tin nhắn kết thúc ở đâu. Hai cách chuẩn:

Ký tự kết thúc (delimiter)Tiền tố độ dài (length prefix)
Ví dụOK,123.45,67.89,30.00\r\n00 1F + 31 byte nội dung
Ưu điểmĐọc được bằng mắt, gõ thử bằng công cụ terminal, robot nào cũng có lệnh "đọc đến ký tự"Chứa được dữ liệu bất kỳ (nhị phân, JSON nhiều dòng, ảnh), biết trước cần đọc bao nhiêu
Nhược điểmNội dung không được chứa ký tự kết thúcLệch 1 byte là lệch toàn bộ phần sau; khó gõ thử bằng tay
Dùng khiGiao tiếp với robot, PLC, thiết bị đọc mã (phổ biến nhất)PC ↔ PC, gửi ảnh, gửi bản ghi lớn

Ký tự kết thúc hay dùng là \r\n (CR LF) hoặc chỉ \r, tùy thiết bị. Ghi rõ trong tài liệu giao thức, kèm mã hex (0x0D 0x0A), vì "xuống dòng" là cách nói không đủ chính xác.

3. Bộ đệm nhận: gom byte thành tin

Cách đúng: mọi byte nhận được đổ vào một bộ đệm; mỗi lần đổ, cắt ra tất cả các tin đã có đủ ký tự kết thúc, phần dở dang giữ lại chờ lần sau. Thêm một giới hạn độ dài để rác trên đường truyền (hoặc bên kia quên ký tự kết thúc) không làm bộ đệm phình mãi.

class BoDemDong:
    """Gom dòng byte thành từng tin hoàn chỉnh kết thúc bằng b'\\r\\n'."""

    def __init__(self, ket_thuc=b"\r\n", dai_toi_da=256):
        self.buf = bytearray()
        self.ket_thuc = ket_thuc
        self.dai_toi_da = dai_toi_da

    def nap(self, du_lieu):
        """Nạp một khối byte vừa recv được, trả về danh sách các tin ĐỦ (chưa có ký tự kết thúc)."""
        self.buf += du_lieu
        cac_tin = []
        while True:
            vi_tri = self.buf.find(self.ket_thuc)
            if vi_tri < 0:
                break
            cac_tin.append(bytes(self.buf[:vi_tri]).decode("ascii"))
            del self.buf[:vi_tri + len(self.ket_thuc)]
        if len(self.buf) > self.dai_toi_da:           # rác hoặc thiếu ký tự kết thúc: đừng phình bộ nhớ
            raise ValueError(f"Tin quá dài ({len(self.buf)} byte) mà chưa thấy ký tự kết thúc")
        return cac_tin


dong = b"OK,123.45,67.89,30.00\r\nNG,0.00,0.00,0.00\r\nOK,101.25,55.10,90.00\r\n"

# Cắt dòng byte theo ba cách khác nhau: kết quả phải giống hệt nhau
for kich_thuoc in (len(dong), 7, 1):
    bd = BoDemDong()
    tin = []
    for i in range(0, len(dong), kich_thuoc):
        tin += bd.nap(dong[i:i + kich_thuoc])
    print(f"mảnh {kich_thuoc:>2} byte →", tin)
mảnh 65 byte → ['OK,123.45,67.89,30.00', 'NG,0.00,0.00,0.00', 'OK,101.25,55.10,90.00']
mảnh  7 byte → ['OK,123.45,67.89,30.00', 'NG,0.00,0.00,0.00', 'OK,101.25,55.10,90.00']
mảnh  1 byte → ['OK,123.45,67.89,30.00', 'NG,0.00,0.00,0.00', 'OK,101.25,55.10,90.00']

Dù dòng byte đến nguyên khối, từng 7 byte hay từng byte một, kết quả vẫn là đúng ba tin. Đây là bài kiểm tra bạn nên viết cho mọi bộ phân tích giao thức.

4. Thiết kế tin nhắn cho robot

Dạng tối thiểu là OK,123.45,67.89,30.00\r\n. Trong dự án thật, nên thêm số thứ tự ở đầu và yêu cầu robot xác nhận:

Vision → Robot :  0007,OK,123.46,67.89,30.00\r\n
                  │    │  │      │     └ góc (độ), 2 chữ số thập phân
                  │    │  │      └ y (mm, hệ tọa độ robot)
                  │    │  └ x (mm, hệ tọa độ robot)
                  │    └ OK = gắp, NG = bỏ qua
                  └ số thứ tự 0000–9999, quay vòng
Robot → Vision :  ACK,0007\r\n
  • Số thập phân cố định ({x:.2f}): độ dài tin ổn định, robot dễ cắt chuỗi, và không bao giờ xuất hiện dạng 1e-05 hay 123.45600000000002.
  • Luôn dùng dấu chấm thập phân, ASCII. Python f-string luôn dùng dấu chấm, nhưng nếu phần mềm vision viết bằng C#/.NET chạy trên Windows cài định dạng Việt Nam, ToString() sẽ ra 123,45 và phá vỡ dấu phẩy phân cách trường. Bên .NET phải dùng CultureInfo.InvariantCulture.
  • Đơn vị và hệ tọa độ ghi trong tài liệu: mm hay 0,01 mm, hệ tọa độ user frame nào của robot, góc theo chiều nào, khoảng giá trị hợp lệ.
  • Số thứ tự + ACK: vision biết robot đã nhận đúng tin nào; khi phải gửi lại, robot nhận ra tin trùng và không gắp hai lần (mục 7).
  • Bên nhận kiểm tra, không đoán: sai số trường, sai chữ, số ngoài khoảng → từ chối và báo lỗi.
def tao_tin(seq, ok, x, y, goc):
    """Tin vision → robot: seq,OK|NG,x,y,góc  (mm, mm, độ; luôn 2 chữ số thập phân, dấu chấm)."""
    if not (0 <= seq <= 9999):
        raise ValueError("seq phải trong 0..9999")
    return f"{seq:04d},{'OK' if ok else 'NG'},{x:.2f},{y:.2f},{goc:.2f}\r\n".encode("ascii")


def doc_tin(dong):
    """Phía robot: tách và KIỂM TRA một tin. Sai định dạng thì báo lỗi, không đoán."""
    truong = dong.split(",")
    if len(truong) != 5:
        raise ValueError(f"cần 5 trường, nhận {len(truong)}: {dong!r}")
    seq, kq, x, y, goc = truong
    if kq not in ("OK", "NG"):
        raise ValueError(f"kết quả lạ: {kq!r}")
    return {"seq": int(seq), "ok": kq == "OK", "x": float(x), "y": float(y), "goc": float(goc)}


print(tao_tin(7, True, 123.4567, 67.891, 30))
print(tao_tin(8, False, 0, 0, 0))
print(doc_tin("0007,OK,123.46,67.89,30.00"))
for xau in ["0007,OK,123,46,67,89,30,00", "0007,ok,1.00,2.00,3.00"]:
    try:
        doc_tin(xau)
    except ValueError as e:
        print("Từ chối:", e)
b'0007,OK,123.46,67.89,30.00\r\n'
b'0008,NG,0.00,0.00,0.00\r\n'
{'seq': 7, 'ok': True, 'x': 123.46, 'y': 67.89, 'goc': 30.0}
Từ chối: cần 5 trường, nhận 8: '0007,OK,123,46,67,89,30,00'
Từ chối: kết quả lạ: 'ok'

Tin thứ hai bị từ chối chính là trường hợp dấu phẩy thập phân kiểu Việt Nam. Nhờ kiểm tra số trường, lỗi bị chặn ngay thay vì thành tọa độ x = 123, y = 46.

5. Cách thứ hai: tiền tố độ dài

Khi gửi dữ liệu lớn hoặc nhị phân giữa hai chương trình PC (ví dụ gửi bản ghi JSON nhiều điểm, hay ảnh), dùng 2 hoặc 4 byte đầu để ghi độ dài. Chìa khóa là hàm nhan_du: recv(n) có thể trả về ít hơn n byte, nên phải lặp tới khi đủ.

import json
import socket
import struct


def gui_khung(sock, payload: bytes):
    sock.sendall(struct.pack(">H", len(payload)) + payload)     # 2 byte độ dài (big-endian) + nội dung


def nhan_du(sock, n):
    """recv tới khi đủ đúng n byte (recv có thể trả về ít hơn n)."""
    buf = bytearray()
    while len(buf) < n:
        manh = sock.recv(n - len(buf))
        if not manh:
            raise ConnectionError("đầu kia đóng kết nối giữa chừng")
        buf += manh
    return bytes(buf)


def nhan_khung(sock):
    (do_dai,) = struct.unpack(">H", nhan_du(sock, 2))
    return nhan_du(sock, do_dai)


a, b = socket.socketpair()
tin = json.dumps({"seq": 9, "ok": True, "diem": [[10.5, 20.25], [30.0, 40.75]]}).encode()
gui_khung(a, tin)
gui_khung(a, b"")                                  # khung rỗng vẫn hợp lệ
print(len(tin), "byte →", json.loads(nhan_khung(b)))
print("khung 2:", nhan_khung(b))
a.close()
b.close()
62 byte → {'seq': 9, 'ok': True, 'diem': [[10.5, 20.25], [30.0, 40.75]]}
khung 2: b''

6. Ai là server, ai là client?

Server mở cổng và chờ (listen/accept); client chủ động kết nối (connect). Về lâu dài, quy tắc quan trọng nhất là: client chịu trách nhiệm kết nối lại, server chỉ cần luôn sẵn sàng nhận kết nối mới.

Cách bố tríKhi nào hợpLưu ý
Robot là server, vision là clientRobot đứng chờ lệnh; vision chủ động gửi khi có kết quảVision phải tự kết nối lại khi robot khởi động lại chương trình
Vision là server, robot là clientRobot điều khiển nhịp: gửi "TRIG", chờ tọa độ (rất phổ biến trong pick & place)Vision phải chấp nhận kết nối mới khi kết nối cũ chết; chỉ giữ một kết nối hoạt động
Hai kết nối, mỗi bên một serverÍt khi cầnPhức tạp gấp đôi, tránh nếu được

Hỏi kỹ sư robot xem bộ điều khiển hỗ trợ vai nào, ký tự kết thúc nào, độ dài chuỗi tối đa bao nhiêu, và robot làm gì khi socket đứt giữa chương trình (dừng có báo lỗi, hay tự mở lại).

7. Timeout, mất kết nối, kết nối lại và gửi lại

Các tình huống phía client phải xử lý:

Tình huốngPython báo thế nàoXử lý
Robot chưa bật / sai IPConnectionRefusedError hoặc TimeoutError khi connectChờ rồi thử lại, báo lỗi cho PLC nếu quá số lần
Robot đóng kết nối "lịch sự"recv trả về b"" (không phải exception!)Coi như mất kết nối: đóng, kết nối lại
Robot reset kết nốiConnectionResetError, BrokenPipeErrorNhư trên
Robot im lặng (bận, treo, rút dây)Không có gì cả, nếu không đặt timeout thì recv chờ mãiLuôn settimeout; quá hạn chờ ACK → đóng, kết nối lại, gửi lại

Một chi tiết hay bị bỏ qua: khi rút dây mạng, không có gói nào báo cho bên kia biết. Cơ chế keepalive của TCP mặc định tắt, và kể cả khi bật (SO_KEEPALIVE), Linux mặc định chỉ bắt đầu thăm dò sau 2 giờ im lặng. Vì vậy phát hiện mất kết nối phải dựa vào timeout của giao thức (chờ ACK, hoặc tin heartbeat định kỳ), không trông chờ hệ điều hành.

Gửi lại sinh ra vấn đề mới: nếu robot đã gắp nhưng ACK đến trễ, vision gửi lại và robot gắp lần hai vào chỗ trống. Số thứ tự giải quyết việc này: robot nhớ các seq đã thực hiện, gặp lại thì chỉ ACK lại. Demo dưới chạy một robot giả làm server trên 127.0.0.1:6020 với hai sự cố: rớt kết nối khi nhận tin #3 (chưa kịp gắp), và ACK trễ 1,3 s ở tin #5 trong khi vision chỉ chờ 1 s. Code dùng lại BoDemDong, tao_tin, doc_tin ở trên.

import socket
import threading
import time


class RobotGia:
    """Robot giả làm TCP server. Kịch bản lỗi: rớt kết nối khi nhận tin #3, ACK chậm ở tin #5."""

    def __init__(self, port, rot_o=3, cham_o=5, tre_ack=1.3):
        self.port, self.rot_o, self.cham_o, self.tre_ack = port, rot_o, cham_o, tre_ack
        self.dung = threading.Event()
        self.da_rot = self.da_cham = False
        self.da_thuc_hien = set()
        self.nhat_ky = []
        self.srv = socket.create_server(("127.0.0.1", port))   # mở cổng ngay, trước khi client kết nối
        self.srv.settimeout(0.2)
        self.thread = threading.Thread(target=self._chay, daemon=True)
        self.thread.start()

    def _chay(self):
        with self.srv:
            while not self.dung.is_set():
                try:
                    conn, _ = self.srv.accept()
                except TimeoutError:
                    continue
                self.nhat_ky.append("robot: có client kết nối")
                with conn:
                    self._phuc_vu(conn)

    def _phuc_vu(self, conn):
        conn.settimeout(0.2)
        bd = BoDemDong()
        while not self.dung.is_set():
            try:
                du_lieu = conn.recv(1024)
            except TimeoutError:
                continue
            except OSError:
                return
            if not du_lieu:                                    # client đã đóng
                return
            for dong in bd.nap(du_lieu):
                tin = doc_tin(dong)
                seq = tin["seq"]
                if seq == self.rot_o and not self.da_rot:
                    self.da_rot = True
                    self.nhat_ky.append(f"robot: nhận #{seq} rồi rớt kết nối (chưa thực hiện)")
                    return
                if seq in self.da_thuc_hien:
                    self.nhat_ky.append(f"robot: #{seq} bị gửi lại, KHÔNG gắp lần hai, chỉ ACK lại")
                else:
                    self.da_thuc_hien.add(seq)
                    hanh_dong = f"gắp tại ({tin['x']}, {tin['y']}, {tin['goc']}°)" if tin["ok"] else "bỏ qua (NG)"
                    self.nhat_ky.append(f"robot: #{seq} {hanh_dong}")
                if seq == self.cham_o and not self.da_cham:
                    self.da_cham = True
                    time.sleep(self.tre_ack)                       # robot bận, ACK trễ
                try:
                    conn.sendall(f"ACK,{seq:04d}\r\n".encode("ascii"))
                except OSError:
                    return

    def tat(self):
        self.dung.set()
        self.thread.join(timeout=2)


class GuiRobot:
    """Phía vision là TCP client: gửi tin, chờ ACK đúng seq, có timeout và kết nối lại."""

    def __init__(self, host, port, timeout=1.0, so_lan_thu=3):
        self.host, self.port, self.timeout, self.so_lan_thu = host, port, timeout, so_lan_thu
        self.sock = None

    def _ket_noi(self):
        self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
        self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)   # tin nhỏ: gửi ngay
        self.bd = BoDemDong()                                               # bỏ mọi dữ liệu dở dang cũ

    def _dong(self):
        if self.sock:
            self.sock.close()
        self.sock = None

    def _cho_ack(self, seq):
        han = time.monotonic() + self.timeout
        while True:
            con_lai = han - time.monotonic()
            if con_lai <= 0:
                raise TimeoutError("quá hạn chờ ACK")
            self.sock.settimeout(con_lai)
            du_lieu = self.sock.recv(1024)
            if not du_lieu:
                raise ConnectionError("robot đóng kết nối")
            for dong in self.bd.nap(du_lieu):
                if dong == f"ACK,{seq:04d}":
                    return
                # ACK của tin cũ hoặc tin lạ: bỏ qua, tiếp tục chờ

    def gui(self, seq, ok, x, y, goc):
        tin = tao_tin(seq, ok, x, y, goc)
        for lan in range(1, self.so_lan_thu + 1):
            try:
                if self.sock is None:
                    self._ket_noi()
                self.sock.sendall(tin)
                self._cho_ack(seq)
                return True
            except OSError as e:                # TimeoutError, ConnectionError... đều là OSError
                print(f"  #{seq} lần {lan}: {type(e).__name__} ({e}) → đóng socket, thử lại")
                self._dong()
                time.sleep(0.1)
        return False

    def dong(self):
        self._dong()


robot = RobotGia(port=6020)
vision = GuiRobot("127.0.0.1", 6020, timeout=1.0)
cac_vat = [(True, 120.5, 60.25, 15.0), (False, 0, 0, 0), (True, 98.123, 70.0, -30.5),
           (True, 101.0, 65.5, 0.0), (True, 130.75, 58.1, 45.0)]
for seq, (ok, x, y, goc) in enumerate(cac_vat, start=1):
    thanh_cong = vision.gui(seq, ok, x, y, goc)
    print(f"#{seq}: {'đã ACK' if thanh_cong else 'THẤT BẠI → báo lỗi cho PLC'}")
vision.dong()
time.sleep(0.3)                                  # cho robot ghi nốt nhật ký
robot.tat()
print("\n".join(robot.nhat_ky))
#1: đã ACK
#2: đã ACK
  #3 lần 1: ConnectionError (robot đóng kết nối) → đóng socket, thử lại
#3: đã ACK
#4: đã ACK
  #5 lần 1: TimeoutError (timed out) → đóng socket, thử lại
#5: đã ACK
robot: có client kết nối
robot: #1 gắp tại (120.5, 60.25, 15.0°)
robot: #2 bỏ qua (NG)
robot: nhận #3 rồi rớt kết nối (chưa thực hiện)
robot: có client kết nối
robot: #3 gắp tại (98.12, 70.0, -30.5°)
robot: #4 gắp tại (101.0, 65.5, 0.0°)
robot: #5 gắp tại (130.75, 58.1, 45.0°)
robot: có client kết nối
robot: #5 bị gửi lại, KHÔNG gắp lần hai, chỉ ACK lại

Đọc kết quả: tin #3 bị rớt trước khi robot thực hiện, vision kết nối lại và gửi lại, robot gắp đúng một lần. Tin #5 thì ngược lại: robot đã gắp nhưng ACK trễ, vision hết giờ, kết nối lại và gửi lại; nhờ số thứ tự, robot nhận ra tin trùng và không gắp lần hai. Khi thử hết so_lan_thu mà vẫn thất bại, hàm trả về False: lúc đó vision phải báo lỗi cho PLC (ERROR) để dừng hoặc giữ sản phẩm, không được im lặng bỏ qua.

Sau timeout, đừng đọc tiếp trên socket cũACK trễ của tin cũ có thể đến ngay sau khi bạn gửi tin mới, và bị hiểu là ACK của tin mới. Có hai cách an toàn: so khớp seq trong ACK (code trên bỏ qua ACK không đúng seq), và đóng socket sau timeout để bắt đầu lại với bộ đệm sạch. Code trên làm cả hai.

8. Lỗi thường gặp

Hiện tượngNguyên nhânCách xử lý
Thỉnh thoảng robot báo "định dạng sai" hoặc gắp lệchCoi mỗi recv là một tinBộ đệm + ký tự kết thúc
Chương trình treo, không báo lỗirecv không có timeoutsettimeout mọi socket, kể cả accept
CPU 100%, log đầy dòng trốngKhông xử lý recv trả về b"", vòng lặp chạy liên tụcb"" = đầu kia đã đóng: thoát vòng, kết nối lại
Chỉ gửi được một phần tinDùng send thay vì sendallLuôn sendall
Tọa độ sai sau khi đổi máy tínhĐịnh dạng số theo vùng (dấu phẩy thập phân)Định dạng cố định, dấu chấm, bên nhận kiểm tra số trường
Robot gắp hai lần một vậtGửi lại sau timeout mà không có số thứ tựseq + robot bỏ qua tin trùng
OSError: Address already in use khi chạy lại serverCổng cũ còn trong trạng thái chờ đóngsocket.create_server đã bật SO_REUSEADDR trên Linux; đóng server đúng cách khi thoát

9. Bài tập

Bài 1. Bộ điều khiển robot chỉ đọc được chuỗi tối đa 32 ký tự mỗi tin (kể cả ký tự kết thúc). Biết x, y nằm trong khoảng −9999,99…9999,99 mm và góc trong −180…180 độ, định dạng ở mục 4 có vừa không? Nếu không, bạn sửa thế nào?

Xem đáp án
tin_dai_nhat = tao_tin(9999, True, -9999.99, -9999.99, -180)
print(tin_dai_nhat, len(tin_dai_nhat), "byte")
b'9999,OK,-9999.99,-9999.99,-180.00\r\n' 35 byte

35 byte, không vừa. Các cách sửa: thu hẹp khoảng giá trị theo vùng làm việc thật của robot (ví dụ x, y trong −999,99…999,99 thì tiết kiệm 2 byte), bỏ số 0 đệm của seq, rút gọn OK/NG thành 1/0 (tiết kiệm 1 byte), hoặc gửi đơn vị 0,1 mm với 1 chữ số thập phân. Dù chọn gì, ghi rõ trong tài liệu giao thức và để tao_tin báo lỗi khi vượt độ dài thay vì cắt bớt âm thầm.

Bài 2. Đổi vai: robot là client, gửi TRIG,<seq>\r\n; vision là server, mỗi lần nhận trigger thì trả về một tin kết quả theo định dạng mục 4. Viết cả hai phía, chạy trên 127.0.0.1.

Xem đáp án
import socket
import threading


def ket_qua_gia(seq):
    return (seq % 3 != 0), 100 + seq * 1.5, 50 - seq * 0.25, 10.0 * seq    # vật thứ 3, 6… là NG


def vision_server(srv, so_lan):
    """Vision là SERVER: chờ robot kết nối, mỗi 'TRIG,<seq>' trả về một tin kết quả."""
    conn, _ = srv.accept()
    with conn:
        conn.settimeout(2.0)
        bd = BoDemDong()
        da_tra = 0
        while da_tra < so_lan:
            du_lieu = conn.recv(1024)
            if not du_lieu:
                break
            for dong in bd.nap(du_lieu):
                lenh, seq = dong.split(",")
                if lenh == "TRIG":
                    conn.sendall(tao_tin(int(seq), *ket_qua_gia(int(seq))))
                    da_tra += 1


srv = socket.create_server(("127.0.0.1", 6021))
srv.settimeout(3.0)
t = threading.Thread(target=vision_server, args=(srv, 3), daemon=True)
t.start()

# Robot là CLIENT
with socket.create_connection(("127.0.0.1", 6021), timeout=2.0) as robot:
    bd = BoDemDong()
    for seq in (1, 2, 3):
        robot.sendall(f"TRIG,{seq:04d}\r\n".encode("ascii"))
        tin = []
        while not tin:
            du_lieu = robot.recv(1024)
            if not du_lieu:
                raise ConnectionError("vision đóng kết nối")
            tin = bd.nap(du_lieu)
        print("robot nhận:", doc_tin(tin[0]))
t.join(timeout=3)
srv.close()
robot nhận: {'seq': 1, 'ok': True, 'x': 101.5, 'y': 49.75, 'goc': 10.0}
robot nhận: {'seq': 2, 'ok': True, 'x': 103.0, 'y': 49.5, 'goc': 20.0}
robot nhận: {'seq': 3, 'ok': False, 'x': 104.5, 'y': 49.25, 'goc': 30.0}

Server đặt timeout cho cả accept và recv để không treo mãi; phía robot cũng dùng BoDemDong vì tin kết quả có thể đến thành nhiều mảnh. Trong trạm thật, vòng accept phải nằm trong vòng lặp ngoài để chấp nhận kết nối mới mỗi khi robot khởi động lại.

Bài 3. Một trạm chạy tốt nhiều tuần, rồi một hôm ai đó rút nhầm dây mạng của robot 10 giây rồi cắm lại. Từ đó vision báo "đã gửi" nhưng robot không nhận gì, đến khi khởi động lại phần mềm vision. Giải thích và đề xuất sửa.

Xem đáp án

Khi rút dây, không bên nào nhận được gói đóng kết nối. Robot thường phát hiện socket hỏng (hoặc chương trình robot bị reset) và mở lại cổng chờ kết nối mới, trong khi vision vẫn giữ socket cũ "nửa mở": sendall vẫn thành công vì dữ liệu chỉ được chép vào bộ đệm gửi của hệ điều hành. Nếu vision không chờ ACK, nó không bao giờ biết. Sửa: (1) luôn chờ ACK có timeout, timeout → đóng socket và kết nối lại như demo; (2) gửi heartbeat định kỳ khi không có sản phẩm, để phát hiện sự cố trước khi có vật cần gắp; (3) ghi log mọi lần kết nối lại để bảo trì biết chuyện gì đã xảy ra.

Tóm tắt

  • TCP là dòng byte: tin có thể dính hoặc vỡ. Không bao giờ coi một lần recv là một tin.
  • Đóng khung bằng ký tự kết thúc (\r\n, hợp với robot/PLC) hoặc tiền tố độ dài (hợp PC ↔ PC, dữ liệu lớn); dùng bộ đệm hoặc nhan_du.
  • Tin cho robot: số thứ tự, OK/NG, tọa độ thập phân cố định, dấu chấm, ASCII; robot trả ACK kèm seq; bên nhận kiểm tra định dạng.
  • Client chịu trách nhiệm kết nối lại; server luôn sẵn sàng nhận kết nối mới.
  • Luôn đặt timeout; b"" nghĩa là đầu kia đã đóng; sau timeout thì đóng socket, kết nối lại, gửi lại; seq giúp robot không thực hiện hai lần.

Bài tiếp theo: Vision dẫn hướng robot gắp vật, nơi tọa độ ảnh được đổi sang tọa độ robot trước khi gửi qua kênh socket bạn vừa xây.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.