using System; using System.Collections; using MyInfoMate.Vr.Net; using UnityEngine; namespace MyInfoMate.Vr.Kiosk { /// /// Ce que le casque dit de lui-même au manager — lot XR-5. /// /// L'onglet XR affiche batterie, version et dernier vu depuis le 12/09, mais rien ne /// les alimentait : c'est ce trou-là que ce composant comble. Un exploitant qui /// voit « vu il y a 3 minutes, 12 % » sait quoi faire ; « — » ne lui apprend rien. /// /// Pourquoi pas MQTT. Le plan prévoit MQTTnet à terme, et le serveur publie /// déjà sur player/{deviceId}. Mais à un à trois casques, un battement HTTP /// périodique fait le même travail pour dix fois moins de code, sans connexion /// permanente à maintenir ni bibliothèque à embarquer. MQTT redeviendra utile le jour /// où il faudra pousser vers le casque — recharger une config à distance — /// pas pour remonter trois chiffres. /// /// L'échec est silencieux, comme tout le reste de la borne : une statistique perdue /// ne vaut pas un message au visiteur. /// public class FleetReporter : MonoBehaviour { /// /// 3 min : assez fréquent pour qu'un exploitant voie un casque tomber avant la /// fin d'une visite, assez rare pour ne rien coûter en batterie ni en réseau. /// const float IntervalSeconds = 180f; ApiClient _client; string _deviceId; string _configurationId; /// /// Le manager a assigné une autre configuration à ce casque. C'est le /// « pousser vers le casque » du lot XR-5, obtenu sans MQTT : la réponse au /// battement porte déjà la configuration courante de l'appareil, il suffit de la /// comparer. /// /// Ce que ça coûte : la latence du battement, trois minutes au pire. /// Ce que ça évite : une connexion permanente à maintenir sur une borne, /// une bibliothèque MQTT embarquée dans le build, et un client de plus à /// reconnecter quand le wifi du musée tombe. Le serveur publie déjà sur /// player/{deviceId} (DeviceController.cs:413) : le jour où trois /// minutes seront trop, tout est prêt côté serveur. /// public event Action ConfigurationChanged; public static FleetReporter Attach(GameObject host, ApiClient client, string deviceId, string configurationId) { if (string.IsNullOrEmpty(deviceId)) return null; var reporter = host.AddComponent(); reporter._client = client; reporter._deviceId = deviceId; reporter._configurationId = configurationId; reporter.StartCoroutine(reporter.Loop()); return reporter; } class DeviceResponse { public string ConfigurationId; } IEnumerator Loop() { // Un premier battement tout de suite : le casque doit apparaître en ligne // dès qu'il démarre, pas trois minutes plus tard. Send(); while (true) { yield return new WaitForSeconds(IntervalSeconds); Send(); } } async void Send() { var result = await _client.PutAsync( $"/api/device/{_deviceId}/heartbeat", new { batteryLevel = BatteryPercent(), appVersion = Application.version }); if (!result.Ok) { Debug.Log($"[Fleet] Battement non envoyé : {result.Error}"); return; } var assigned = result.Value?.ConfigurationId; if (string.IsNullOrEmpty(assigned) || assigned == _configurationId) return; Debug.Log($"[Fleet] Nouvelle configuration assignée : {assigned}"); _configurationId = assigned; ConfigurationChanged?.Invoke(assigned); } /// /// Le niveau en pourcentage, ou null si le casque ne le donne pas — /// SystemInfo.batteryLevel renvoie -1 là où l'information n'existe pas, /// et envoyer « -100 % » serait pire que ne rien envoyer. /// static string BatteryPercent() { var level = SystemInfo.batteryLevel; return level < 0f ? null : Mathf.RoundToInt(level * 100f).ToString(); } } }