using System; using System.Collections.Generic; using System.Linq; using Manager.Services; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Mqtt.Client.AspNetCore.Services; using Newtonsoft.Json; using NSwag.Annotations; namespace ManagerService.Controllers { [Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)] [ApiController, Route("api/[controller]")] [OpenApiTag("Device", Description = "Device management")] public class DeviceController : ControllerBase { private DeviceDatabaseService _deviceService; private ConfigurationDatabaseService _configurationService; private readonly ILogger _logger; private readonly MyInfoMateDbContext _myInfoMateDbContext; IHexIdGeneratorService idService = new HexIdGeneratorService(); public DeviceController(ILogger logger, DeviceDatabaseService deviceService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext) { _logger = logger; _deviceService = deviceService; _configurationService = configurationService; _myInfoMateDbContext = myInfoMateDbContext; } private string? GetCallerInstanceId() => User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value; private bool IsSuperAdmin() => User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin); /// /// Get a list of all devices /// /// id instance /// Canal à filtrer. Absent = tous les appareils, comportement historique. [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet] public ObjectResult Get([FromQuery] string instanceId, [FromQuery] AppType? appType = null) { try { var scopedInstanceId = IsSuperAdmin() ? instanceId : GetCallerInstanceId(); var query = _myInfoMateDbContext.Devices.Include(d => d.Configuration).AsQueryable(); if (scopedInstanceId != null) query = query.Where(d => d.InstanceId == scopedInstanceId); if (appType != null) query = query.Where(d => d.AppType == appType.Value); return new OkObjectResult(query.ToList().Select(d => d.ToDTO())); } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Get a specific device /// /// id device /// /// ⚠️ C'est le premier appel d'une tablette qui démarre : /// tablet-app/lib/main.dart:38 reconstruit son client avec l'hôte mémorisé, /// puis demande son propre détail pour savoir quelle configuration afficher. Sans /// exception à la policy InstanceAdmin de la classe, cet appel répondait /// 403 et la tablette ne retrouvait plus son contenu au démarrage — même /// cause que l'appairage cassé depuis le 13/03/2026 (voir ). /// /// Le cloisonnement est déjà écrit plus bas : une clé ne voit que les appareils /// de son instance, et un appareil d'ailleurs ressort en 404. /// [AllowAnonymous] [Security.RequireAppKey] [ProducesResponseType(typeof(DeviceDetailDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{id}/detail")] public ObjectResult GetDetail(string id) { try { //OldDevice device = _deviceService.GetById(id); Device device = _myInfoMateDbContext.Devices.Include(d => d.Configuration).FirstOrDefault(i => i.Id == id); if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())) throw new KeyNotFoundException("This device was not found"); return new OkObjectResult(device.ToDetailDTO()); } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Create a new device /// /// New device info /// /// ⚠️ C'est une app qui appelle cette route, pas un humain : une tablette /// qui s'appaire avec un code PIN, et bientôt un casque. Or le contrôleur exige /// InstanceAdmin, qu'une clé d'API n'a pas — elle ne porte que /// AppRead et Viewer — et tablet-app ne s'authentifie /// jamais autrement. Depuis que la policy a été posée sur la classe (commit /// a452f4a, 13/03/2026, « need to be tested »), l'appairage d'une /// nouvelle tablette répondait 403 ; les tablettes déjà appairées ne /// rappellent pas cette route, donc rien ne le signalait. /// /// Le cloisonnement, lui, était déjà écrit juste en dessous : une clé ne peut /// créer un appareil que dans son instance. /// [AllowAnonymous] [Security.RequireAppKey] [ProducesResponseType(typeof(DeviceDetailDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 409)] [ProducesResponseType(typeof(string), 500)] [HttpPost] public ObjectResult Create([FromBody] DeviceDetailDTO newDevice) { try { if (newDevice == null) throw new ArgumentNullException("Device param is null"); if (!IsSuperAdmin() && newDevice.instanceId != GetCallerInstanceId()) throw new UnauthorizedAccessException("Cannot create a device for another instance"); //var configuration = _configurationService.GetById(newDevice.configurationId); var configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == newDevice.configurationId); if (configuration == null) throw new KeyNotFoundException("Configuration does not exist"); //OldDevice device = new OldDevice(); Device device = new Device().FromDTO(newDevice); device.Id = idService.GenerateHexId(); var deviceDB = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Identifier == newDevice.identifier); if (deviceDB != null) { // Update info device = deviceDB; //device = _deviceService.GetByIdentifier(newDevice.identifier); device.DateUpdate = DateTime.Now.ToUniversalTime(); } else { // Creation device.Identifier = newDevice.identifier; device.DateCreation = DateTime.Now.ToUniversalTime(); } device.InstanceId = newDevice.instanceId; device.Name = newDevice.name; device.ConfigurationId = newDevice.configurationId; // OLD WAY -> AppConfigurationLink device.IpAddressETH = newDevice.ipAddressETH; device.IpAddressWLAN = newDevice.ipAddressWLAN; device.Connected = newDevice.connected; device.ConnectionLevel = newDevice.connectionLevel; device.LastConnectionLevel = newDevice.lastConnectionLevel; device.BatteryLevel = newDevice.batteryLevel; device.LastBatteryLevel = newDevice.lastBatteryLevel; device.AppType = newDevice.appType; // Était hardcodé sur AppType.Tablet : un casque enregistré par ce chemin était // rattaché à l'instance kiosk et apparaissait dans l'onglet Kiosk. ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == newDevice.appType); if (applicationInstance == null) throw new KeyNotFoundException($"Application instance does not exist for app type {newDevice.appType}"); //OldDevice deviceCreated = _deviceService.IsExistIdentifier(newDevice.identifier) ? _deviceService.Update(device.Id, device) : _deviceService.Create(device); if (deviceDB != null) { _myInfoMateDbContext.Update(device); } else { _myInfoMateDbContext.Add(device); } _myInfoMateDbContext.SaveChanges(); if (deviceDB == null) { // Create AppConfigurationLink AppConfigurationLink link = new AppConfigurationLink(); link.ConfigurationId = newDevice.configurationId; link.ApplicationInstanceId = applicationInstance.Id; link.DeviceId = device.Id; link.Id = idService.GenerateHexId(); _myInfoMateDbContext.AppConfigurationLinks.Add(link); } _myInfoMateDbContext.SaveChanges(); return new OkObjectResult(device.ToDTO()); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (UnauthorizedAccessException ex) { return new ObjectResult(ex.Message) { StatusCode = 403 }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (InvalidOperationException ex) { return new ConflictObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Update a device /// /// Device to update [ProducesResponseType(typeof(DeviceDetailDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPut] public ObjectResult Update([FromBody] DeviceDetailDTO updatedDevice) { try { if (updatedDevice == null) throw new ArgumentNullException("Device param is null"); //OldDevice device = _deviceService.GetById(updatedDevice.id); Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == updatedDevice.id); if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())) throw new KeyNotFoundException("Device does not exist"); if (!IsSuperAdmin() && updatedDevice.instanceId != device.InstanceId) throw new UnauthorizedAccessException("Cannot move a device to another instance"); device.Name = updatedDevice.name; device.InstanceId = updatedDevice.instanceId; device.Identifier = updatedDevice.identifier; device.IpAddressWLAN = updatedDevice.ipAddressWLAN; device.IpAddressETH = updatedDevice.ipAddressETH; device.Connected = updatedDevice.connected; device.ConnectionLevel = updatedDevice.connectionLevel; device.LastConnectionLevel = updatedDevice.lastConnectionLevel; device.BatteryLevel = updatedDevice.batteryLevel; device.LastBatteryLevel = updatedDevice.lastBatteryLevel; //OldDevice deviceModified = _deviceService.Update(updatedDevice.id, device); _myInfoMateDbContext.SaveChanges(); return new OkObjectResult(device.ToDTO()); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (UnauthorizedAccessException ex) { return new ObjectResult(ex.Message) { StatusCode = 403 }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Heartbeat sent by a running app: battery, app version, last seen. /// /// Device id /// What the app knows about itself /// /// Lot XR-5. L'onglet XR affiche batterie, version et dernier vu depuis /// le 12/09 — mais rien ne les alimentait : Update n'écrit ni /// AppVersion ni LastSeen, et exige de toute façon un compte admin. /// /// Volontairement étroit : une app en fonctionnement ne doit pas pouvoir se /// renommer, changer d'instance ni se réassigner une configuration. Elle dit /// seulement comment elle va. /// [AllowAnonymous] [Security.RequireAppKey] [ProducesResponseType(typeof(DeviceDTO), 200)] [ProducesResponseType(typeof(string), 403)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPut("{id}/heartbeat")] public ObjectResult Heartbeat(string id, [FromBody] DeviceHeartbeatDTO beat) { try { Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id); if (device == null) throw new KeyNotFoundException("Device does not exist"); // Une clé ne parle que des appareils de son instance. Sans ce contrôle, // n'importe quelle app pourrait écrire l'état des casques d'un autre lieu. if (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()) throw new UnauthorizedAccessException("This key does not grant access to this device"); var now = DateTime.Now.ToUniversalTime(); if (beat?.batteryLevel != null) { device.BatteryLevel = beat.batteryLevel; device.LastBatteryLevel = now; } if (!string.IsNullOrEmpty(beat?.appVersion)) device.AppVersion = beat.appVersion; if (beat?.connectionLevel != null) { device.ConnectionLevel = beat.connectionLevel; device.LastConnectionLevel = now; } // Recevoir un battement **est** la preuve que l'appareil est en ligne : // on ne demande pas à l'app de nous dire qu'elle est connectée. device.Connected = true; device.LastSeen = now; device.DateUpdate = now; _myInfoMateDbContext.SaveChanges(); return new OkObjectResult(device.ToDTO()); } catch (UnauthorizedAccessException ex) { return new ObjectResult(ex.Message) { StatusCode = 403 }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Update device main info /// /// Device to update [ProducesResponseType(typeof(DeviceDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPut("mainInfos")] public ObjectResult UpdateMainInfos([FromBody] DeviceDTO deviceIn) { try { if (deviceIn == null) throw new ArgumentNullException("Device param is null"); //OldDevice device = _deviceService.GetById(deviceIn.id); Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == deviceIn.id); if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())) throw new KeyNotFoundException("Device does not exist"); //var configuration = _configurationService.GetById(deviceIn.configurationId); var configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == deviceIn.configurationId); if (configuration == null) throw new KeyNotFoundException("Configuration does not exist"); // Todo add some verification ? device.Name = deviceIn.name; device.Connected = deviceIn.connected; //device.Configuration = configuration.Label; device.ConfigurationId = deviceIn.configurationId; //OldDevice deviceModified = _deviceService.Update(device.Id, device); _myInfoMateDbContext.SaveChanges(); MqttClientService.PublishMessage($"player/{device.Id}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true })); return new OkObjectResult(device.ToDTO()); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Delete a device /// /// Id of device to delete [ProducesResponseType(typeof(string), 202)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpDelete("{id}")] public ObjectResult Delete(string id) { try { if (id == null) throw new ArgumentNullException("Device param is null"); Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id); if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())) throw new KeyNotFoundException("Device does not exist"); _myInfoMateDbContext.Remove(device); _myInfoMateDbContext.SaveChanges(); //_deviceService.Remove(id); return new ObjectResult("The device has been deleted") { StatusCode = 202 }; } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } } }