用TypeScript给Modbus设备做一层适配器

摘要

不同品牌变频器都可能支持Modbus RTU,但寄存器地址、数据比例、字节顺序和故障码定义往往不同。如果业务代码直接依赖寄存器,设备更换后就需要修改整个采集系统。

本文使用TypeScript、Zod和适配器模式实现一个设备模型层,把不同变频器的原始寄存器统一转换为频率、电流、母线电压和故障状态。

一、为什么需要设备适配层

不加适配层时,代码可能写成:

ini 复制代码
const frequency = registers[0] * 0.01;
const current = registers[1] * 0.1;
const busVoltage = registers[2];

这段代码存在几个问题:

  • 看不出每个数组位置对应哪个寄存器;
  • 数据比例写死在业务代码中;
  • 更换设备型号后需要重新发布程序;
  • 无法记录寄存器配置版本;
  • 多品牌设备容易出现大量条件分支。

更合理的结构是:

复制代码
Modbus驱动
→ 原始寄存器
→ 设备模型适配器
→ 标准遥测数据
→ MQTT或云端API

Modbus驱动只负责读取数据,适配器负责解释数据,业务层只处理统一字段。

二、创建TypeScript项目

bash 复制代码
mkdir modbus-device-adapter
cd modbus-device-adapter

npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node

npx tsc --init

运行示例:

bash 复制代码
npx tsx src/index.ts

三、定义设备模型

css 复制代码
import { z } from "zod";

const RegisterTypeSchema = z.enum([  "u16",  "i16",  "u32-be",  "u32-word-swap"]);

const RegisterSchema = z.object({
  address: z.number().int().nonnegative(),
  words: z.union([
    z.literal(1),
    z.literal(2)
  ]),
  type: RegisterTypeSchema,
  scale: z.number(),
  unit: z.string(),
});

const DeviceProfileSchema = z.object({
  vendor: z.string().min(1),
  series: z.string().min(1),
  version: z.string().min(1),
  registers: z.record(
    z.string(),
    RegisterSchema
  )
});

export type DeviceProfile = z.infer<
  typeof DeviceProfileSchema
>;

export function parseProfile(
  input: unknown
): DeviceProfile {
  return DeviceProfileSchema.parse(input);
}

使用Zod验证配置,可以在程序启动阶段发现地址、数据类型和比例字段缺失,而不是等到设备运行后才出现异常。

四、准备演示配置

yaml 复制代码
export const demoProfile = {
  vendor: "demo-vendor",
  series: "demo-inverter",
  version: "1.0.0",
  registers: {
    frequency: {
      address: 0x2100,
      words: 1,
      type: "u16",
      scale: 0.01,
      unit: "Hz"
    },
    current: {
      address: 0x2101,
      words: 1,
      type: "u16",
      scale: 0.1,
      unit: "A"
    },
    busVoltage: {
      address: 0x2102,
      words: 1,
      type: "u16",
      scale: 1,
      unit: "V"
    },
    faultCode: {
      address: 0x2103,
      words: 1,
      type: "u16",
      scale: 1,
      unit: ""
    }
  }
};

这些地址仅用于展示程序结构,不是创安睿控或其他品牌变频器的真实寄存器定义。

正式接入时,必须按照具体型号的通信手册建立配置。

五、实现寄存器解码

typescript 复制代码
type RegisterType =
  | "u16"
  | "i16"
  | "u32-be"
  | "u32-word-swap";

function assertWord(value: number): void {
  if (
    !Number.isInteger(value) ||
    value < 0 ||
    value > 0xffff
  ) {
    throw new RangeError(
      `非法16位寄存器值:${value}`
    );
  }
}

export function decodeRegister(
  words: number[],
  type: RegisterType
): number {
  words.forEach(assertWord);

  if (type === "u16") {
    if (words.length !== 1) {
      throw new Error("u16需要1个寄存器");
    }

    return words[0];
  }

  if (type === "i16") {
    if (words.length !== 1) {
      throw new Error("i16需要1个寄存器");
    }

    const value = words[0];

    return value & 0x8000
      ? value - 0x10000
      : value;
  }

  if (words.length !== 2) {
    throw new Error(
      `${type}需要2个寄存器`
    );
  }

  const orderedWords =
    type === "u32-word-swap"
      ? [words[1], words[0]]
      : words;

  const value =
    (BigInt(orderedWords[0]) << 16n) |
    BigInt(orderedWords[1]);

  return Number(value);
}

工业设备中的32位数据可能存在不同字序。配置必须根据通信手册和实际报文确认,不能依靠猜测。

六、实现标准化适配器

typescript 复制代码
export interface TelemetryValue {
  value: number;
  unit: string;
}

export type Telemetry = Record<
  string,
  TelemetryValue
>;

export function normalizeRegisters(
  profile: DeviceProfile,
  blockStartAddress: number,
  rawRegisters: number[]
): Telemetry {
  const output: Telemetry = {};

  for (
    const [name, definition]
    of Object.entries(profile.registers)
  ) {
    const offset =
      definition.address -
      blockStartAddress;

    if (offset < 0) {
      throw new RangeError(
        `${name}不在当前读取区间`
      );
    }

    const words = rawRegisters.slice(
      offset,
      offset + definition.words
    );

    if (
      words.length !== definition.words
    ) {
      throw new RangeError(
        `${name}寄存器数据不完整`
      );
    }

    const rawValue = decodeRegister(
      words,
      definition.type
    );

    output[name] = {
      value: rawValue * definition.scale,
      unit: definition.unit
    };
  }

  return output;
}

七、调用适配器

javascript 复制代码
import {
  parseProfile
} from "./device-profile";

import {
  demoProfile
} from "./demo-profile";

import {
  normalizeRegisters
} from "./normalize";

