Tlink TP301 4G DTU 数据透传采集

背景

本来想通过配置 MQTT 直接上平台,但是配了半天始终收不到数据,从对方平台拉取数据也不稳定,干脆就自己从设备上透传直采吧。

对 Modbus 还有 DTU 这东西不熟,一开始以为透传要把自己的端作为 Modbus RTU 的主端或从端,但是因为 4G DTU 这玩意压根没有固定 IP,所以写的代码只能作为从端,可是从端下发不了指令没法查呀,试了很多次都弄不了。后来想着先建一个 Socket 吧,看看能不能收到数据,结果还真有设备配置的 ID 上来,但是因为一开始一个 recv 程序就结束了,并没有保持长连接,也就没收到后续的内容。又以为是要自己写 Modbus 指令让 DTU 透传下去作为一个代理进行查询,试了一下果然能查上来寄存器的数据,正常情况下到这可能也就完了,状态什么的数据也已经能拉到了,服务端放个定时也就够了。但是吧,这设备还有个 GPS 定位直接接在了 DTU 上,这个可不能通过 ModBus 取,因为跟 PLC 根本就没关系。想着拿串口接上 PLC 看看系统,结果能连上但是没有输出,麻了麻了。

后来,偶然建了个长连接,发现 Socket 能够收到 ID、Modbus 指令的相应、GPS 定位、心跳等信息,只需要解析一下 Modbus 的数据就行了,命苦哦。

代码

import asyncio
import hashlib
import logging
import random
import socket
import string
import time
from datetime import datetime
from logging import handlers
from typing import Self

from aiohttp_requests import requests

BIND_ADDR = '0.0.0.0'
BIND_PORT = 6000
BACKLOG_NUM = 30
BUFFER_SIZE = 1024

CLOUD_SVR_URL = 'https://EXAMPLE.COM'
APP_KEY = 'KEY'
APP_SECRET = 'SECRET'
APP_MODEL = 'MODEL'

LOG_TO_FILE = True


class DtuSocket:

    def __init__(self, addr: str = BIND_ADDR, port: int = BIND_PORT, backlog: int = BACKLOG_NUM,
                 buffer_size=BUFFER_SIZE):
        self.addr, self.port, self.backlog, self.buffer_size = addr, port, backlog, buffer_size

    async def msg(self, conn: socket, addr: str) -> None:
        loop = asyncio.get_running_loop()
        plc_status = PlcStatus()
        try:
            client_id = (await loop.sock_recv(conn, self.buffer_size)).decode('utf-8')
            if not client_id:
                logger.warning('Empty Client ID, will close connection')
                return
            logger.info(f"Client ID: '{client_id}'")
            plc_status.client_id = client_id
            while True:
                data = await loop.sock_recv(conn, self.buffer_size)
                if not data:
                    # FIXME
                    # logger.warning(f"Empty data received from client: '{client_id}'")
                    await asyncio.sleep(0.5)
                    continue
                logger.info(f"Received from '{client_id}': {data}")
                plc_status.update(data)
                await plc_status.trans()
        except BaseException as e:
            logger.error(f"Client {addr} close with exception", e)
        finally:
            conn.close()
            logger.debug(f"Client {addr} closed")
            if plc_status.client_id:
                plc_status.status = 1
                await plc_status.trans()

    async def start(self) -> None:
        loop = asyncio.get_running_loop()
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.bind((self.addr, self.port))
        s.listen(self.backlog)
        s.setblocking(False)
        logger.info(f"Serving on:  {self.addr}:{self.port}")
        while True:
            conn, addr = await loop.sock_accept(s)
            logger.info(f"Accepted connect from: {addr}")
            loop.create_task(self.msg(conn, addr))


def parse_data(data: bytes) -> tuple | None:
    if data.startswith(b"\x02\x04\x02"):
        return "STATUS", (1 if data[4:5] == b"\x00" else 0,)
    elif data.startswith("GPS:".encode('utf-8')):
        return "GPS", (data[len("GPS:".encode('utf-8')):-3].decode('utf-8').split(','))
    else:
        return None


class PlcStatus:
    def __init__(self, client_id: str = '', status: int = 1, longitude: float = 0., latitude: float = 0.):
        self.client_id, self.status, self.longitude, self.latitude = client_id, status, longitude, latitude

    def update(self, msg: bytes) -> Self:
        data = parse_data(msg)
        match data:
            case ['STATUS', (status, )]:
                self.status = status
            case ['GPS', (latitude, longitude)]:
                self.latitude = latitude
                self.longitude = longitude
            case _:
                logger.debug(f"Unexpected data from {self.client_id}: {data}")
        return self

    async def trans(self) -> Self:
        nonce_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
        timestamp = str(int(time.time()))
        sign = hashlib.md5(f"nonceStr={nonce_str}&secretKey={APP_SECRET}"
                           f"&timestamp={timestamp}".encode("utf-8")).hexdigest().zfill(32)
        headers = {
            'Content-Type': 'application/json',
            "appKey": APP_KEY,
            "timestamp": timestamp,
            "nonceStr": nonce_str,
            "sign": sign,
        }
        req_body = {
            "type": "hw_truck_trane",
            "version": 1,
            "data": [
                {
                    "uuid": "74d35ee20988",
                    "code": self.client_id,
                    "status": self.status,
                    "longitude": self.longitude,
                    "latitude": self.latitude,
                    "recordTime": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                },
            ]
        }
        resp = await requests.post(CLOUD_SVR_URL, json=req_body, headers=headers)
        resp_body = await resp.text()
        logger.debug(f"Client '{self.client_id}', header: {headers}, request: {req_body}, response: {resp_body}")
        return self


def setup_logger():
    l = logging.getLogger(__name__)
    l.setLevel(logging.DEBUG)
    formatter = logging.Formatter(style='{', fmt='{asctime} - {levelname:8}: {message}')
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.DEBUG)
    console_handler.setFormatter(formatter)
    l.addHandler(console_handler)
    if LOG_TO_FILE:
        file_handler = logging.handlers.TimedRotatingFileHandler(
            './tp_link.log',
            when='D',
            interval=1,
            backupCount=15,
            encoding='utf-8'
        )
        file_handler.suffix = '%Y-%m-%d.log'
        console_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(formatter)
        l.addHandler(file_handler)
    return l


if __name__ == '__main__':
    logger = setup_logger()
    server = DtuSocket()
    asyncio.run(server.start())

posted @ 2025-05-12 15:42  seliote  阅读(49)  评论(0)    收藏  举报