feat(mqtt): add MQTT bridge component and integration

main
uc-hoba 3 weeks ago
parent f2578aa19d
commit 8aa58b1d6e
  1. 230
      Assets/Main.unity
  2. 173
      Assets/Scripts/MqttBridge.cs
  3. 2
      Assets/Scripts/MqttBridge.cs.meta

@ -682,6 +682,37 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 403055228}
m_CullTransparentMesh: 1
--- !u!1 &424481165
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 424481166}
m_Layer: 0
m_Name: ===============
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &424481166
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 424481165}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &437810536
GameObject:
m_ObjectHideFlags: 0
@ -981,6 +1012,21 @@ MonoBehaviour:
<color=#AAAAAA>Vertical</color>
<color=#7EC8E3>15</color>
<b><color=#FFC940>MQTT</color></b>
<color=#AAAAAA>Broker
Host</color> <color=#E3D47E>"localhost"</color>
<color=#AAAAAA>Broker
Port</color> <color=#7EC8E3>1883</color>
<color=#AAAAAA>Topic</color>
<color=#E3D47E>"unity/#"</color>
<color=#AAAAAA>Is Connected</color> <color=#E37E7E>false</color>
'
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 52d992bef73842844aaf4142ab3f3f5d, type: 2}
@ -1078,6 +1124,13 @@ MonoBehaviour:
- distance
- horizontal
- vertical
- target: {fileID: 952249953}
groupLabel: MQTT
fields:
- brokerHost
- brokerPort
- topic
- IsConnected
--- !u!222 &587767521
CanvasRenderer:
m_ObjectHideFlags: 0
@ -1560,6 +1613,57 @@ Transform:
- {fileID: 961739753}
m_Father: {fileID: 520906050}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &952249952
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 952249954}
- component: {fileID: 952249953}
m_Layer: 0
m_Name: MQTT
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &952249953
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 952249952}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2d9fec57d2f43b54cb83a1310af13701, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::UltraCombos.MqttBridge
brokerHost: localhost
brokerPort: 1883
topic: unity/#
verbose: 1
onMessage:
m_PersistentCalls:
m_Calls: []
--- !u!4 &952249954
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 952249952}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &961739749
GameObject:
m_ObjectHideFlags: 0
@ -1694,8 +1798,7 @@ Transform:
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1160234427}
m_Children: []
m_Father: {fileID: 924830808}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1142443635
@ -1731,77 +1834,6 @@ Transform:
- {fileID: 1553319432}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1160234425
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1160234427}
- component: {fileID: 1160234426}
- component: {fileID: 1160234428}
m_Layer: 0
m_Name: Main Volume
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1160234426
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1160234425}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 172515602e62fb746b5d573b38a5fe58, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IsGlobal: 0
priority: 0
blendDistance: 0
weight: 1
sharedProfile: {fileID: 11400000, guid: 2f46645358cbbd04eaa1121591e09687, type: 2}
--- !u!4 &1160234427
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1160234425}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 961739753}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!65 &1160234428
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1160234425}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1173268649
GameObject:
m_ObjectHideFlags: 0
@ -2018,6 +2050,55 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1300422186}
m_CullTransparentMesh: 1
--- !u!1 &1371171025
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1371171027}
- component: {fileID: 1371171026}
m_Layer: 0
m_Name: Global Volume
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1371171026
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1371171025}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 172515602e62fb746b5d573b38a5fe58, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Runtime::UnityEngine.Rendering.Volume
m_IsGlobal: 1
priority: 0
blendDistance: 0
weight: 1
sharedProfile: {fileID: 11400000, guid: 2f46645358cbbd04eaa1121591e09687, type: 2}
--- !u!4 &1371171027
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1371171025}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1553319431
GameObject:
m_ObjectHideFlags: 0
@ -3761,8 +3842,11 @@ SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 476511608}
- {fileID: 1371171027}
- {fileID: 952249954}
- {fileID: 605636970}
- {fileID: 848515248}
- {fileID: 424481166}
- {fileID: 604712594}
- {fileID: 505702790}
- {fileID: 1142443636}

