Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

118 lines
4.6 KiB
C#

using System;
using System.Collections;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Kiosk
{
/// <summary>
/// Ce que le casque dit de lui-même au manager — lot <b>XR-5</b>.
///
/// 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. <b>Un exploitant qui
/// voit « vu il y a 3 minutes, 12 % » sait quoi faire ; « — » ne lui apprend rien.</b>
///
/// <b>Pourquoi pas MQTT.</b> Le plan prévoit MQTTnet à terme, et le serveur publie
/// déjà sur <c>player/{deviceId}</c>. 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 <b>pousser</b> 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.
/// </summary>
public class FleetReporter : MonoBehaviour
{
/// <summary>
/// 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.
/// </summary>
const float IntervalSeconds = 180f;
ApiClient _client;
string _deviceId;
string _configurationId;
/// <summary>
/// Le manager a assigné une <b>autre configuration</b> à 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.
///
/// <b>Ce que ça coûte</b> : la latence du battement, trois minutes au pire.
/// <b>Ce que ça évite</b> : 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
/// <c>player/{deviceId}</c> (<c>DeviceController.cs:413</c>) : le jour où trois
/// minutes seront trop, tout est prêt côté serveur.
/// </summary>
public event Action<string> ConfigurationChanged;
public static FleetReporter Attach(GameObject host, ApiClient client,
string deviceId, string configurationId)
{
if (string.IsNullOrEmpty(deviceId)) return null;
var reporter = host.AddComponent<FleetReporter>();
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<DeviceResponse>(
$"/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);
}
/// <summary>
/// Le niveau en pourcentage, ou null si le casque ne le donne pas —
/// <c>SystemInfo.batteryLevel</c> renvoie -1 là où l'information n'existe pas,
/// et envoyer « -100 % » serait pire que ne rien envoyer.
/// </summary>
static string BatteryPercent()
{
var level = SystemInfo.batteryLevel;
return level < 0f ? null : Mathf.RoundToInt(level * 100f).ToString();
}
}
}