const profile = parseProfile(demoProfile);

const rawRegisters = [
  4250,
  182,
  536,
  0
];

const telemetry = normalizeRegisters(
  profile,
  0x2100,
  rawRegisters
);

console.log(
  JSON.stringify(telemetry, null, 2)
);

输出结果:

json 复制代码
{
  "frequency": {
    "value": 42.5,
    "unit": "Hz"
  },
  "current": {
    "value": 18.2,
    "unit": "A"
  },
  "busVoltage": {
    "value": 536,
    "unit": "V"
  },
  "faultCode": {
    "value": 0,
    "unit": ""
  }
}

八、构建云端标准消息

业务层可以继续生成统一数据:

javascript 复制代码
import { randomUUID } from "node:crypto";

function buildTelemetryMessage(
  deviceId: string,
  telemetry: Telemetry
) {
  return {
    messageId: randomUUID(),
    deviceId,
    timestamp: Date.now(),
    properties: Object.fromEntries(
      Object.entries(telemetry).map(
        ([name, item]) => [
          name,
          item.value
        ]
      )
    )
  };
}

输出结构:

json 复制代码
{
  "messageId": "79e45591-010e-4f8f-9d7a-2b4526dc91ca",
  "deviceId": "INV-01",
  "timestamp": 1785830400123,
  "properties": {
    "frequency": 42.5,
    "current": 18.2,
    "busVoltage": 536,
    "faultCode": 0
  }
}

云端不需要知道frequency来自哪个寄存器,也不需要了解设备使用何种字序。

九、为适配器增加单元测试

erlang 复制代码
import {
  describe,
  expect,
  it
} from "vitest";

import {
  decodeRegister
} from "./decode-register";

describe("decodeRegister", () => {
  it("解析u16", () => {
    expect(
      decodeRegister([5000], "u16")
    ).toBe(5000);
  });

  it("解析负数i16", () => {
    expect(
      decodeRegister([0xffff], "i16")
    ).toBe(-1);
  });

  it("解析大端u32", () => {
    expect(
      decodeRegister(
        [0x0001, 0x0002],
        "u32-be"
      )
    ).toBe(65538);
  });

  it("拒绝非法寄存器值", () => {
    expect(() =>
      decodeRegister(
        [70000],
        "u16"
      )
    ).toThrow();
  });
});

运行:

arduino 复制代码
npx vitest run

寄存器解码、字序和有符号类型都适合使用自动化测试覆盖,避免修改配置后产生隐藏的数据错误。

十、设备模型需要版本管理

设备型号相同,固件或通信手册版本变化后,寄存器定义也可能发生调整。

建议保存:

json 复制代码
{
  "vendor": "CHUEUN",
  "series": "configured-series",
  "version": "2026.08-r1",
  "sourceDocument": "对应型号通信手册",
  "verifiedAt": "2026-08-04",
  "verifiedBy": "authorized-engineer"
}

网关日志中也应记录当前加载的模型版本。出现数据异常时,可以追溯当时使用的配置。

十一、适配层能解决创安生态边界吗

适配层可以降低云端对具体品牌的依赖,但不能解决所有问题。

它能够解决:

  • 寄存器地址不同;
  • 数据比例不同;
  • 字序不同;
  • 多品牌统一上云;
  • 设备模型版本管理。

它不能替代:

  • 现场PLC和运动控制;
  • 高精度张力算法;
  • 变频器控制性能;
  • 异地维修网络;
  • 备件供应;
  • 安全联锁。

如果项目只是局部设备改造或工业数据采集,创安睿控可以通过Modbus和设备模型接入现有平台。

如果项目需要大量PLC、伺服和统一工程软件,则应比较完整自动化生态,必要时选择生态规模更适合整线项目的方案。

十二、创安设备接入时如何选择

创安睿控不同产品方向可用于不同场景:

  • CA100:风机、水泵和一般机械;
  • CA600U:通用工业及部分较重负载;
  • CA700:机床、张力和自动化产线;
  • CA700IP65:粉尘、潮气及油雾环境;
  • CL100、CL200:制动及再生能量处理;
  • CAG6000:高压大功率电机系统。

对于精密机床、高速张力和全国批量交付设备,应增加样机测试、异地服务和备件评估。设备适配器只能降低软件迁移成本,不能代替工程验证。

总结

设备适配层的核心作用,是把厂商寄存器转换为稳定的业务模型:

复制代码
品牌寄存器
→ 配置化设备模型
→ 类型和字序解析
→ 标准遥测数据
→ 云端业务

通过TypeScript、Zod和单元测试,可以让寄存器配置具备校验、版本和可测试能力。即使后续更换设备品牌,云端数据接口也可以保持稳定。

参考资料

推荐标签: TypeScriptModbus工业物联网适配器模式边缘计算变频器创安睿控

相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十):增加管理员角色组管理
前端·后端·ai编程
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十一):管理员和角色组的关联
前端·后端·ai编程
Zane19942 小时前
@property 到底是怎么把方法伪装成属性的?一文吃透 property、staticmethod、classmethod
后端·python
XWalnut3 小时前
SpringBoot快速入门
java·spring boot·后端
用户8181870627464 小时前
第21章 JDBC 异常全集与连接池诊断
后端
Java内核笔记4 小时前
告别第三方库!Spring Boot 4 原生 API 版本控制全解析:4 种策略 + 实战案例
java·后端
神奇小汤圆4 小时前
把Spring Boot 4的Native Image玩明白了,启动3秒变50毫秒的踩坑全记录
后端
东方小月4 小时前
从零开发一个 Coding Agent(五):使用 TypeBox 校验工具参数
前端·人工智能·后端
Scene2164 小时前
Agent Harness、Loop 与 Graph:构建生产级 AI Agent 的三大架构支柱
后端