Websocket (Professional)

ℹ️

此功能在 realvirtual 6.3 (Professional) 中添加。

ℹ️

此接口需要 REALVIRTUAL_JSON 编译器定义。有关设置说明,请参见 Newtonsoft JSON。

概述

WebSocket 实时接口 (WebSocket Realtime Interface) 通过 WebSocket 连接提供高性能、双向的信号交换。它既可作为服务器也可作为客户端运行,支持两个 Unity 实例、外部应用程序、PLC 或任何支持 WebSocket 通信的系统之间的连接。

基于 FastInterface 框架构建,提供线程安全的后台通信、自动重连、变化检测和低延迟信号传输。

核心能力:

  • 单组件中的服务器和客户端模式
  • 扁平 JSON 通信协议 (v2),便于从任何语言集成
  • 从远端导入信号,自动反转方向
  • SSL/TLS 支持 (wss\://),通过反向代理实现安全连接
  • 基于模式的信号方向检测,适用于没有 PLC 类型前缀的外部系统
  • 订阅筛选,使客户端只接收所需的信号

设置

添加接口

  1. 将 WebsocketRealtimeInterface 预制体拖入场景,或将组件添加到现有 GameObject 上
  2. 接口显示为 realvirtual 根对象的子对象
  3. 在接口下创建 PLC 信号对象 (PLCInputBool, PLCOutputFloat 等) 作为子对象
WebSocket 实时接口 Inspector

服务器模式

将 Is Server 设置为 true(在 Connection Settings 折叠面板中)。服务器在配置的地址和端口上监听,并向所有已连接的客户端广播信号数据。

  • Address: 要绑定的 IP (127.0.0.1 用于本机,0.0.0.0 用于所有接口)
  • Port: TCP 端口(默认:1234)

客户端模式

将 Is Server 设置为 false。客户端连接到远程 WebSocket 服务器。

  • Address: 服务器的 IP 地址
  • Port: 服务器的 TCP 端口
  • Use SSL: 启用以使用 wss:// 连接(如通过反向代理)
  • Path: 可选的 URL 路径(如 /my-bridge/ws 用于反向代理路由)
  • Client Name: 握手期间发送的标识名称

信号导入

客户端模式导入(编辑模式)

在客户端模式下,您可以从远程服务器导入信号而无需手动创建:

  1. 确保远程服务器正在运行
  2. 在 Inspector 中点击 Import Signals(在编辑模式下工作 — 无需进入播放模式)
  3. 接口连接、请求信号目录、创建方向反转的本地镜像信号,然后断开连接

您也可以使用 Test Connection 来验证连接而不进行导入。

服务器模式导入(播放模式)

在服务器模式下,您可以在播放模式期间从已连接的客户端导入信号:

  1. 启动仿真,使服务器运行且客户端已连接
  2. 在 Inspector 中点击 Import Signals from Clients
  3. 服务器向所有已连接的客户端广播导入请求,并创建方向反转的本地镜像信号

这对于发现远程客户端提供的信号而无需手动定义非常有用。

属性

配置

属性描述
Update Cycle Ms通信循环间隔(毫秒)(默认:10)
Only Transmit Changed Inputs仅发送自上次周期以来值发生变化的信号
Auto Reconnect连接断开时自动重连
Reconnect Interval Seconds重连尝试之间的间隔(秒)
Max Reconnect Attempts放弃前的最大重连尝试次数(-1 = 无限)
Debug Mode启用详细日志记录以便故障排除

连接设置

属性描述
Is Servertrue = WebSocket 服务器,false = WebSocket 客户端
Address要绑定的 IP(服务器)或要连接的 IP(客户端)
Port通信 TCP 端口(默认:1234)
Use SSL(仅客户端)使用 wss:// 进行加密连接
Path(仅客户端)追加在 host:port 之后的可选 URL 路径
Client Name(仅客户端)初始化握手期间发送的名称
Connect On Play进入播放模式时自动连接

状态

属性描述
Connected Clients(仅服务器)当前已连接的客户端数量

信号导入

属性描述
Default Signal Direction无模式匹配时导入信号的方向(Input 或 Output)
Use Pattern Matching启用基于名称的方向检测
Input Patterns标识 Unity 写入信号的名称子串(如 "Unity")
Output Patterns标识 PLC 写入信号的名称子串

用例

  • Unity 到 Unity 的分布式仿真,带有信号镜像
  • 外部应用程序集成(Web 仪表板、自定义 HMI、测试框架)
  • PLC 桥接连接(如通过运行在 ctrlX CORE 或其他边缘设备上的桥接应用程序)
  • WebGL 构建,其中基于 TCP 的协议不可用

通信协议 v2 参考

WebSocket 实时接口使用扁平 JSON 协议。所有消息都是 UTF-8 编码的 JSON 对象,以 WebSocket 文本帧发送。没有双重编码 — 所有字段都是原生 JSON 类型。

消息信封

每条消息都有一个 type 字段来确定其用途:


{
  "type": "<message_type>",
  "version": 2,
  ...
}

消息类型

init - 客户端标识

客户端在 WebSocket 连接建立后立即发送。


{
  "type": "init",
  "version": 2,
  "name": "MyClient"
}
字段类型描述
typestring始终为 "init"
versionint协议版本 (2)
namestring客户端标识名称

data - 周期性信号交换

双向周期性发送。包含信号名称及其当前值。


{
  "type": "data",
  "signals": {
    "Sensor1": true,
    "DriveSpeed": 150.5,
    "MotorOn": false,
    "Counter": 42,
    "Status": "Running"
  }
}
字段类型描述
typestring始终为 "data"
signalsobject信号名称到当前值的映射。值为原生 JSON 类型:true/false 用于布尔值,数字用于整数/浮点数,字符串用于文本。

import_request - 信号发现请求

从一端发送到另一端,请求完整的信号目录。


{
  "type": "import_request",
  "version": 2
}

import_answer - 信号目录响应

对 import_request 的响应。包含所有信号名称、其当前值和 PLC 类型字符串。


{
  "type": "import_answer",
  "version": 2,
  "signals": {
    "Sensor1": true,
    "DriveSpeed": 0.0,
    "Counter": 0
  },
  "signalTypes": {
    "Sensor1": "PLCInputBool",
    "DriveSpeed": "PLCInputFloat",
    "Counter": "PLCOutputInt"
  }
}
字段类型描述
signalsobject信号名称到当前值
signalTypesobject信号名称到类型字符串。识别的类型:PLCInputBool、PLCOutputBool、PLCInputInt、PLCOutputInt、PLCInputFloat、PLCOutputFloat、PLCInputText、PLCOutputText。也接受不带前缀的中性类型(如 Bool、Float)。

导入时的方向反转: 当接收端导入信号时,方向会被反转:

  • 远端 PLCInputBool(远端从 PLC 读取)变为本地 PLCOutputBool(本地写入 Unity)
  • 远端 PLCOutputBool(远端写入 PLC)变为本地 PLCInputBool(本地从 Unity 读取)

subscribe - 筛选服务器输出

客户端发送,告知服务器它希望接收哪些信号。服务器随后在 data 消息中只发送匹配的信号。如果未发送订阅消息,服务器发送所有信号。


{
  "type": "subscribe",
  "version": 2,
  "subscribe": ["Sensor1", "DriveSpeed", "Counter"]
}

snapshot - 即时完整状态

服务器在收到 subscribe 消息后发送。包含所有已订阅信号的当前值。


{
  "type": "snapshot",
  "signals": {
    "Sensor1": true,
    "DriveSpeed": 0.0,
    "Counter": 0
  }
}

config_get - 读取服务器配置

客户端发送,从服务器请求当前配置(如桥接应用程序)。


{
  "type": "config_get",
  "version": 2
}

config_answer - 配置响应

服务器响应 config_get 发送。包含作为键值映射的当前配置。


{
  "type": "config_answer",
  "version": 2,
  "config": {
    "publishIntervalMs": 5,
    "loggingLevel": "Information",
    "browsePaths": [
      { "prefix": "plc/app/Application/sym", "direction": "input", "recursive": true }
    ]
  }
}
字段类型描述
configobject应用程序特定的键值配置映射。结构取决于服务器实现。

config_set - 写入服务器配置

客户端发送,更新服务器上的配置值。服务器应持久化并应用新配置。


{
  "type": "config_set",
  "version": 2,
  "config": {
    "publishIntervalMs": 10,
    "loggingLevel": "Debug",
    "browsePaths": [
      { "prefix": "plc/app/Application/sym", "direction": "input", "recursive": true }
    ]
  }
}

config_result - 配置更新结果

服务器响应 config_set 发送。报告配置是否已成功应用。


{
  "type": "config_result",
  "version": 2,
  "success": true,
  "message": "Configuration saved and applied"
}
字段类型描述
successbooleantrue 表示配置已应用,false 表示出错
messagestring人类可读的结果消息
ℹ️

config 消息是可选的且特定于应用程序。它们被 ctrlX 桥接器用于远程配置发布间隔、日志级别和 Data Layer 浏览路径。如果您实现自己的服务器,可以为 config 字段定义任何键值结构,或完全忽略这些消息。

信号值类型

realvirtual 类型JSON 类型示例
PLCInputBool / PLCOutputBoolbooleantrue, false
PLCInputInt / PLCOutputIntinteger42, -1, 0
PLCInputFloat / PLCOutputFloatnumber3.14, 0.0, -100.5
PLCInputText / PLCOutputTextstring"Running", ""

实现另一端

本节描述如何构建您自己的 WebSocket 应用程序,使其与 realvirtual WebSocket 实时接口通信。您可以使用任何支持 WebSocket 连接的编程语言。

连接序列

连接到 realvirtual 服务器时:


1. 打开到 ws://address:port 的 WebSocket 连接
2. 发送带有客户端名称的 "init" 消息
3. (可选)发送 "import_request" 以发现可用信号
4. (可选)发送 "subscribe" 以筛选要接收的信号
5. 开始 "data" 消息的周期性交换

运行您自己的服务器,realvirtual 作为客户端连接时:


1. 在您的 address:port 上启动 WebSocket 服务器
2. 接收 realvirtual 发送的 "init" 消息(客户端标识自身)
3. (可选)接收列出客户端所需信号的 "subscribe" 消息
4. (可选)发送已订阅信号当前值的 "snapshot"
5. 开始 "data" 消息的周期性交换

示例:Python 客户端

一个最小化的 Python 客户端,连接到 realvirtual 服务器、导入信号并交换数据:


import asyncio
import json
import websockets

async def main():
    uri = "ws://127.0.0.1:8080"

    async with websockets.connect(uri) as ws:
        # 1. 发送 init
        await ws.send(json.dumps({
            "type": "init",
            "version": 2,
            "name": "PythonClient"
        }))
        print("Connected and init sent")

        # 2. 请求信号目录
        await ws.send(json.dumps({
            "type": "import_request",
            "version": 2
        }))

        # 3. 等待 import_answer
        while True:
            raw = await ws.recv()
            msg = json.loads(raw)
            if msg.get("type") == "import_answer":
                print(f"Available signals: {list(msg['signalTypes'].keys())}")
                break

        # 4. 订阅特定信号(可选)
        await ws.send(json.dumps({
            "type": "subscribe",
            "version": 2,
            "subscribe": ["Sensor1", "DriveSpeed"]
        }))

        # 5. 周期性数据交换
        while True:
            # 从 realvirtual 接收数据
            raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
            msg = json.loads(raw)

            if msg.get("type") in ("data", "snapshot"):
                signals = msg.get("signals", {})
                print(f"Received: {signals}")

                # 向 realvirtual 发送数据
                await ws.send(json.dumps({
                    "type": "data",
                    "signals": {
                        "MyOutput": True,
                        "SetSpeed": 100.5
                    }
                }))

asyncio.run(main())

示例:Python 服务器

一个 realvirtual 作为客户端连接的服务器。当您的应用程序是"主机"而 realvirtual 是连接的客户端时非常有用:


import asyncio
import json
import websockets

# 信号状态
signals = {
    "PLC_Running": True,
    "PLC_Speed": 0.0,
    "PLC_Counter": 0
}

signal_types = {
    "PLC_Running": "PLCOutputBool",
    "PLC_Speed": "PLCOutputFloat",
    "PLC_Counter": "PLCOutputInt"
}

async def handle_client(ws):
    client_name = "unknown"
    subscribed_signals = None  # None = 发送全部

    async for raw in ws:
        msg = json.loads(raw)
        msg_type = msg.get("type")

        if msg_type == "init":
            client_name = msg.get("name", "unknown")
            print(f"Client connected: {client_name}")

        elif msg_type == "import_request":
            # 发送信号目录
            await ws.send(json.dumps({
                "type": "import_answer",
                "version": 2,
                "signals": signals,
                "signalTypes": signal_types
            }))

        elif msg_type == "subscribe":
            subscribed_signals = msg.get("subscribe", [])
            # 发送已订阅信号的快照
            snapshot = {k: v for k, v in signals.items()
                       if k in subscribed_signals}
            await ws.send(json.dumps({
                "type": "snapshot",
                "signals": snapshot
            }))

        elif msg_type == "data":
            # 从 realvirtual 接收值
            received = msg.get("signals", {})
            print(f"From {client_name}: {received}")
            # 在您的应用程序中处理接收到的值...

            # 发送更新后的值
            to_send = signals
            if subscribed_signals is not None:
                to_send = {k: v for k, v in signals.items()
                          if k in subscribed_signals}
            await ws.send(json.dumps({
                "type": "data",
                "signals": to_send
            }))

async def main():
    async with websockets.serve(handle_client, "0.0.0.0", 8080):
        print("WebSocket server running on ws://0.0.0.0:8080")
        await asyncio.Future()  # 永久运行

asyncio.run(main())

示例:C# 客户端 (.NET)

适用于 .NET 应用程序(如 PLC 或边缘设备上的桥接应用程序):


using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public class RealvirtualWebSocketClient
{
    private ClientWebSocket ws;
    private readonly string uri;
    private readonly string clientName;

    public RealvirtualWebSocketClient(string uri, string clientName = "DotNetClient")
    {
        this.uri = uri;
        this.clientName = clientName;
    }

    public async Task ConnectAsync(CancellationToken ct = default)
    {
        ws = new ClientWebSocket();
        await ws.ConnectAsync(new Uri(uri), ct);

        // 发送 init
        await SendAsync(new
        {
            type = "init",
            version = 2,
            name = clientName
        }, ct);
    }

    public async Task<Dictionary<string, JsonElement>> ReceiveDataAsync(
        CancellationToken ct = default)
    {
        var buffer = new byte[8192];
        var result = await ws.ReceiveAsync(buffer, ct);
        var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
        var msg = JsonSerializer.Deserialize<JsonElement>(json);

        var msgType = msg.GetProperty("type").GetString();
        if (msgType == "data" || msgType == "snapshot")
        {
            var signals = new Dictionary<string, JsonElement>();
            foreach (var prop in msg.GetProperty("signals").EnumerateObject())
                signals[prop.Name] = prop.Value;
            return signals;
        }
        return null;
    }

    public async Task SendDataAsync(Dictionary<string, object> signals,
        CancellationToken ct = default)
    {
        await SendAsync(new { type = "data", signals }, ct);
    }

    public async Task RequestImportAsync(CancellationToken ct = default)
    {
        await SendAsync(new { type = "import_request", version = 2 }, ct);
    }

    private async Task SendAsync(object message, CancellationToken ct)
    {
        var json = JsonSerializer.Serialize(message);
        var bytes = Encoding.UTF8.GetBytes(json);
        await ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
    }
}

示例:JavaScript / TypeScript(浏览器或 Node.js)


const ws = new WebSocket("ws://127.0.0.1:8080");

ws.onopen = () => {
    // 发送 init
    ws.send(JSON.stringify({
        type: "init",
        version: 2,
        name: "WebClient"
    }));

    // 请求信号目录
    ws.send(JSON.stringify({
        type: "import_request",
        version: 2
    }));
};

ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);

    switch (msg.type) {
        case "import_answer":
            console.log("Available signals:", Object.keys(msg.signalTypes));
            // 订阅特定信号
            ws.send(JSON.stringify({
                type: "subscribe",
                version: 2,
                subscribe: ["Sensor1", "DriveSpeed"]
            }));
            break;

        case "data":
        case "snapshot":
            console.log("Received signals:", msg.signals);

            // 发送数据
            ws.send(JSON.stringify({
                type: "data",
                signals: {
                    "MyOutput": true,
                    "SetSpeed": 100.5
                }
            }));
            break;
    }
};

