diff --git a/Assets/Main.unity b/Assets/Main.unity index 0e7c822..314b3e8 100644 --- a/Assets/Main.unity +++ b/Assets/Main.unity @@ -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: Vertical 15 + + + MQTT + + Broker + Host "localhost" + + Broker + Port 1883 + + Topic + "unity/#" + + Is Connected false + ' 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} diff --git a/Assets/Scripts/MqttBridge.cs b/Assets/Scripts/MqttBridge.cs new file mode 100644 index 0000000..db43fb8 --- /dev/null +++ b/Assets/Scripts/MqttBridge.cs @@ -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 { } + 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($"[MQTT] Connect failed: {e.Message}"); + } + } + + private async Task OnConnected(MqttClientConnectedEventArgs e) + { + Debug.Log("[MQTT] 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($"[MQTT] Subscribed to: {topicFilter}"); + } + + public async Task PublishAsync(string publishTopic, string payload) + { + if (!IsConnected) + { + Debug.LogWarning("[MQTT] 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($"[MQTT] {incomingTopic}: {payload}"); + } + } + } +} diff --git a/Assets/Scripts/MqttBridge.cs.meta b/Assets/Scripts/MqttBridge.cs.meta new file mode 100644 index 0000000..295f71c --- /dev/null +++ b/Assets/Scripts/MqttBridge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d9fec57d2f43b54cb83a1310af13701 \ No newline at end of file