Hub : créer son connecteur

Dans le cadre de la supervision et de l’automatisation d’un bâtiment, la solution Immersive Hub joue un rôle central :

  • Elle centralise toutes les données provenant de sources hétérogènes (IoT, automates, bases de données, API REST…).
  • Elle met à disposition des applications clientes (Beholder 3D, tableaux de bord, scripts d’automatisation) des informations à jour en temps réel.
  • Elle permet à vos systèmes de parler un langage commun, même si les protocoles sous-jacents sont différents.

Le Hub, au travers de ses connecteurs, possède un grand nombre de protocoles, OPC UA, MQTT, Excel etc.

Mais que faire si :

  • Votre capteur ou votre service externe n’est pas nativement pris en charge par le Hub ?
  • Vous avez besoin de connecter une API propriétaire ou une source de données sur mesure ?

C’est ici qu’intervient la création d’un connecteur personnalisé.

Un connecteur Hub agit comme un pont entre votre source de données et le Hub.

  • Il écoute ou interroge la source externe
  • Il transmet les valeurs au Hub
  • Il permet aux Handles enregistrés par les sessions clientes de recevoir les données à jour.

Dans ce tutoriel, nous allons :

  1. Mettre en place un exemple concret en se connectant à une API REST simulant des capteurs de bâtiment
  2. Créer un connecteur capable de récupérer et d’exposer ces données au Hub.

