私有云IOT定位追踪系统详解

目录

1. 说明

2. 完成后的效果

2.1 实时定位

2.2 轨迹重现 

2.3 设备美照

3. 项目设计

3.1 系统拓扑图​编辑

3.2 技术选型

3.3 消息订阅处理架构图

3.4 frp服务在线监控​编辑

4. 实施

4.1 数据模型 – DeviceLocation

4.2 数据报规格定义

订阅主题

数据报格式 

银尔达后台配置

4.3 脚本:

– Kafka消费者守护程序

– 高德地图GPS定位纠偏脚本

5. 要点:

– API输出坐标数据时,需在Pydantic 模型做数据转换

6. 参考:

6.1 工具

– Javascript 在线转 Typescript: 

– GPS定位纠偏

6.2 码讯定位精度选择(定位技术对比)


1. 说明

      本文介绍一套低成本实现的IOT定位追踪系统方案,实现基于:本地内网服务器-云服务器-IOT终端-手机终端 互联互通基础上的定位追踪应用。

2. 完成后的效果

2.1 实时定位

定位实时数据

2.2 轨迹重现 

定位轨迹数据

2.3 设备美照

3. 项目设计

  • 准备一个云服务器,固定带宽2~5M的一般云主机,装frp服务端,负责系统通讯接驳。
  • 本地私有云集群搭建,多核高内存高性能服务器集群,负责提供各种服务,其中之一装Frp客户端,映射各服务端口到云服务器。
  • IOT定位器,在相关后台设置上报消息格式,MQTT服务器路径及订阅主题
  • IOT设备消息路径:定位器-》云服务器-》私有云 MQTT Server-》Nifi-》Kafka-》应用消费服务
  • 使用终端API路径:终端-》云服务器-》私有云 API Server
  • 3.1 系统拓扑图
    3.2 技术选型
    层次 技术/框架/硬件 备注
    前端 Vue3, Typescript VUE3结合高德地图Api, 实现实时定位与轨迹重现
    中间件
  • Nifi – 数据流控制
  • Emqx – MQTT服务
  • Kafka – 消息服务
  • 高德地图2.0
  • 后端 FastApi, Kinit 使用Kinit做后台管理
    数据库 Mysql 8, Redis 使用mysql里面的地理字段

    Geometry存位置数据

    定位器硬件 银尔达 Air820ug GPS有源天线,供电5V2A,10W,含GPS/BD定位功能,通过其后台设置每分钟上报一次
    3.3 消息订阅处理架构图

    3.4 frp服务在线监控

    4. 实施

    4.1 数据模型 – DeviceLocation

    参考: FastApi地理坐标数据存取实践_fastapi geoalchemy-CSDN博客

    from typing import List, Optional
    from datetime import datetime
    from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, ForeignKeyConstraint, Index, Integer, String, Table, Text, text
    from sqlalchemy.dialects.mysql import TINYINT
    from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
    from sqlalchemy.orm.base import Mapped
    from geoalchemy2 import Geometry, WKBElement
    from sqlalchemy.orm import relationship, Mapped, mapped_column
    from db.db_base import BaseModel
    from .data_types import DeviceType
    import uuid
    import secrets
    
    metadata = BaseModel.metadata
    
    class DeviceLocation(BaseModel):
        __tablename__ = 'ia_iot_device_location'
        __table_args__ = (
            Index('Index_1', 'iot_device_id'),
            Index('Index_2', 'coordinates')
        )
    
        id = mapped_column(BigInteger, primary_key=True)
        coordinates: Mapped[WKBElement] = mapped_column(Geometry(geometry_type='POINT', spatial_index=True), nullable=False, comment='地理坐标')
        iot_device_id = mapped_column(BigInteger, server_default=text("'0'"))
        label = mapped_column(String(255, 'utf8mb4_general_ci'))
    4.2 数据报规格定义
    订阅主题
    Topic 类型 备注
    ia001.device.bus Kafka 主题, 不允许有“/” 消息总线
    /ia001/report/# Emqx 主题 Nifi使用,通配设备上报消息
    /ia001/cmd/${IMEI} Emqx 主题 下发指令给设备, id – IMEI
    数据报格式 
    定位 {'meta': {'id': '860048072112954', 'cmd': '1001', 'extra': {}}, 'data': {'type': 'GPS', 'lng': '113.3589331', 'lat': '22.5405440', 'csq': '22'}}
    银尔达后台配置
    主动上报内容格式
    基站定位 {"meta":{"id":"${IMEI}","cmd":"1001","extra":{}},"data":{"type":"LBS","lng":"${LBSLON}","lat":"${LBSLAT}","csq":"${CSQ}"}}
    GPS定位 {"meta":{"id":"${IMEI}","cmd":"1001","extra":{}},"data":{"type":"GPS","lng":"${GPSLON}","lat":"${GPSLAT}","csq":"${CSQ}"}}
    LWT {"meta":{"id":"${IMEI}","cmd":"1004","extra":{}},"data":{"msg":"off line"}}
    4.3 脚本:
    – Kafka消费者守护程序
    from confluent_kafka import Consumer, KafkaError, KafkaException
    import asyncio
    import json
    import logging
    from sqlalchemy import insert, select, delete
    from datetime import datetime
    from core.database import db_getter
    from application.settings import BASE_DIR, IOT, KAFKA
    from apps.vadmin.iot.models.models import DeviceLocation, Device
    from apps.vadmin.iot.crud import DeviceLocationDal
    from apps.vadmin.iot.cmds.utils import *
    from apps.vadmin.iot.cmds.cmds import *
    
    # 设置日志格式,'%()'表示日志参数
    log_format = "%(message)s"
    logging.basicConfig(
        filename=f"{BASE_DIR}/logs/k1.log", format=log_format, level=logging.INFO
    )
    
    
    class DaemonConsumer:
        def __init__(self):
            self.debug = True
            # self.debug = False
    
        async def consume_loop(self, consumer, topics):
            try:
                # 订阅主题
                consumer.subscribe(topics)
                while True:
                    # 轮询消息
                    msg = consumer.poll(timeout=1.0)
                    if msg is None:
                        continue
                    if msg.error():
                        if msg.error().code() == KafkaError._PARTITION_EOF:
                            # End of partition event
                            print(
                                "%% %s [%d] reached end at offset %d\n"
                                % (msg.topic(), msg.partition(), msg.offset())
                            )
                        elif msg.error():
                            raise KafkaException(msg.error())
                    else:
                        # 正常消息
                        raw_message = msg.value()
                        # print(f"Raw message: {raw_message}")
                        str_msg = raw_message.decode("utf-8")
                        payload = json.loads(str_msg)
                        payload["server_time"] = datetime.now().strftime(
                            "%Y-%m-%d %H:%M:%S"
                        )
                        if self.debug:
                            print(f"Received message: {type(payload)} : {payload}")
                            json_data = json.dumps(payload, ensure_ascii=False)
                            logging.info("{}".format(json_data))
    
                        if "meta" in payload:
                            await self.process_message(payload)
            finally:
                # 关闭消费者
                consumer.close()
    
        async def process_message(self, payload):
            cmdTuple = CommandParser.getCommand(payload["meta"]["cmd"])
            print(cmdTuple)
            cmd = CommandDispatcher.dispatch(cmdTuple["class"])
            await cmd.execute(cmdTuple["action"], payload)
    
        async def run(self):
            # 消费者配置
            # conf = {
            #     "bootstrap.servers": "host001.dev.ia:19092,host001.dev.ia:29092,host001.dev.ia:39092",
            #     "group.id": "mygroup1",
            #     "auto.offset.reset": "earliest",
            # }
            conf = KAFKA
    
            # 创建消费者
            consumer = Consumer(conf)
            # await self.consume_loop(consumer, ["ia001.device.bus"])
            await self.consume_loop(consumer, [IOT["KAFKA_TOPIC_BUS"]])
    
    – 高德地图GPS定位纠偏脚本

    Javascript 版本: 查看绑定资源 

    Typescript 版本: 

    /* eslint-disable @typescript-eslint/no-loss-of-precision */
    
    const x_PI: number = (3.14159265358979324 * 3000.0) / 180.0
    const PI: number = 3.1415926535897932384626
    const a: number = 6378245.0
    const ee: number = 0.00669342162296594323
    
    const bd09togcj02 = (bd_lon: number, bd_lat: number): number[] => {
      bd_lon = +bd_lon
      bd_lat = +bd_lat
      const x: number = bd_lon - 0.0065
      const y: number = bd_lat - 0.006
      const z: number = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * x_PI)
      const theta: number = Math.atan2(y, x) - 0.000003 * Math.cos(x * x_PI)
      const gg_lng: number = z * Math.cos(theta)
      const gg_lat: number = z * Math.sin(theta)
      return [gg_lng, gg_lat]
    }
    
    const gcj02tobd09 = (lng: number, lat: number): number[] => {
      lat = +lat
      lng = +lng
      const z: number = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * x_PI)
      const theta: number = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * x_PI)
      const bd_lng: number = z * Math.cos(theta) + 0.0065
      const bd_lat: number = z * Math.sin(theta) + 0.006
      return [bd_lng, bd_lat]
    }
    
    const wgs84togcj02 = (lng: number, lat: number): number[] => {
      lat = +lat
      lng = +lng
      if (out_of_china(lng, lat)) {
        return [lng, lat]
      } else {
        const dlat: number = transformlat(lng - 105.0, lat - 35.0)
        const dlng: number = transformlng(lng - 105.0, lat - 35.0)
        const radlat: number = (lat / 180.0) * PI
        let magic: number = Math.sin(radlat)
        magic = 1 - ee * magic * magic
        const sqrtmagic: number = Math.sqrt(magic)
        const dlatAdjusted: number = (dlat * 180.0) / (((a * (1 - ee)) / (magic * sqrtmagic)) * PI)
        const dlngAdjusted: number = (dlng * 180.0) / ((a / sqrtmagic) * Math.cos(radlat) * PI)
        const mglat: number = lat + dlatAdjusted
        const mglng: number = lng + dlngAdjusted
        return [mglng, mglat]
      }
    }
    
    const gcj02towgs84 = (lng: number, lat: number): number[] => {
      lat = +lat
      lng = +lng
      if (out_of_china(lng, lat)) {
        return [lng, lat]
      } else {
        const dlat: number = transformlat(lng - 105.0, lat - 35.0)
        const dlng: number = transformlng(lng - 105.0, lat - 35.0)
        const radlat: number = (lat / 180.0) * PI
        let magic: number = Math.sin(radlat)
        magic = 1 - ee * magic * magic
        const sqrtmagic: number = Math.sqrt(magic)
        const dlatAdjusted: number = (dlat * 180.0) / (((a * (1 - ee)) / (magic * sqrtmagic)) * PI)
        const dlngAdjusted: number = (dlng * 180.0) / ((a / sqrtmagic) * Math.cos(radlat) * PI)
        const mglat: number = lat + dlatAdjusted
        const mglng: number = lng + dlngAdjusted
        return [lng * 2 - mglng, lat * 2 - mglat]
      }
    }
    
    const transformlat = (lng: number, lat: number): number => {
      lat = +lat
      lng = +lng
      let ret: number =
        -100.0 +
        2.0 * lng +
        3.0 * lat +
        0.2 * lat * lat +
        0.1 * lng * lat +
        0.2 * Math.sqrt(Math.abs(lng))
      ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
      ret += ((20.0 * Math.sin(lat * PI) + 40.0 * Math.sin((lat / 3.0) * PI)) * 2.0) / 3.0
      ret += ((160.0 * Math.sin((lat / 12.0) * PI) + 320 * Math.sin((lat * PI) / 30.0)) * 2.0) / 3.0
      return ret
    }
    
    const transformlng = (lng: number, lat: number): number => {
      lat = +lat
      lng = +lng
      let ret: number =
        300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng))
      ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
      ret += ((20.0 * Math.sin(lng * PI) + 40.0 * Math.sin((lng / 3.0) * PI)) * 2.0) / 3.0
      ret += ((150.0 * Math.sin((lng / 12.0) * PI) + 300.0 * Math.sin((lng / 30.0) * PI)) * 2.0) / 3.0
      return ret
    }
    
    const out_of_china = (lng: number, lat: number): boolean => {
      lat = +lat
      lng = +lng
      return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55)
    }
    
    export default {
      bd09togcj02,
      gcj02tobd09,
      wgs84togcj02,
      gcj02towgs84
    }
    

    5. 要点:

    – API输出坐标数据时,需在Pydantic 模型做数据转换
    
        @field_validator("coordinates", mode="before")
        def parse_coordinates(cls, value: WKBElement):
            return dump_coords(to_shape(value))[0] if value else None

    6. 参考:

    6.1 工具
    – Javascript 在线转 Typescript: 

    Javascript to Typescript converter with ChatGPT | Js2TS.com

    – GPS定位纠偏

    GPS 定位纠偏 

    6.2 码讯定位精度选择(定位技术对比)
    # UWB 蓝牙5.1 蓝牙信标 Wi-Fi RFID Zigbee
    精度 10-30cm 1-5m 3-5m 依赖于信标的密度 5-15m 15cm-1m 3-10m
    可靠性 抗干扰能力强 对多路径、障碍物和干扰非常敏感 易受遮挡和多径影响 对多路径、障碍物和干扰非常敏感 不易受影响 抗干扰能力弱
    覆盖范围 50-100m 10-20m 6-8m 40-50m 1m 60-70m(主要应用一维)
    数据通信 最高27Mbps 最高2Mbps 不适用 最高1 Gbps 不适用 20-250kbps
    安全范围 非常安全 可利用中继攻击进行欺骗 可利用中继攻击进行欺骗 可利用中继攻击进行欺骗 可利用中继攻击进行欺骗 安全性比较低
    定位服务延迟 <1ms ≤3ms ≥3ms ≥3ms ≥1s 30ms
    可拓展性 基于超过数万个或不限量标签的解决方案 几百到一千标签 几百到一千标签 几百到一千标签 不限量标签 最大60000个节点

    作者:bennybi

    物联沃分享整理
    物联沃-IOTWORD物联网 » 私有云IOT定位追踪系统详解

    发表回复