@ -0,0 +1,173 @@
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;
using UnityEngine;
using UnityEngine.Events;
namespace UltraCombos
{
public class MqttBridge : MonoBehaviour
{
[SerializeField] private string brokerHost = "localhost";
[SerializeField] private int brokerPort = 1883;
[SerializeField] private string topic = "unity/#";
[SerializeField] private bool verbose = true;
[System.Serializable]
public class MessageEvent : UnityEvent<string, string> { }
public MessageEvent onMessage;
public bool IsConnected => mqttClient?.IsConnected ?? false;
private IMqttClient mqttClient;
private CancellationTokenSource cts;
private string clientId;
// Messages received on MQTT thread → dispatched on main thread in Update()
private readonly ConcurrentQueue<(string topic, string payload)> messageQueue
= new ConcurrentQueue<(string, string)>();
private void Awake()
{
// Cache Unity main-thread-only API before any async/thread-pool usage
clientId = $"unity_{SystemInfo.deviceUniqueIdentifier}";
}
private async void Start()
{
cts = new CancellationTokenSource();
// ConfigureAwait(false): continuation doesn't need to resume on Unity main thread
await ConnectAsync().ConfigureAwait(false);
}
private void Update()
{
// Drain the queue on the main thread so handlers can safely touch Unity objects
while (messageQueue.TryDequeue(out var msg))
{
onMessage?.Invoke(msg.topic, msg.payload);
HandleMessage(msg.topic, msg.payload);
}
}
private void OnDestroy()
{
// Must be synchronous: at shutdown the Unity SynchronizationContext stops processing,
// so async void OnDestroy would hang forever waiting for a continuation that never runs.
cts?.Cancel();
if (mqttClient?.IsConnected == true)
{
try { mqttClient.DisconnectAsync().Wait(System.TimeSpan.FromSeconds(2)); }
catch { }
}
mqttClient?.Dispose();
}
// ── Connection ────────────────────────────────────────────────────────
private async Task ConnectAsync()
{
if (mqttClient != null)
{
mqttClient.ApplicationMessageReceivedAsync -= OnApplicationMessageReceived;
mqttClient.ConnectedAsync -= OnConnected;
mqttClient.DisconnectedAsync -= OnDisconnected;
mqttClient.Dispose();
mqttClient = null;
}
var factory = new MqttFactory();
mqttClient = factory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer(brokerHost, brokerPort)
.WithClientId(clientId)
.WithCleanSession()
.Build();
mqttClient.ApplicationMessageReceivedAsync += OnApplicationMessageReceived;
mqttClient.ConnectedAsync += OnConnected;
mqttClient.DisconnectedAsync += OnDisconnected;
try
{
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
connectCts.CancelAfter(System.TimeSpan.FromSeconds(5));
await mqttClient.ConnectAsync(options, connectCts.Token).ConfigureAwait(false);
}
catch (System.Exception e)
{
Debug.LogError($"<b>[MQTT]</b> Connect failed: {e.Message}");
}
}
private async Task OnConnected(MqttClientConnectedEventArgs e)
{
Debug.Log("<b>[MQTT]</b> Connected.");
await SubscribeAsync(topic).ConfigureAwait(false);
}
private async Task OnDisconnected(MqttClientDisconnectedEventArgs e)
{
Debug.LogWarning($"[MQTT] Disconnected. Reconnecting in 3s…");
if (cts.IsCancellationRequested) return;
try
{
await Task.Delay(System.TimeSpan.FromSeconds(3), cts.Token)
.ConfigureAwait(false);
await ConnectAsync().ConfigureAwait(false);
}
catch (TaskCanceledException) { }
}
// ── Subscribe / Publish ───────────────────────────────────────────────
private async Task SubscribeAsync(string topicFilter)
{
var filter = new MqttTopicFilterBuilder()
.WithTopic(topicFilter)
.Build();
await mqttClient.SubscribeAsync(filter, cts.Token).ConfigureAwait(false);
Debug.Log($"<b>[MQTT]</b> Subscribed to: {topicFilter}");
}
public async Task PublishAsync(string publishTopic, string payload)
{
if (!IsConnected)
{
Debug.LogWarning("<b>[MQTT]</b> Publish skipped — not connected.");
return;
}
var message = new MqttApplicationMessageBuilder()
.WithTopic(publishTopic)
.WithPayload(payload)
.Build();
await mqttClient.PublishAsync(message, cts.Token).ConfigureAwait(false);
}
// ── Receive ───────────────────────────────────────────────────────────
private Task OnApplicationMessageReceived(MqttApplicationMessageReceivedEventArgs e)
{
var incomingTopic = e.ApplicationMessage.Topic;
var payload = e.ApplicationMessage.ConvertPayloadToString();
// Enqueue so it's processed on the main thread in Update()
messageQueue.Enqueue((incomingTopic, payload));
return Task.CompletedTask;
}
// Override this in a subclass, or subscribe to OnMessage instead
protected virtual void HandleMessage(string incomingTopic, string payload)
{
if (verbose)
{
Debug.Log($"<b>[MQTT]</b> {incomingTopic}: {payload}");
}
}
}
}

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d9fec57d2f43b54cb83a1310af13701
Loading…
Cancel
Save