Dans cet article

    Afficher plus
    Réduire

    Pré-requis

    1. Avoir installé et lancé un Hub
    2. Avoir la connaissance du role et des fonctionnalités du Hub.
    3. Savoir développer en .Net.

    Pourquoi créer un connecteur Hub personnalisé ?

    Dans la solution Immersive, le Hub est le cœur de l’architecture de données.
    Il agit comme un chef d’orchestre entre :

    • Les sources de données (capteurs IoT, automates, bases de données, API REST…)
    • Les consommateurs (Beholder 3D, tableaux de bord, scripts d’automatisation…)

    Le Hub prend déjà en charge de nombreux protocoles standards comme :

    • OPC UA pour les automates industriels
    • Modbus pour les systèmes techniques
    • MQTT pour les flux IoT
    • REST et SQL pour les services et bases classiques

    Le cas typique : déploiement chez un client avec source non supportée

    Lorsque l’on déploie Immersive chez un client final, il n’est pas rare de se retrouver dans l’un de ces scénarios :

    1. Le client expose des données par un protocole propriétaire
      • Exemple : un fabricant d’ascenseurs avec un protocole TCP maison
      • Ou un système GTB ancien qui publie ses données dans un format non standard
    2. Le client dispose d’une API spécifique
      • Exemple : un web service REST interne exposant des températures, consommations ou alarmes
      • Ou une façade de données cloud qui nécessite une authentification personnalisée
    3. L’accès aux données requiert une adaptation métier
      • Filtrage, agrégation ou conversion d’unités avant qu’elles soient exploitables
      • Ajout de logique pour remonter uniquement les valeurs pertinentes pour la supervision

    Dans ces cas, le Hub ne peut pas, en l’état, interroger directement la source.
    Sans connecteur dédié, les données restent inaccessibles pour Beholder ou vos scénarios d’automatisation.

    Le rôle du connecteur personnalisé

    Créer un connecteur Hub personnalisé permet de :

    • Étendre le Hub à de nouveaux protocoles sans toucher au cœur du logiciel
    • Adapter l’accès à des API spécifiques (REST, SOAP, fichiers, flux WebSocket…)
    • Transformer ou filtrer la donnée avant qu’elle arrive dans Immersive

    En pratique :

    • Le connecteur agit comme un traducteur entre la source du client et le Hub
    • Chaque Handle enregistré est associé à un chemin que le connecteur sait interpréter
    • Le Hub reste générique tandis que votre connecteur gère la logique spécifique

    Présentation du projet

    Dans ce projet d'exemple, nous accompagnons un syndic de copropriété ("le client" pour la suite) souhaitant exploiter la plateforme Immersive pour créer un jumeau numérique de son bâtiment.

    Les données IoT nécessaires à la représentation temps réel sont disponibles sur l’infrastructure du client, exposées via une API REST interne.

    Cependant, ces données utilisent un protocole spécifique qui n’est actuellement pas supporté par les connecteurs existants d’Immersive. Cela signifie qu’il sera nécessaire de développer un connecteur dédié afin d’assurer l’intégration et la visualisation des informations dans le jumeau numérique.

    Cette étape constitue un enjeu clé pour garantir la continuité entre l’infrastructure IoT du client et les capacités de représentation 3D et de gestion offertes par Immersive.

    Présentation détaillée du projet (téléchargeable)

    Le projet permettant de simuler l'API Rest de ce syndic de copropriété fourni est un projet Visual Studio (C# / ASP.NET Core) qui reproduit une infrastructure bâtimentaire et expose des données via une API REST. Il sert de base pour développer un connecteur Immersive adapté à ce protocole non supporté nativement.

    Cliquez ici pour télécharger le projet ASP.Net de simulation :

    Équipements et capteurs simulés

    • Temperature : « Room 101 Temperature » (unité : °C).
    • Humidity : « Room 101 Humidity » (unité : %).
    • Light : « Corridor Light Level » (unité : lux).
    • Presence : « Office Presence » (booléen).
    • Energy : « Main Electric Counter » (unité : kWh, cumulatif).
    • Smoke : « Smoke Detector » (booléen).
    • Elevator : « Lift A » (multi-variables : floor numérique, state enum, door enum).
    • Garage door : « Garage G1 » (multi-variables : door enum, obstacle bool).

    Endpoints REST exposés

    GET  /api/Devices           → Liste de tous les équipements (single-value et multi-variables).
    GET  /api/Devices/{id:int}  → Détail d’un équipement par identifiant.
    POST /api/Scene             → Déclenche une scène (simulation d’action bâtimentaire).
    

    Modèle de données (réponses JSON)

    Équipement single-value (ex. « Corridor Light Level ») :

    {
      "id": 3,
      "name": "Corridor Light Level",
      "type": "light",
      "path": "",
      "value": "300",
      "unit": "lux",
      "timestampUtc": "2025-08-17T10:23:00Z",
      "variables": null
    }

    Ce type d'équipement n'expose qu'une seule valeur via la propriété "value".

    Équipement multi-variables – Ascenseur « Lift A » :

    {
      "id": 101,
      "name": "Lift A",
      "type": "elevator",
      "path": "/equipments/101",
      "value": null,
      "unit": null,
      "timestampUtc": null,
      "variables": [
        {
          "name": "floor",
          "path": "/equipments/101/floor",
          "kind": "numeric",
          "unit": null,
          "value": "3",
          "timestampUtc": "2025-08-17T10:23:00Z"
        },
        {
          "name": "state",
          "path": "/equipments/101/state",
          "kind": "enum",
          "unit": null,
          "value": "Moving",
          "timestampUtc": "2025-08-17T10:23:00Z"
        },
        {
          "name": "door",
          "path": "/equipments/101/door",
          "kind": "enum",
          "unit": null,
          "value": "Closed",
          "timestampUtc": "2025-08-17T10:23:00Z"
        }
      ]
    }

    Équipement multi-variables – Porte de garage « Garage G1 » :

    {
      "id": 201,
      "name": "Garage G1",
      "type": "garageDoor",
      "path": "/equipments/201",
      "value": null,
      "unit": null,
      "timestampUtc": null,
      "variables": [
        {
          "name": "door",
          "path": "/equipments/201/door",
          "kind": "enum",
          "unit": null,
          "value": "Closed",
          "timestampUtc": "2025-08-17T10:23:00Z"
        },
        {
          "name": "obstacle",
          "path": "/equipments/201/obstacle",
          "kind": "bool",
          "unit": null,
          "value": "false",
          "timestampUtc": "2025-08-17T10:23:00Z"
        }
      ]
    }

    Ces équipements exposent plusieurs variables via la collection "variables".

    Exemples d’appels API

    Lister tous les équipements

    GET /api/Devices

    Récupérer un équipement par id

    GET /api/Devices/101

    Déclencher une scène

    Scènes prises en charge : corridor light / lumiere couloir (on/off), fire alarm / alarme incendie (on/off).

       
    POST /api/Scene
    Content-Type: application/json
    
    {
      "sceneName": "Corridor Light",
      "action": "on"
    }
    

    Exemple de réponse :

    {
      "scene": "Corridor Light",
      "action": "on",
      "timestamp": "2025-08-17T10:23:00Z",
      "message": "💡 Corridor light on"
    }

    Les présentations avec la source de données du client étant faites, nous pouvons commencer à réflechir à l'implémentation de notre connector.

    DLL d’abstraction : contrat à respecter (IConnector) et base d’implémentation (ConnectorBase)

    Pour développer un connecteur reconnu par le Hub, il doit respecter un contrat commun : c’est ce contrat qui permet au Hub d’identifier le connecteur, d’orchestrer son cycle de vie et d’échanger des données (handles, valeurs, commandes). Ce contrat est défini dans la DLL GraphicStream.Immersive.Hub.Abstractions.

    Concrètement, vous avez deux voies :

    • Implémenter l’interface IConnector pour un contrôle total (vous codez tout le cycle de vie et les interactions).
    • Faire hériter la classe de votre connecteur custom de ConnectorBase pour gagner du temps (cycle de vie, gestion des erreurs, scheduling et publication déjà pris en charge, vous ne codez que le métier).

    La suite de ce chapitre présente chaque membre du contrat (IConnector) et explique comment ConnectorBase en fournit l’ossature afin d’accélérer l’implémentation.

    Interface IConnector — membres et rôle

    Déclaration de l'interface :

    /// <summary>
    /// Defines the contract for a Hub connector.
    /// </summary>
    public interface IConnector
    {
        /// <summary>
        /// Gets or sets the Hub context used to interact with the host.
        /// </summary>
        IHubContext? Context { get; set; }
    
        /// <summary>
        /// Gets or sets the connector display name.
        /// </summary>
        string Name { get; set; }
    
        /// <summary>
        /// Gets or sets the connector's description.
        /// </summary>
        string? Description { get; set; }
    
        /// <summary>
        /// Gets or sets the connector's pattern.
        /// </summary>
        string? Pattern { get; set; }
    
        /// <summary>
        /// Gets or sets the connector's connection string.
        /// </summary>
        string? ConnectionString { get; set; }
    
        /// <summary>
        /// Gets the <see cref="SessionStatus"/> of the current <see cref="OpcHubConnectorSession"/>
        /// </summary>
        Status Status { get; set; }
    
        /// <summary>
        /// Gets the connector definition associated to the current <see cref="IConnector"/> instance.
        /// </summary>
        ConnectorDefinition? Definition { get;}
    
        /// <summary>
        /// Gets the list of associated <see cref="ActivitySign"/>.
        /// </summary>
        System.Collections.Generic.IEnumerable<ActivitySign>? ActivitySigns { get; }
    
        /// <summary>
        /// Initialize the current <see cref="ConnectorBase"/> with the base useful parameters.
        /// </summary>
        Task Initialize();
    
        /// <summary>
        /// Connects the connector to its data source.
        /// Returns true if the connection succeeds.
        /// </summary>
        Task<bool> Connect();
    
        /// <summary>
        /// Disconnects and cleans up the connector.
        /// </summary>
        Task<bool> Disconnect();
    
        /// <summary>
        /// Return true if the current <see cref="IConnector"/> can handle the specified handle.
        /// </summary>
        /// <param name="handle"></param>
        /// <returns></returns>
        virtual bool CanHandle(IHandle handle) { return true; }
    
        /// <summary>
        /// Registers handles (paths) that the Hub wants to monitor.
        /// Returns true if at least one handle is supported.
        /// </summary>
        System.Collections.Generic.List<Handles.HandleRegistrationResponse> HandleHandles(IEnumerable<IHandle> handles);
    
        /// <summary>
        /// Requests the connector to write a value to the specified handle (output direction).
        /// </summary>
        /// <param name="handle">The handle representing the target data path.</param>
        /// <param name="value">The value to write to the target.</param>
        /// <returns>
        /// A task that completes with <c>true</c> if the write was successful, or <c>false</c> otherwise.
        /// </returns>
        Task<WriteValueResult> WriteValue(string path, string? value);
    
    }

    Description détaillée de chaque champ / propriété / méthode

    • Context : IHubContext?

      Permet d’interagir avec le Hub (journaliser, notifier un changement de handle, enregistrer des subscriptions, accès stockage, etc.).

    • Name : string
      • Le nom affiché du connecteur (lisible côté UI/ops).
      • Utile pour distinguer plusieurs instances d’un même type de connecteur.
      • Le nom peut être défini par le connecteur ou chargé depuis la configuration du Hub.
    • Description : string?
      • Description libre (but, périmètre, environnement cible).
      • Optionnelle.
      • La description peut être définie par le connecteur ou chargée depuis la configuration du Hub.
    • Pattern : string?
      • Chaîne de pattern/filtre propre au connecteur (par ex. masque de chemins, sélection de nœuds, etc.). Le path pris en compte par le connecteur peut reposer sur une correspondance avec le pattern. Ce tutoriel montre un exemple.
      • Optionnelle ; interprétation métier par le connecteur.
      • Le pattern peut être défini par le connecteur ou chargé depuis la configuration du Hub.
    • ConnectionString : string?
      • Paramètres de connexion à la source (URL, credentials, base de données, broker, etc.).
      • La sémantique dépend du protocole/source gérés par le connecteur.
      • La connection string peut être définie par le connecteur ou chargée depuis la configuration du Hub.
    • Status : Status
      • État courant du connecteur (enum). Valeurs typiques : Disconnected, Connecting, Connected, ConnectError, DisconnectedWithCommunicationError, WorkingError.
      • À mettre à jour lors des transitions (A partir du Connect(), lors d’erreurs réseau, etc.).
      • Le status permet de prévenir les administrateurs du Hub ou les outils de supervision de la viabilité de la source de données représentée par le connecteur.
    • Definition : ConnectorDefinition?
      • Définition/config structurée associée au connecteur (métadonnées, propriétés déclaratives).
      • Fourni par la configuration Hub pour cette instance.
      • La configuration est un moyen de paramétrer un connecteur de manière flexible.
    • ActivitySigns : IEnumerable<ActivitySign>?
      • Signaux/indicateurs d’activité rattachés au connecteur (ex. activité de polling, débit, etc.).
      • Optionnel ; utilisé par l’observabilité Immersive.
      • Les signes d'activité permettent de prévenir les administrateurs du Hub ou les outils de supervision de la viabilité de la source de données représentée par le connecteur.
    • Initialize() : Task
      • Initialisation « à froid » : valider les champs (ConnectionString, Pattern…), créer les clients (HTTP, MQTT…), préparer les mappers et caches.
      • Doit être idempotente et lever des exceptions claires si la configuration est invalide.
    • Connect() : Task<bool>
      • Connexion effective à la source (établir session, handshake, ouvrir subscriptions).
      • Retourne true si la connexion est réussie ; met à jour Status en conséquence.
    • Disconnect() : Task<bool>
      • Fermeture propre de la connexion (désabonnement, flush, dispose ressources).
      • Retourne true si la déconnexion s’est bien passée.
    • CanHandle(IHandle handle) : bool
      • Indique si le connecteur sait traiter un handle (chemin) donné.
      • À surcharger dans une implémentation concrète si le connecteur ne supporte qu’un sous-ensemble de chemins.
    • HandleHandles(IEnumerable<IHandle> handles) : List<HandleRegistrationResponse>
      • Enregistre une liste de handles demandés par le Hub (chemins à surveiller/publier) sur le connector a validé via CanHandle.
      • Retourne, pour chaque handle, une réponse d’enregistrement (HandleRegistrationResponse) indiquant notamment la prise en charge effective et la clé/chemin final utilisés.
    • WriteValue(string path, string? value) : Task<WriteValueResult>
      • Écriture (sortante) d’une valeur vers la source sur un chemin donné (handle cible).
      • Retourne un WriteValueResult indiquant le succès/échec et, selon le type, des détails complémentaires.

    Classe ConnectorBase — base d’implémentation

    La classe ConnectorBase est fournie dans la DLL GraphicStream.Immersive.Hub. Elle implémente déjà la majorité du contrat IConnector et fournit une ossature standard : gestion du Status, des chemins (HandledPaths), des messages d’état (StateMessages), intégration avec le IHubContext et le IConnectorsManager.

    En héritant de ConnectorBase, vous n’avez plus qu’à surcharger certaines méthodes virtuelles pour brancher votre logique protocole :

    • OnConnect() et OnDisconnect() : pour établir ou fermer la connexion avec la source de données.
    • OnCanHandle(IHandle handle) : pour définir les critères de prise en charge d’un handle (par défaut, basé sur Pattern).
    • GetHandle(string path, DateTime? date) : pour retourner la valeur d’un handle ciblé.
    • HandleHandles(IEnumerable<IHandle>) : pour gérer l’enregistrement de nouveaux handles demandés par le Hub.
    • WriteValue(string path, string? value) : pour écrire une valeur dans la source de données.

    Formatage des Paths & utilisation d’un Pattern (RegExp)

    Le Hub peut exécuter plusieurs connecteurs en parallèle. Lorsqu’une session propose un Handle, le Hub appelle la méthode CanHandle de chaque connecteur et retient le premier qui répond true. Par défaut, cette décision s’appuie sur deux éléments : l’état du connecteur (IConnector.Status == Connected) et le champ IConnector.Pattern qui est évalué comme une expression régulière sur le Path du handle.

    Pour éviter toute ambiguïté entre connecteurs, les paths exposés par les handles doivent permettre aux connecteurs de pouvoir dire via CanHandle si la donnée ciblée par le Handle peut être prise en charge par eux ou non.

    Par exemple des Handles ciblant une donnée hébergée par un serveur OPC UA auront des paths du type :

    • nsu=namespaceUri;i=integer<
    • nsu=namespaceUri;s=string
    • nsu=namespaceUri;g=guid
    • nsu=namespaceUri;b=base64string

    Par exemple :

    ns=4;i=2
    

    Notre connecteur pour notre projet d'apprentissage de Syndic de Copropriété doit reconnaître de façon non équivoque les chemins qui lui appartiennent. Partons donc sur la standardisation suivante :

    BuildingSensor://{deviceIdentifier}/{VariableName}
    • BuildingSensor:// : Les paths vers les données du syndicat devront commencer par ce scheme.
    • deviceIdentifier : identifiant numérique ou alphanumérique de l’équipement simulé.
    • VariableName : nom de variable (optionnel) pour les équipements multi-variables (ex. floor, door, state…).

    Comment faire reconnaitre le path ?

    Nous pourrions tout simplement dans OnCanHandle de ConnectorBase vérifier simplement le scheme via Uri.Scheme == "buildingsensor": Cela marche et est suffisant. Pour pousser l'exemple plus loin et continuer notre apprentissage ajoutons de la précision/filtrage. Nous allons donc :

    • Définir un Pattern (regexp) qui capture notre format de chemin.
    • Adapter OnCanHandle pour s’appuyer sur ce pattern et sur l’état du connecteur.

    Le pattern recommandé

    Ce pattern accepte le schéma « BuildingSensor:// », un identifiant d’équipement, et un nom de variable optionnel :

    ^buildingsensor:\/\/(?<device>[A-Za-z0-9_-]+)(?:\/(?<prop>[A-Za-z0-9_.:-]+))?$
    • (?<device>...) capture l’identifiant d’équipement.
    • (?<prop>...)? capture la variable si présente (optional).
    • La casse du schéma est gérée via l’option IgnoreCase.

    Création pas à pas du connecteur vers BuildingSensorSimulator

    Ce guide détaille chaque étape pour développer proprement son connector. Toutes les méthodes clés sont montrées telles qu’implémentées.

    Cliquez ici pour télécharger le projet ASP.Net final et pret à la compilation.

    Étape 1 : Créer le projet Visual Studio

    1. Ouvrir Visual Studio > Class Library (.NET).
    2. Nommer le projet : MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator.
    3. Cible : .NET 9.0 (aligné avec le Hub).

    Étape 2 : Ajouter la DLL via NuGet

    Au lieu d’un ProjectReference, installe la DLL d’abstraction depuis un package NuGet :

    PM> Install-Package GraphicStream.Immersive.Hub.Abstractions
    # ou en CLI :
    dotnet add package GraphicStream.Immersive.Hub.Abstractions
    

    On obtient ainsi les contrats du Hub (IConnector, ConnectorBase, IHandle, HandleSnapshot, WriteValueResult, etc.).

    Étape 3 : Créer la classe de connecteur

    Créez une classe et nommez-la BuildingSensorSimulatorConnector. Héritez de ConnectorBase pour bénéficier du cycle de vie et de la plomberie. Constructeur et champs principaux :

    public sealed class BuildingSensorSimulatorConnector : ConnectorBase, IDisposable
    {
        private readonly Uri _baseUri;
        private readonly TimeSpan _pollInterval;
        private readonly HttpClient _httpClient;
        private readonly JsonSerializerOptions _jsonOptions;
        private CancellationTokenSource? _internalCts;
        private Task? _runLoop;
        private bool _disposed;
        private readonly System.Threading.Lock _interestGate = new();
        private const string SingleValueProperty = "value";
        private readonly ConcurrentDictionary<string, VariableSnapshot> _pathToLastSnapshot = new();
    
        public BuildingSensorSimulatorConnector()
        {
            this._baseUri = new Uri("https://localhost:7003");
            this._pollInterval = TimeSpan.FromSeconds(2);
            this._httpClient = new HttpClient();
            this._jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
            this.Name = "BuildingSensorSimulatorConnector";
        }
    }
    

    Nous definisons, en dur, le nom à donner au connector, l'adresse de base de la simulation du serveur du Syndic de copropriété et une recherche de valeur pour un thread qui reste à écrire toutes les 2 secondes.

    Étape 4 : Définir le schéma des chemins et la classe Path

    Les handles suivis utilisent le schéma BuildingSensor://{deviceIdentifier}/{VariableName}. Redigeons la classe Path qui va extraire proprement DeviceIdentifier et Property (nom de la variable) depuis un IHandle :

    /// <summary>
    /// <para>BuildingSensor://{deviceIdentifier}/{VariableName}</para>
    /// </summary>
    /// <param name="deviceId">Device identifier.</param>
    /// <param name="property">Property name.</param>
    public class Path(string deviceId, string property)
    {
    
        /// <summary>
        /// Gets or sets the device identifier associated to the current <see cref="Path Instance" />.
        /// </summary>
        public string DeviceIdentifier { get; set; } = deviceId;
    
        /// <summary>
        /// Gets or sets the property name associated to the current <see cref="Path.Property" />.
        /// </summary>
        public string Property { get; set; } = property;
    
        /// <summary>
        /// Extract a formated path from an <see cref="IHandle" /> instance.
        /// </summary>
        /// <param name="h"><see cref="IHandle" /> instance used to gets a formated path.</param>
        /// <returns>A formated path from the specified <see cref="IHandle" /> instance.</returns>
        public static Path FromHandle(IHandle h)
        {
            System.Uri uri = new(h.Path);
            return new(uri.Host, uri.LocalPath.Trim('/'));
        }
    
    

    Étape 5 : Reconnaitre les bons paths

    On s’assure ensuite que le connecteur ne gère que le scheme buildingsensor via OnCanHandle

    Deux possibilités :

    Étape 5.a : Reconnaissance simple et rapide

    Un OnCanHandle simple pourrait être de verifier que path est renseigné, puis qu'il est une url viable et enfin qu'il commence par buildingsensor:// :

    public override bool OnCanHandle(IHandle handle)
    {
        if (string.IsNullOrWhiteSpace(handle.Path)) return false;
        if (!Uri.TryCreate(handle.Path, UriKind.Absolute, out var uri)) return false;
        return uri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase);
    }

    Cette méthode est rapide et simple, mais laisse passer les paths qui ne sont pas forcement formatté pour extraire l'identifiant du device et la variable, ainsi le path suivant serait valide :

    buildingsensor://iam_#not(_a_valide_path)

    Étape 5.b : Reconnaissance précise

    Ici nous pourrions reposer sur une expression régulière, stockée dans la propriété Pattern du IConnector

    Etape 1) Définir le Pattern (par exemple dans le constructeur ou via la configuration du Hub) :

    
    /// <summary>
    /// Initializes a new instance of the <see cref="BuildingSensorSimulatorConnector"/> class.
    /// </summary>
    public BuildingSensorSimulatorConnector()
    {
        this._pathToHandle = [];
        this._baseUri = new Uri("https://localhost:7003");
        this._pollInterval = TimeSpan.FromSeconds(2);
        this._httpClient = new HttpClient();
        this._jsonOptions = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        };
    
        this.Name = "BuildingSensorSimulatorConnector";
        this.Pattern = @"^buildingsensor://(?<device>[A-Za-z0-9_-]+)(?:/(?<prop>[A-Za-z0-9_.:-]+))?$";
    }
    

    Etape 2) Créer ou Remplacer la méthode OnCanHandle par une version qui s’appuie sur l’état et la regexp :

    /// <inheritdoc />
    public override bool OnCanHandle(IHandle handle)
    {
        // 1) Le connecteur doit être connecté pour accepter un handle.
        if (this.Status != Status.Connected)
            return false;
    
        // 2) Path requis.
        var path = handle?.Path;
        if (string.IsNullOrWhiteSpace(path))
            return false;
    
        // 3) S'assurer que Pattern est défini (fallback si oublié dans la config).
        if (string.IsNullOrWhiteSpace(this.Pattern))
            this.Pattern = @"^buildingsensor:\/\/(?<device>[A-Za-z0-9_-]+)(?:\/(?<prop>[A-Za-z0-9_.:-]+))?$";
    
        // 4) Évaluer la regexp (Regexp du Hub) sur le Path.
        return System.Text.RegularExpressions.Regex.IsMatch(
            path,
            this.Pattern,
            System.Text.RegularExpressions.RegexOptions.IgnoreCase |
            System.Text.RegularExpressions.RegexOptions.CultureInvariant
        );
    }

    Étape 6 : Enregistrer les handles demandés

    HandleHandles parse les chemins, valide le schéma, mémorise les couples DeviceId/Property et retourne une réponse d’enregistrement pour chacun :

    /// <inheritdoc/>
    public override List<HandleRegistrationResponse> HandleHandles(IEnumerable<IHandle> handles)
    {
        var responses = new System.Collections.Generic.List<HandleRegistrationResponse>();
    
        // Make a temporary storage of asked id/names extracted from handles's path.
        var temporaryIdToNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
    
        foreach (var h in handles)
        {
            var resp = HandleRegistrationResponse.FromHandle(h);
            try
            {
                // check path.
                if (!Uri.TryCreate(h.Path, UriKind.Absolute, out var uri) ||
                    !uri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase))
                {
                    resp.SupportStatus = SupportStatus.Unknown;
                    resp.Message = "Unsupported scheme (expected BuildingSensor://...)";
                }
                else
                {
                    var p = MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator.Path.FromHandle(h);
                    var deviceIdentifier = p.DeviceIdentifier?.Trim();
                    var propertyName = string.IsNullOrWhiteSpace(p.Property) ? SingleValueProperty : p.Property.Trim();
    
                    if (string.IsNullOrEmpty(deviceIdentifier))
                    {
                        resp.SupportStatus = SupportStatus.Unknown;
                        resp.Message = "Missing device identifier in path.";
                    }
                    else
                    {
                        if (!temporaryIdToNames.TryGetValue(deviceIdentifier, out var set))
                            temporaryIdToNames[deviceIdentifier] = set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
    
                        set.Add(propertyName);
                        resp.SupportStatus = SupportStatus.Known;
                        resp.Message = $"Registered {deviceIdentifier}/{propertyName}.";
                    }
                }
            }
            catch (Exception ex)
            {
                resp.SupportStatus = SupportStatus.Failed;
                resp.Message = $"Registration parsing error: {ex.Message}";
            }
    
            responses.Add(resp);
        }
    
        // merge what was registered here with previoulsy known 
        lock (_interestGate)
        {
            foreach (var kv in temporaryIdToNames)
            {
                if (!_deviceIdentifierToVariableNames.TryGetValue(kv.Key, out var set))
                {
                    set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                    _deviceIdentifierToVariableNames[kv.Key] = set;
                }
    
                foreach (var prop in kv.Value)
                    set.Add(prop); // no double occurences
            }
        }
    
        // Report registration
        Context?.AddStateMessage(LogMessage.Info($"Registered interests: {string.Join(", ", temporaryIdToNames.Select(kv => $"{kv.Key}/{string.Join("|", kv.Value)}"))}"));
    
        return responses;
    }
    

    Pour chaque IHandle reçu nous veillons à renvoyer un HandleRegistrationResponse avec de quoi dire au Hub qui appelle cette fonction si le IHandle a bien été parsé, reconnu et qu'il est viable.'

    Pour faciliter les traitements ulterieurs nous sauvegardons les associations Identifiant de device et noms de variables.

    Étape 7 : Démarrer/arrêter le polling

    OnConnect() lance la boucle ; Stop() et Dispose() arrêtent proprement :

    public override Task<bool> OnConnect()
    {
        RunAsync(CancellationToken.None);
        return Task<bool>.FromResult(true);
    }
    
    public Task RunAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(this._disposed, this);
        if (this._runLoop != null) throw new InvalidOperationException("The connector is already running.");
    
        this._internalCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        this._runLoop = Task.Run(() => RunLoopAsync(_internalCts.Token), CancellationToken.None);
        return this._runLoop;
    }
    
    public void Stop()
    {
        try { _internalCts?.Cancel(); } catch { /* ignore */ }
    }
    
    public override void Dispose()
    {
        if (this._disposed) return;
        this._disposed = true;
        Stop();
        try { _runLoop?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
        this._internalCts?.Dispose();
        this._httpClient.Dispose();
        base.Dispose();
    }
    

    Étape 8 : Boucle de polling & lecture des capteurs

    La boucle appelle périodiquement /api/Devices puis traite le snapshot :

    /// <summary>
    /// Main polling loop: fetches sensors and emits changes/logs.
    /// </summary>
    private async Task RunLoopAsync(CancellationToken ct)
    {
        // Gate partagé (singleton ou service)
        var gate = new DistinctLogGate(reminderInterval: TimeSpan.FromMinutes(1));
        this.AddStateMessage($"Connecting to simulator at {_baseUri}");
    
        while (!ct.IsCancellationRequested)
        {
            try
            {
                gate.TryLogConnectorDistinct(this,"GettingList", LogMessage.Info("Getting list of sensors and device."));
    
                var sensors = await GetSensorsAsync(ct).ConfigureAwait(false);
    
                gate.TryLogConnectorDistinct( this, "ListGetted", LogMessage.Info($"{sensors.Count} getted"));
    
                ProcessSnapshot(sensors);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                // Graceful shutdown.
                break;
            }
            catch (Exception ex)
            {
                this.AddErrorStateMessage($"Polling failed: {ex.Message}", ex.Message);
            }
    
            try
            {
                await Task.Delay(_pollInterval, ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                break;
            }
        }
    
        this.AddStateMessage("Connector stopped.");
    }
    
    
    private async Task<List<DeviceDto>> GetSensorsAsync(CancellationToken ct)
    {
        var uri = new Uri(_baseUri, "/api/Devices");
        using var resp = await _httpClient.GetAsync(uri, ct).ConfigureAwait(false);
        resp.EnsureSuccessStatusCode();
        await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
        var data = await JsonSerializer.DeserializeAsync<List<DeviceDto>>(stream, _jsonOptions, ct).ConfigureAwait(false);
        return data ?? [];
    }
    

    Étape 9 : Processer le snapshot & publier uniquement les changements

    Cette méthode va analyser les devices recupérés ainsi que leurs valeurs et comparer à ce que le connector possède déjà via ses dictionnaires. Si une donnée est considérée comme étant changée elle doit être renvoyée au Hub pour venir les sessions qui sont interessées.

    private void ProcessSnapshot(List<DeviceDto> devices)
    {
        Dictionary<string, HashSet<string>> interests;
        lock (_interestGate)
        {
            interests = _deviceIdentifierToVariableNames.ToDictionary(
                kv => kv.Key,
                kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
                StringComparer.OrdinalIgnoreCase);
        }
        if (interests.Count == 0) return;
    
        foreach (var d in devices)
        {
            var deviceKey = d.Id.ToString(CultureInfo.InvariantCulture);
            if (!interests.TryGetValue(deviceKey, out var wantedProps)) continue;
    
            if (d.Variables is { Count: > 0 })
            {
                foreach (var v in d.Variables)
                {
                    if (!wantedProps.Contains(v.Name)) continue;
                    var path = $"BuildingSensor://{deviceKey}/{v.Name}";
                    var value = v.Value ?? string.Empty;
                    var ts = v.TimestampUtc;
                    PublishIfChanged(path, value, ts, v.Unit, HandleStatus.Good);
                }
            }
            else
            {
                if (!wantedProps.Contains(SingleValueProperty)) continue;
                var path = $"BuildingSensor://{deviceKey}/{SingleValueProperty}";
                var value = d.Value?.ToString(CultureInfo.InvariantCulture) ?? "";
                var ts = d.TimestampUtc;
                var unit = d.Unit;
                PublishIfChanged(path, value, ts!.Value, unit, HandleStatus.Good);
            }
        }
    }
    
    private void PublishIfChanged(string path, string value, DateTime changeDate, string? unit, HandleStatus status)
    {
        var snap = new VariableSnapshot(value, changeDate);
        if (this._pathToLastSnapshot.TryGetValue(path, out var prev))
        {
            if (prev.Value == snap.Value && prev.TimestampUtc == snap.TimestampUtc) return;
        }
        this._pathToLastSnapshot[path] = snap;
    
        HandleSnapshot snapshot = new(
            path,
            value,
            status,
            changeDate,
            DateTime.UtcNow,
            unit,
            null);
        Context?.NotifyHandleChanged(snapshot);
    
        (HandleChanges as SimpleSubject<HandleSnapshot>)!.OnNext(snapshot);
    }
    

    La méthode PublishIfChanged va comparer la dernière valeur connue du path à la valeur récupérée depuis l'appel au projet de simulation.

    Si un changement est détecté, un HandleSnapshot est instancié et la valeur est notifiée au Hub pour le prévenir que le connecteur courant a détecté un changement de valeur ou d'état.'

    Le Hub en interne va déterminer les différentes Sessions qui ont émi un interet pour ce path et leur transmettre l'état/valeur de ce handle.'

    Étape 10 : Écritures → mappage vers les scènes

    La méthode WriteValue permet au Hub de contacter le connector pour lui demander d'écrire une valeur pour le path spécifié. Charge au Hub de contacter sa source de données et d'écrire la donnée à l'endroit ciblé par le Path. :

    /// <inheritdoc/>
    public override async Task<WriteValueResult> WriteValue(string path, string? value)
    {
        // default result.
        var result = new WriteValueResult { IsSuccessFull = false };
    
        try
        {
            // 1) Parse path BuildingSensor://{deviceId}/{property}
            if (!Uri.TryCreate(path, UriKind.Absolute, out var bsUri) ||
                !bsUri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase))
            {
                result.ExceptionMessage = "Unsupported path scheme (expected BuildingSensor://).";
                return result;
            }
    
            var deviceIdentifier = bsUri.Host;                      // ex: "101"
            var propertyName = bsUri.LocalPath.Trim('/');       // ex: "value", "door", "state", ...
    
            if (string.IsNullOrWhiteSpace(deviceIdentifier))
            {
                result.ExceptionMessage = "Missing device identifier in path.";
                return result;
            }
    
            // 2) gets the device to get its Type
            //    GET /api/Devices/{id}
            var deviceUri = new Uri(_baseUri, $"/api/Devices/{deviceIdentifier}");
            using var devResp = await _httpClient.GetAsync(deviceUri).ConfigureAwait(false);
            if (!devResp.IsSuccessStatusCode)
            {
                string message = $"Device {deviceIdentifier} not found (HTTP {(int)devResp.StatusCode}).";
                this.AddErrorStateMessage(message, null);
                result.ExceptionMessage = message;
                return result;
            }
    
            var devStream = await devResp.Content.ReadAsStreamAsync().ConfigureAwait(false);
            var device = await JsonSerializer.DeserializeAsync<DeviceDto>(devStream, _jsonOptions).ConfigureAwait(false);
            if (device is null)
            {
                result.ExceptionMessage = "Failed to deserialize device payload.";
                this.AddErrorStateMessage(result.ExceptionMessage, null);
                return result;
            }
    
            // 3) Compute scene + action
            //    - Type "light"  => SceneName = "Corridor Light", action on/off
            //    - Type "smoke"  => SceneName = "Fire Alarm",     action on/off
            //    - etc can extend here others devices
            string? sceneName = null;
            string? action = null;
    
            // Helper: transform value into bool (on/off)
            static bool IsTruthy(string? s)
            {
                if (string.IsNullOrWhiteSpace(s)) return false;
                s = s.Trim();
                if (bool.TryParse(s, out var b)) return b;
                if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d > 0;
                return s.Equals("on", StringComparison.OrdinalIgnoreCase) ||
                        s.Equals("open", StringComparison.OrdinalIgnoreCase) ||
                        s.Equals("start", StringComparison.OrdinalIgnoreCase);
            }
    
            var truthy = IsTruthy(value);
    
            switch ((device.Type ?? "").ToLowerInvariant())
            {
                case "light":
                    // "Corridor Light"
                    sceneName = "Corridor Light";
                    action = truthy ? "on" : "off";
                    break;
    
                case "smoke":
                    // "Fire Alarm"
                    sceneName = "Fire Alarm";
                    action = truthy ? "on" : "off";
                    break;
    
                default:
                    // Non supporté par le SceneController actuel
                    result.ExceptionMessage = $"No scene mapping for device type '{device.Type}' (path: {path}).";
                    this.AddErrorStateMessage(result.ExceptionMessage, null);
                    return result;
            }
    
            // 4) Appel du SceneController : POST /api/Scene  { SceneName, Action }
            var sceneUri = new Uri(_baseUri, "/api/Scene");
            var payload = new { SceneName = sceneName, Action = action };
            var json = JsonSerializer.Serialize(payload, _jsonOptions);
            using var content = new StringContent(json, Encoding.UTF8, "application/json");
    
            using var resp = await _httpClient.PostAsync(sceneUri, content).ConfigureAwait(false);
            if (!resp.IsSuccessStatusCode)
            {
                var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
                result.ExceptionMessage = $"Scene POST failed HTTP {(int)resp.StatusCode}: {body}";
                this.AddErrorStateMessage(result.ExceptionMessage, null);
                return result;
            }
    
            // 5) Succès
            result.IsSuccessFull = true;
            result.ReturnData = [sceneName, action];
            return result;
        }
        catch (Exception ex)
        {
            result.ExceptionMessage = ex.Message;
            this.AddErrorStateMessage(result.ExceptionMessage, null);
            return result;
        }
    }

    Cette méthode repose sur les possibilités offertes par le serveur REST de simulation pour envoyer un ordre via une requete POST après avoir déterminé le device ciblé par le path fourni dans la signature de la méthode.

    Elle initialise et renseigne une instance de type WriteValueResult pour renvoyer au Hub le résultat de cet écriture.

    Étape 11 : DTOs & structure de données

    Les DTO reflètent la charge utile du simulateur (single-value & multi-variables) :

    private sealed class DeviceDto
    {
        public int Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public string Type { get; set; } = string.Empty;
    
        public string? Value { get; set; }
        public string? Unit { get; set; }
        public DateTime? TimestampUtc { get; set; }
    
        public List<DeviceVariableDto>? Variables { get; set; }
    }
    
    public sealed class DeviceVariableDto
    { 
        public string Name { get; set; } = string.Empty;
        public string Path { get; set; } = string.Empty;
        public string Kind { get; set; } = string.Empty;
        public string? Unit { get; set; }
        public string Value { get; set; } = string.Empty;
        public DateTime TimestampUtc { get; set; }
    }
    

    Téléchargement des sources