用 Go 语言实现一个智慧园区 IoT 设备模拟器:303 台设备、4 种协议
这是一个在智慧园区平台开发阶段用来模拟设备数据采集的项目
智慧园区平台开发最头疼的不是写代码,是没数据。真实设备还没进场,平台就已经要联调了;演示时大屏空空荡荡;压测时只能拿随机数凑——结果数据一看就是假的,温度跳变、三相电压一样大、半夜满员。这个项目就是来解决这件事的。
为什么需要设备模拟器?
如果你做过 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 种场景模式
光有正常数据不够,还得能模拟突发事件。
定时场景
突发场景
切换到消防场景时,模拟器会自动注入对应的告警事件——不是随机告警,是按真实消防联动逻辑来的:先烟感报警,再温感确认,然后手报触发,消防泵启动,紧急广播播报。
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 平台开发,需要联调数据、演示大屏、或者压测——可以试试。
- 感谢你赐予我前进的力量