方向映射指南

实现另一端时,理解信号方向至关重要:

您的应用程序发送...realvirtual 创建...含义
signalTypes: {"Speed": "PLCOutputFloat"}本地 PLCInputFloat,名称为 "Speed"您的应用写入 Speed,Unity 读取它
signalTypes: {"Enable": "PLCInputBool"}本地 PLCOutputBool,名称为 "Enable"您的应用读取 Enable,Unity 写入它
signalTypes: {"Value": "Float"}方向由模式匹配或默认值决定中性类型 - 无方向前缀

经验法则: 如果您的应用程序写入值,将其声明为 PLCOutput。如果您的应用程序从 Unity 读取值,将其声明为 PLCInput。realvirtual 在导入时反转方向。

实现者提示

  • 仅 JSON - 所有消息都是 JSON 文本帧。不使用二进制帧。
  • 无需心跳 - 周期性的 data 消息充当隐式心跳。如果停止发送数据,连接保持打开。
  • 部分更新 - data 消息不需要包含所有信号。您可以只发送已变化的信号。
  • 类型转换 - realvirtual 会尝试转换 JSON 数字类型。为浮点信号发送 1 或为整数信号发送 1.0 均可。
  • 信号名称 - 在两端使用一致的名称。名称区分大小写且精确匹配。
  • SSL/TLS - 对于通过反向代理 (nginx, traefik) 的生产部署,配置 useSSL=true 并设置 path 以匹配您的代理路由。

故障排除

接口无法连接:

  • 检查服务器是否正在运行且在配置的地址和端口上可访问
  • 对于客户端模式,验证地址、端口、SSL 和路径设置
  • 使用 Test Connection 按钮验证连接
  • 启用 Debug Mode 获取详细日志

信号不更新:

  • 确保信号是接口的子 GameObject
  • 检查信号方向(Input = Unity 写入,Output = Unity 读取)
  • 验证信号名称在两端是否完全匹配
  • 检查发送的 data 消息是否包含预期的信号名称

导入创建错误的方向:

  • 检查 import_answer 中的 signalTypes - PLCInput 和 PLCOutput 在导入时会反转
  • 对于中性类型,配置 Input Patterns 和 Output Patterns 或设置 Default Signal Direction

另请参见