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无模式匹配时导入信号的方向(InputOutput
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信号名称到类型字符串。识别的类型:PLCInputBoolPLCOutputBoolPLCInputIntPLCOutputIntPLCInputFloatPLCOutputFloatPLCInputTextPLCOutputText。也接受不带前缀的中性类型(如 BoolFloat)。

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

  • 远端 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 - PLCInputPLCOutput 在导入时会反转
  • 对于中性类型,配置 Input PatternsOutput Patterns 或设置 Default Signal Direction

另请参见