这是一个在智慧园区平台开发阶段用来模拟设备数据采集的项目

智慧园区平台开发最头疼的不是写代码,是没数据。真实设备还没进场,平台就已经要联调了;演示时大屏空空荡荡;压测时只能拿随机数凑——结果数据一看就是假的,温度跳变、三相电压一样大、半夜满员。这个项目就是来解决这件事的。

为什么需要设备模拟器?

如果你做过 IoT 平台开发,一定遇到过这些场景:

联调阶段:平台写好了,设备还没采购,怎么测?

演示阶段:大屏要好看,得有数据跳,总不能演示时跑空盘

压测阶段:要模拟 1000+ 设备并发上报,总不能真买 1000 台传感器

算法调优:数据分析算法需要合理输入,纯 rand() 生成的数据训练出来的模型没用

最粗暴的方案是随机生成。但随机数据有几个致命问题:

1. 温度不会 1 秒内从 10° 跳到 40° 再跳回来——它有热惯性

2. 三相电压不会完全一样——总有 2% 左右的不平衡度

3. 半夜 3 点办公室 CO2 不会 1200ppm——没人哪来的二氧化碳

4. 功率因数不会和负载率无关——轻载时 PF 就是低

所以我们需要一个**基于物理模型的、有时间规律的**设备数据模拟器。

项目概览

parkdevicesimulator,一个开箱即用的智慧园区 IoT 设备数据模拟平台。

核心数字:

10 大设备系统:楼宇自控、智能照明、安防监控、门禁管理、消防报警、智能停车、能源管理、环境监测、电梯监控、广播发布

49 种设备类型:从空调机组到车牌识别相机,从烟感探测器到储能电池

303 台设备实例:通过 YAML 配置文件定义,开箱即用

203+ 数据点:每个设备 3~12 个数据点

4 种协议:MQTT、HTTP REST、Modbus TCP、OPC UA

技术栈:**Go + Vue3 + Element Plus + ECharts**,前后端分离,单体部署。

插件式设备注册

每种设备类型通过 init() 函数自动注册到全局 Registry:

func init() {
    device.RegisterDevice("ahu", NewAHU)
}

func NewAHU(id string, meta map[string]any, cfg map[string]any) device.Device {
    return &AHU{
        BaseDevice:     device.NewBaseDevice(id, "ahu", "bas", types.ProtocolMQTT, meta),
        prevSupplyTemp: 18.0,
        prevReturnTemp: 24.0,
        setTemp:        24.0,
    }
}

新增设备类型只需要:

1. 实现 Device 接口GenerateData + CheckAlarms

2. 在 init() 中注册

3. YAML 配置文件中加一行

零侵入,不动核心代码。

四种协议适配

每种协议一个 Adapter,统一接口:

type ProtocolAdapter interface {
    Start() error
    Stop() error
    Report(deviceID string, data map[string]any) error
    Status() ProtocolStatus
}

MQTT:用 paho 库,按 park/{building}/{system}/{deviceType}/{deviceId} 主题发布

HTTP REST:POST 回调到 callback server,适合 RESTful 平台对接

Modbus TCP:内置内存寄存器模拟,设备数据映射到 holding register,无需外部服务

OPC UA:用 gopcua 创建真实 OPC UA Server,设备数据作为变量节点暴露

每台设备的协议可在 YAML 中指定,同一种设备类型可以走不同协议:

# 环境传感器走 OPC UA
 type: temp_humidity_sensor
  building: B001
  count: 10
  protocol: opcua

# 电力仪表走 Modbus
 type: power_meter
  building: B001
  count: 5
  protocol: modbus

数据生成模型——这个才是重点

前面说了,随机数据没用。这个模拟器的核心是**让数据像真的**。

时间规律

园区有人上班、有人下班,数据必须跟着时间走。

// 入住率模型
func OccupancyRate(hour int, isWorkday bool) float64 {
    if !isWorkday {
        return 0.05 + rand.Float64()*0.1  // 周末基本没人
    }
    switch {
    case hour >= 9 && hour < 12:  // 上午工作
        return 0.7 + rand.Float64()*0.2
    case hour >= 12 && hour < 14: // 午休
        return 0.3 + rand.Float64()*0.2  // 人走了大半
    case hour >= 14 && hour < 18: // 下午工作
        return 0.7 + rand.Float64()*0.2
    default:
        return 0.05 + rand.Float64()*0.1  // 夜间
    }
}

用电负载率、CO2 浓度、噪声水平都跟着入住率走。午休时人少了,CO2 降下来,空调风速降一档,照明回路关掉一半——这些是联动的。

温度惯性

空调不会瞬间把房间从 30° 降到 24°。温度变化是渐变的:

