Hub : créer son connecteur
- Bien démarrer
Formation
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 :
- Mettre en place un exemple concret en se connectant à une API REST simulant des capteurs de bâtiment
- Créer un connecteur capable de récupérer et d’exposer ces données au Hub.
Pré-requis
- Avoir installé et lancé un Hub
- Avoir la connaissance du role et des fonctionnalités du Hub.
- 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 :
- 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
- 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
- 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.
Le développement d’un connecteur sur-mesure est indispensable afin d’assurer la compatibilité entre le protocole IoT du client et la plateforme 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 :
- 📦 BuildingSensorSimulator — v1.0 • 1.8 MB • ZIP
É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"
}
Le connecteur Immersive à développer devra interroger /api/Devices (polling ou webhook si adapté), mapper chaque équipement et/ou variable à un élément du jumeau (zone, modèle, etc.), puis mettre à jour les valeurs affichées et les états (ascenseur, porte de garage…). Les scènes exposées par /api/Scene permettent de tester la chaîne bout-à-bout.
Le projet est fourni en Visual Studio : lancez-le puis consommez directement les endpoints ci-dessus depuis votre connecteur. Vous pourrez publier l’archive en téléchargement dans ce tutoriel.
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
IConnectorpour un contrôle total (vous codez tout le cycle de vie et les interactions). - Faire hériter la classe de votre connecteur custom de
ConnectorBasepour 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.
En résumé : installez la DLL GraphicStream.Immersive.Hub.Abstractions, puis soit vous implémentez IConnector, soit vous dérivez de ConnectorBase pour bénéficier d’une base robuste et standardisée.
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.).
Dans Immersive, le IHubContext est injecté en tant que service via le conteneur d’injection de dépendances d’ASP.NET Core.
Cela signifie que vous n’avez pas besoin de l’instancier vous-même : le moteur l’injecte automatiquement dans tout service ou acteur qui est enregistré et géré par le pipeline ASP.NET Core.
Concrètement, si vous déclarez une dépendance de type IHubContext dans le constructeur d’un service ou d’un composant (par exemple un BackgroundService, un Controller, un HostedService ou tout autre type ajouté dans le conteneur), l’instance active du Hub vous sera fournie automatiquement.
⚠️ Attention : ajouter IHubContext dans le constructeur d’une classe arbitraire non gérée par ASP.NET Core ne fonctionnera pas — l’injection ne se fait que pour les types enregistrés dans le conteneur du processus.
- 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.
- État courant du connecteur (enum). Valeurs typiques :
- 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
truesi la connexion est réussie ; met à jourStatusen conséquence.
- Disconnect() : Task<bool>
- Fermeture propre de la connexion (désabonnement, flush, dispose ressources).
- Retourne
truesi 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.
- Enregistre une liste de handles demandés par le Hub (chemins à surveiller/publier) sur le connector a validé via
- WriteValue(string path, string? value) : Task<WriteValueResult>
- Écriture (sortante) d’une valeur vers la source sur un chemin donné (handle cible).
- Retourne un
WriteValueResultindiquant le succès/échec et, selon le type, des détails complémentaires.
Pour notifier le Hub d’un changement de valeur, le connecteur crée une instance de HandleSnapshot et appelle Context.NotifyHandleChanged(...). Pour les abonnements, utilisez Context.RegisterForChange(session, handleRequests). Pensez aussi à enrichir StateMessages via Context.AddStateMessage(...) pour une observabilité claire.
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()etOnDisconnect(): 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é surPattern).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.
En pratique : vous dérivez de ConnectorBase, vous configurez vos propriétés
(Name, Description, Pattern, ConnectionString…),
et vous surchagez uniquement les méthodes virtuelles correspondant à votre protocole.
Toute la gestion du cycle de vie (statut, erreurs, messages d’état) est déjà assurée par la base.
En pratique : définissez ConnectionString et Pattern, implémentez la vraie connexion vers la source ciblée par le connector dans OnConnect(), filtrez ce que vous supportez dans CanHandle(), vous pouvez reposer pour celà sur le champ Pattern, enregistrez vos chemins via HandleHandles(), publiez les changements avec Context.NotifyHandleChanged(...) et implémentez les écritures via WriteValue(...). Status doit refléter précisément l’état de santé du connecteur.
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…).
Exemple de chemins valides :
BuildingSensor://101/floor: Path vers la variable floor de l'ascenseur d'identifiant 101.BuildingSensor://201/door: Path vers la variable door de la porte de Garage G1 d'identifiant 201.BuildingSensor://3ouBuildingSensor://3/Value: Path vers la valeur Value en Lux de la luminosité de la lampe du couloir.
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
OnCanHandlepour 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.
- 🔌 Connector (BuildingSensor) — v1.0 • 8 KB • ZIP
Étape 1 : Créer le projet Visual Studio
- Ouvrir Visual Studio > Class Library (.NET).
- Nommer le projet :
MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator. - 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
);
}
Résultat : seul un chemin au format BuildingSensor://{device}/{prop?} sera accepté et uniquement lorsque le connecteur est en état Connected. Vous évitez ainsi toute collision avec d’autres connecteurs, tout en tirant parti du comportement standard du Hub (pattern + statut).
É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.
Notez l'utilisation des logs à la fin de HandleHandles :
// Report registration
Context?.AddStateMessage(LogMessage.Info($"Registered interests: {string.Join(", ", temporaryIdToNames.Select(kv => $"{kv.Key}/{string.Join("|", kv.Value)}"))}"));
Nous accédons au contexte du Hub pour en appeler la fonction AddStateMessage. De cette façon nous pouvons plus facilement suivre les activités du Connector créé et les problèmes qu'il peut rencontrer.
É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 ?? [];
}
Notez l'utilisation de logs intelligents :
gate.TryLogConnectorDistinct(this,"GettingList", LogMessage.Info("Getting list of sensors and device."));
gate est un objet de type DistinctLogGate instancié juste au-dessus (préférez au final créer des instance global dans vos projets).
gate va analyser les logs emis par le Connector courant et plutot que de spammer l'utilisateurs avec des logs répétitifs il ne va emettre le log lié uniquement passé un laps de temps déterminé (ici 1 minute) ou si une donnée du LogMessage a évolué depuis la dernière tentative de log inhibée.
É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
- 📦 BuildingSensorSimulator — v1.0 • 1.8 MB • ZIP
- 🔌 Connector (BuildingSensor) — v1.0 • 8 KB • ZIP