// 温度惯性模型:新值 = 旧值 + (目标值  旧值) × 系数
func TemperatureInertia(current, target, inertia float64) float64 {
    return current + (targetcurrent)*inertia
}

AHU 的送风温度每 30 秒更新一次,朝目标值缓慢逼近,再加上高斯噪声:

a.prevSupplyTemp = engine.TemperatureInertia(a.prevSupplyTemp, targetSupply, 0.92)
a.prevSupplyTemp += engine.GaussNoise(0, 0.2)

这样生成的数据画出来是一条平滑的曲线,不是锯齿。

三相不平衡

真实电网的三相电压不会完全一样:

func ThreePhaseVoltage(ratedV float64) (float64, float64, float64) {
    unbalance := 0.02  // 2% 不平衡度
    va := ratedV + GaussNoise(0, ratedV*unbalance)
    vb := ratedV + GaussNoise(0, ratedV*unbalance)
    vc := ratedV + GaussNoise(0, ratedV*unbalance)
    return va, vb, vc
}

功率因数和负载率相关——轻载时 PF 低,满载时高:

func PowerFactor(loadRate float64) float64 {
    pf := 0.75 + loadRate*0.2 + GaussNoise(0, 0.02)
    return Clamp(pf, 0.5, 0.99)
}

这些不是花里胡哨的技巧,是电力工程师一眼就能看出真假的东西。

室外温度

用正弦曲线模拟一天的温度变化,最低在凌晨 5 点,最高在下午 2 点:

`

夏季基准 28° 振幅 6°,冬季基准 8° 振幅 4°。室外温度影响空调设定、室内温度、PM2.5 浓度——一个变量牵动一串设备。

CO2 与 PM2.5

CO2 和人有关——人多就高:

func CO2Level(occupancyRate float64) float64 {
    return 400.0 + occupancyRate*800 + GaussNoise(0, 20)
    // 室外 400ppm,满员时约 1200ppm
}

PM2.5 和季节有关——冬季逆温层导致扩散差:

func PM25Level(season string, outdoorTemp float64) float64 {
    base := 30.0
    if season == "winter" {
        base = 80  // 冬季逆温
    }
    if outdoorTemp < 10 {
        base *= 1.5
    }
    return base + GaussNoise(0, 10)
}

8 种场景模式

光有正常数据不够,还得能模拟突发事件。

定时场景

场景

效果

正常工作日

默认场景,满负荷运转

周末

入住率降至 5%~15%,大部分设备待机

节假日

同周末,但持续多天

夏季高峰

室外基准温度 32°,冷负荷 ×1.3

冬季高峰

室外基准温度 2°,供暖全开

突发场景

场景

注入告警

消防紧急

烟感×5、温感×3、手报×1、消火栓×1、喷淋泵×1、紧急广播×1,共 12 条

停电事件

市电中断告警、电梯停运告警

入侵事件

红外对射×4、电子围栏×2、视频分析×1、门禁×1,共 8 条

切换到消防场景时,模拟器会自动注入对应的告警事件——不是随机告警,是按真实消防联动逻辑来的:先烟感报警,再温感确认,然后手报触发,消防泵启动,紧急广播播报。

Web 管理面板

Vue3 + Element Plus + ECharts,5 个页面:

  • 总览仪表盘:设备在线率环形图、系统分布柱状图、告警趋势折线图、当前场景状态

  • 设备管理:303 台设备一表览,支持按系统/状态筛选、关键字搜索、动态添加/删除设备

  • 告警中心:实时告警列表,按等级(critical/warning/info)筛选,支持确认/清除

  • 场景模式:8 种场景一键切换,当前场景高亮显示

  • 协议状态:四种协议运行状态、连接信息(endpoint、topic pattern、端口)

前端构建后嵌入 Go 后端,单端口部署,不用分开跑:

cd frontend && npm run build  # 输出到 web/dist/
./bin/parkdevicesimulator   # 访问 http://localhost:8090/web/

一些技术决策的思考

为什么用 Go?

IoT 模拟器需要高并发(303 个 goroutine 同时跑)、低内存、单文件部署。Go 三个都满足。Python 能做但并发不如 Go 优雅,Node.js 单线程模型在 CPU 密集的数据计算上有瓶颈。Go 编译出来一个二进制扔到服务器上就跑,不需要装 runtime——这对运维太友好了。

为什么不用时序数据库?

模拟器的数据是实时生成的,不需要持久化历史数据。如果平台需要存历史,那是平台的事——模拟器只管上报,不管存储。保持职责单一。

为什么 YAML 不用数据库配置?

303 台设备的定义是静态的,YAML 人类可读可编辑。改设备配置不需要连数据库,改个文件重启就行。而且 YAML 支持 anchor 和 merge,重复结构可以复用。

循环依赖怎么解?

​engine​ 包需要 device.Device​ 接口,device​ 包需要 engine​ 的工具函数。Go 不允许循环导入。解法是抽出 types​ 包放公共类型(ScenarioContext、Alarm、ProtocolType),device​ 和 engine​ 都依赖 types​ 而不互相依赖。

// types/types.go — 公共类型,零依赖
type ScenarioContext struct {
    Hour          int
    IsWorkday     bool
    Season        string
    OutdoorTemp   float64
    OccupancyRate float64
    // ...
}

// device/base.go — 依赖 types
type Device interface {
    GenerateData(ctx types.ScenarioContext) map[string]any
    CheckAlarms(data map[string]any) []types.Alarm
    // ...
}

// engine/utils.go — 依赖 types(实际上不需要,utils 是纯数学)
func TemperatureInertia(current, target, inertia float64) float64 { ... }

代码片段:一个完整的设备实现

以 AHU(空调机组)为例,看看一个设备需要实现什么:

type AHU struct {
    device.BaseDevice
    prevSupplyTemp float64  // 上一次的送风温度(用于惯性计算)
    prevReturnTemp float64  // 上一次的回风温度
    setTemp        float64  // 设定温度
}

func (a *AHU) GenerateData(ctx types.ScenarioContext) map[string]any {
    // 送风温度:夏天目标 16°,冬天供暖 28°
    targetSupply := 18.0
    if ctx.OutdoorTemp > 28 {
        targetSupply = 16.0
    } else if ctx.OutdoorTemp < 10 {
        targetSupply = 28.0
    }
    // 温度惯性:朝目标值缓慢逼近 + 高斯噪声
    a.prevSupplyTemp = engine.TemperatureInertia(a.prevSupplyTemp, targetSupply, 0.92)
    a.prevSupplyTemp += engine.GaussNoise(0, 0.2)

    // 回风温度:室内温度受室外影响 + 设定温度
    targetReturn := a.setTemp + (ctx.OutdoorTempa.setTemp)*0.1
    a.prevReturnTemp = engine.TemperatureInertia(a.prevReturnTemp, targetReturn, 0.95)
    a.prevReturnTemp += engine.GaussNoise(0, 0.3)

    // 风速:随入住率变化
    fanSpeed := 3
    switch {
    case ctx.OccupancyRate > 0.8:
        fanSpeed = 5
    case ctx.OccupancyRate > 0.5:
        fanSpeed = 4
    case ctx.OccupancyRate < 0.2:
        fanSpeed = 2
    }

    // 运行状态:周末夜间关机
    running := true
    if ctx.OccupancyRate < 0.1 && !ctx.IsWorkday {
        running = false
    }

    return map[string]any{
        "supply_temp": engine.Clamp(a.prevSupplyTemp, 5, 40),
        "return_temp": engine.Clamp(a.prevReturnTemp, 10, 40),
        "running":     running,
        "set_temp":    a.setTemp,
        "fan_speed":   fanSpeed,
    }
}

func (a *AHU) CheckAlarms(data map[string]any) []types.Alarm {
    var alarms []types.Alarm
    if rt, ok := data["return_temp"].(float64); ok && rt > 30 {
        alarms = append(alarms, types.Alarm{
            DeviceID: a.ID(),
            Level:    "warning",
            Type:     "high_return_temp",
            Message:  "回风温度过高",
        })
    }
    return alarms
}

每个设备都这么写:拿到场景上下文 → 基于物理模型计算 → 返回数据点。清晰、可测试、易维护。

如何使用

# 克隆
git clone https://github.com/yangdavip/parkdevicesimulator
cd parkdevicesimulator

# 编译后端
go build o bin/parkdevicesimulator ./cmd/simulator/

# 构建前端
cd frontend && npm install && npm run build && cd ..

# 启动
./bin/parkdevicesimulator

# 打开浏览器
open http://localhost:8090/web/

303 台设备全部上线运行。如果你本地有 MQTT broker(brew install mosquitto && mosquitto​),设备数据会通过 MQTT 自动发布。OPC UA Server 和 Modbus 是内置的,不需要外部服务。

仓库地址

  • GitHub: https://github.com/yangdavip/parkdevicesimulator

  • Gitee: https://gitee.com/yang_davip/parkdevicesimulator

MIT License,随便用。

写在最后

这个项目解决的核心问题就一个:让模拟数据像真的

随机数谁都会生成,但生成有物理意义、有时间规律、有联动关系的数据——这需要你对 IoT 场景有理解。温度有惯性、三相有不平衡、CO2 跟人走、PM2.5 看季节——这些细节决定了数据能不能骗过懂行的人。

Go 的并发模型 + 插件架构 + YAML 配置,编译出来一个二进制,扔到任何机器上就跑。

如果你也在做 IoT 平台开发,需要联调数据、演示大屏、或者压测——可以试试。