Canal de simulation
Vue d'ensemble
Le canal de simulation est un canal WebSocket bidirectionnel à haute fréquence, utilisé pour échanger l'état des appareils et envoyer des commandes de session ou d'appareil.
URL par défaut : ws://localhost:10001
Le port peut être modifié dans la configuration.
Contrat de base (important) :
- Lorsqu'un client se connecte, le service envoie un message d'inventaire initial contenant la liste complète des appareils.
- Ensuite, le serveur envoie exactement un message de mise à jour d'état pour chaque message reçu du client.
- Chaque mise à jour d'état contient l'état (et le statut) de tous les appareils.
Cette page documente le protocole et les commandes de base du canal de simulation.
Des modules ou fonctionnalités supplémentaires peuvent enregistrer et étendre le système avec leurs propres commandes et/ou champs d'état. Ceux-ci sont documentés séparément dans la section modules.
Règles du protocole
Une réponse par message du client
Le service émet un message de mise à jour d'état contenant l'état de tous les appareils, pour chaque message reçu du client.
Cela signifie que :
- Si vous avez besoin d'un instantané récent de l'état des appareils, vous devez envoyer quelque chose (une commande de session, une commande de sondage ou une commande d'appareil).
- Le canal fonctionne comme une boucle cadencée (« tick ») par les messages du client.
Interrogation sans application de forces (commandes de sondage)
Si vous voulez observer les changements d'état sans appliquer de forces ni modifier les paramètres de simulation, utilisez les commandes de sondage :
probe_positionpourinverse3probe_orientationpour les Verse grips
Les commandes de sondage ne contiennent aucune donnée de commande ; elles forcent simplement l'interrogation des informations de l'appareil, de sorte que les données de position et d'orientation sont à jour lors de la prochaine mise à jour d'état.
Les sessions de simulation qui envoient déjà des commandes de contrôle (set_cursor_force, set_cursor_position, etc.) n'ont pas besoin d'envoyer de commandes de sondage -- l'état de l'appareil est toujours inclus dans la trame de réponse. Les commandes de sondage sont destinées aux sessions de surveillance seulement (p. ex. Haply Hub) qui doivent interroger l'état de l'appareil sans le piloter.
Configuration et état
- Le message d'inventaire initial inclut les champs
config,stateetstatusde l'appareil. - Les messages de mise à jour d'état réguliers incluent les champs
stateetstatusde l'appareil.
Si vous avez besoin à nouveau d'un instantané incluant la configuration, utilisez session.force_render_full_state.
configure et commands
Les messages d'appareil prennent en charge deux dictionnaires distincts : configure et commands. Ils ont des rôles différents :
| Dictionnaire | Rôle | Fréquence |
|---|---|---|
configure | Configuration persistante ponctuelle : préréglage, base, montage, filtres, paramètres des modules | Envoi unique ou en cas de changement |
commands | Par cycle, non persistant : force, position, couples | Doit être envoyé à chaque cycle |
Les entrées de commands (set_cursor_force, set_cursor_position, set_angular_torques, set_angular_position) sont appliquées une fois par cycle et ne sont pas stockées -- si vous cessez de les envoyer, l'effet cesse immédiatement et l'appareil revient à l'état de repos. Utilisez configure pour tout ce qui doit persister d'un cycle à l'autre sans être renvoyé.
set_transform fait exceptionset_transform se trouve sous commands, mais il est persistant -- le service conserve la dernière transformation envoyée jusqu'à ce que vous en envoyiez une nouvelle. Vous n'êtes pas obligé de l'envoyer à chaque cycle. Cela dit, comme sa fonction est la navigation de la caméra ou de la scène, le diffuser à la cadence des cycles (par exemple une transformation par image lorsque l'utilisateur déplace la scène) est le mode d'utilisation attendu pour une navigation à mouvement continu.
Systèmes de coordonnées
Haply utilise par défaut un système de coordonnées droitier avec Z vers le haut.
Deux entrées de configuration influencent la façon dont les coordonnées sont interprétées et retournées :
session.configure.basis: modifie la correspondance entre les coordonnées de Haply et le système de coordonnées de votre application (voir Base ci-dessous).inverse3[*].configure.preset: sélectionne une configuration d'usine nommée qui place l'origine de l'espace de travail (p. ex.arm_front_centeredplace(0, 0, 0)au centre de l'espace de travail). Voir la section Configuration propre à chaque appareil.
Formats des messages
Cette section décrit l'enveloppe générale et les types de messages de haut niveau. Des exemples complets sont fournis plus loin dans ce document.
Groupes d'appareils
Les messages sont regroupés par type d'appareil au premier niveau (p. ex. inverse3, verse_grip, wireless_verse_grip). Chaque clé de type d'appareil correspond à un tableau d'objets, un par appareil.
Inventaire initial (serveur → client)
Envoyé une seule fois, immédiatement après l'établissement d'une connexion WebSocket.
Chaque entrée comprend :
device_idconfigstatestatus
Voir : Exemple : charge utile de l'inventaire initial
Mise à jour d'état (serveur → client)
Envoyée une fois pour chaque message reçu du client.
Chaque entrée comprend :
device_idstatestatus
Voir : Exemple : charge utile de mise à jour d'état
Enveloppe des commandes de session (client → serveur)
Les commandes de session sont des actions qui s'appliquent à la connexion ou à la session en cours et qui ne sont pas propres à un appareil.
{
"session": {
"<command_name>": {
"...": "..."
}
}
}
Enveloppe des commandes d'appareil (client → serveur)
Les commandes d'appareil sont envoyées sous la clé du type d'appareil, sous forme de tableau, ce qui permet d'envoyer des commandes à un ou plusieurs appareils dans un même message. Chaque entrée d'appareil prend en charge à la fois un dictionnaire configure et un dictionnaire commands.
{
"<device_type>": [
{
"device_id": "<id>",
"configure": {
"<config_key>": { "...": "..." }
},
"commands": {
"<command_name>": { "...": "..." }
}
}
]
}
Vous pouvez inclure plusieurs entrées pour commander plusieurs appareils du même type dans un seul message.
Notez que commands est un dictionnaire pouvant contenir plusieurs commandes pour un même appareil, mais une seule par type de commande.
Le champ execute
Chaque entrée de commande ou de configuration accepte un booléen "execute" facultatif (par défaut true). Définissez "execute": false pour que l'entrée soit analysée sans être appliquée. Cela est utile pour les sérialiseurs fondés sur la réflexion (p. ex. Unity JsonUtility), qui émettent toujours tous les champs.
"set_cursor_force": { "execute": false, "vector": { "x": 0.0, "y": 0.0, "z": 0.0 } }
Référence des commandes
Session
Forcer le rendu complet de l'état
Demande un instantané de tous les états et configurations des appareils.
{
"session": {
"force_render_full_state": {}
}
}
Configuration de session
La configuration persistante au niveau de la session est envoyée via session.configure. Elle reprend le même principe que le dictionnaire configure au niveau de l'appareil.
Profil
Définit le nom du profil de session, utilisé par Haply Hub pour reconnaître la simulation et conserver les ajustements d'appareil propres à chaque application.
{
"session": {
"configure": {
"profile": {
"name": "my_profile"
}
}
}
}
Base (niveau session)
Définit la correspondance de base pour l'ensemble de la session. Cette correspondance décrit comment les coordonnées de Haply sont transformées vers le système de coordonnées de votre application ; une fois définie, tous les états des appareils sont retournés dans cette base et toutes les valeurs que vous envoyez y sont interprétées.
La chaîne de permutation s'exprime par rapport au système de coordonnées droitier, avec Z vers le haut, de Haply : une permutation de X, Y, Z, éventuellement précédée de + ou de -. Exemples :
XYZ,ZYX,+Y-Z+X,X-ZYYZXsignifie que votreYcorrespond à la droite de Haply, votreZà l'avant de Haply et votreXau haut de Haply.
Exemple de système gaucher avec Z vers le haut (Unreal, X-YZ) :
{
"session": {
"configure": {
"basis": { "permutation": "X-YZ" }
}
}
}
session.set_basisLa convention de signe des axes a changé entre l'ancienne commande session.set_basis et session.configure.basis. Une permutation qui fonctionnait avec l'ancienne commande peut donner une transformation inversée avec la nouvelle -- inversez alors les signes des axes au besoin (p. ex. X-ZY devient XZ-Y).
Commandes de tous les appareils
Sondage (tous les appareils)
Utilisez les commandes de sondage pour demander des informations de position et d'orientation à jour, sans appliquer de forces ni d'autres modifications à la simulation.
- inverse3 :
probe_position - Verse grips (
verse_grip,wireless_verse_grip) :probe_orientation
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"probe_position": {}
}
}
],
"verse_grip": [
{
"device_id": "049D",
"commands": {
"probe_orientation": {}
}
}
],
"wireless_verse_grip": [
{
"device_id": "049D",
"commands": {
"probe_orientation": {}
}
}
]
}
Définir la transformation (tous les appareils)
Définit la transformation de l'espace de travail (de l'espace de l'appareil vers l'espace de l'application) pour un appareil.
Contrairement aux autres entrées de commands, set_transform est persistant -- le service conserve la dernière valeur envoyée jusqu'à ce que vous en envoyiez une nouvelle. Il n'est pas nécessaire de l'envoyer à chaque cycle ; comme sa fonction est la navigation de la caméra ou de la scène, le diffuser à la cadence des cycles est toutefois le mode d'utilisation attendu pour un mouvement continu (p. ex. une transformation par image pendant que l'utilisateur déplace la scène).
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_transform": {
"transform": {
"position": { "x": 0.0, "y": 0.0, "z": 0.0 },
"rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 },
"scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
}
}
}
}
]
}
Pour les appareils Verse grip et Wireless Verse grip, seule la composante rotation de la transformation a un effet -- la poignée ne transmet que l'orientation, il n'y a donc rien à mettre à l'échelle ni à déplacer pour position ou scale. Les champs position et scale sont acceptés (et renvoyés dans l'instantané) uniquement pour assurer la cohérence du schéma avec Inverse3 ; les laisser à leurs valeurs identité (position = {0,0,0}, scale = {1,1,1}) est la pratique standard.
Configuration de l'appareil Inverse3
Les entrées de configuration sont envoyées dans le dictionnaire configure. Elles sont persistantes -- envoyez-les une fois ou en cas de changement.
Préréglage
{
"inverse3": [
{
"device_id": "049D",
"configure": {
"preset": { "preset": "arm_front_centered" }
}
}
]
}
Valeurs disponibles : device_defaults, arm_front, arm_front_centered, led_front, led_front_centered, custom.
Base (niveau appareil)
{
"inverse3": [
{
"device_id": "049D",
"configure": {
"basis": { "permutation": "ZXY" }
}
}
]
}
Montage
Définit la transformation de montage de l'appareil. Notez l'enveloppe transform à l'intérieur de mount.
{
"inverse3": [
{
"device_id": "049D",
"configure": {
"mount": {
"transform": {
"position": { "x": 0.0, "y": 0.0, "z": 0.0 },
"rotation": { "x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0 },
"scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
}
}
}
}
]
}
Le côté commande (client vers serveur) enveloppe la transformation : "mount": { "transform": { ... } }.
Le côté instantané (serveur vers client) est à plat : "mount": { "position": {...}, "rotation": {...}, "scale": {...} }.
Ce choix est intentionnel -- les commandes utilisent une enveloppe unifiée, tandis que les instantanés sérialisent directement la transformation. Veillez à ne pas copier la structure de l'instantané dans une charge utile de commande.
Amortissement
Contrôle l'amortissement uniforme et/ou directionnel. Au moins un champ doit être présent.
{
"inverse3": [
{
"device_id": "049D",
"configure": {
"damping": { "scalar": 0.5 }
}
}
]
}
Vous pouvez aussi définir un amortissement directionnel, ou les deux à la fois :
"damping": { "scalar": 0.5, "vector": { "x": 0.0, "y": 1.0, "z": 0.0 } }
Porte de force
Définit le gain d'atténuation de la porte de force (0.0 à 1.0).
{
"inverse3": [
{
"device_id": "049D",
"configure": {
"force_gate": { "gain": 0.3 }
}
}
]
}
Commandes Inverse3
Pour envoyer des commandes à un appareil inverse3, incluez une entrée avec un device_id correspondant sous la clé inverse3. Les commandes sont envoyées à chaque cycle et doivent être renvoyées à chaque cycle pour rester actives.
Commander un seul appareil :
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_cursor_force": {
"vector": {
"x": 1.0,
"y": 2.0,
"z": 3.0
}
}
}
}
]
}
Commander plusieurs appareils dans un même message :
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_cursor_force": {
"vector": {
"x": 1.0,
"y": 2.0,
"z": 3.0
}
}
}
},
{
"device_id": "049E",
"commands": {
"set_cursor_force": {
"vector": {
"x": 1.0,
"y": 2.0,
"z": 3.0
}
}
}
}
]
}
Définir la position du curseur
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_cursor_position": {
"position": {
"x": 1.0,
"y": 2.0,
"z": 3.0
}
}
}
}
]
}
Définir la force du curseur
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_cursor_force": {
"vector": {
"x": 1.0,
"y": 2.0,
"z": 3.0
}
}
}
}
]
}
Définir la position angulaire
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_angular_position": {
"angles": {
"a0": 1.0,
"a1": 2.0,
"a2": 3.0
}
}
}
}
]
}
Définir les couples angulaires
{
"inverse3": [
{
"device_id": "049D",
"commands": {
"set_angular_torques": {
"torques": {
"a0": 1.0,
"a1": 2.0,
"a2": 3.0
}
}
}
}
]
}
Verse Grip : configure
Les appareils Verse Grip et Wireless Verse Grip prennent en charge les mêmes clés configure que Inverse3 pour preset, basis et mount.
Verse Grip étendu
La commande set_extension_data fait partie du protocole étendu des Verse Grip, utilisé avec les versions de poignée qui implémentent le protocole de communication d'extension de carte.
Définir les données d'extension de la poignée
Longueurs de données prises en charge :
- Jusqu'à 20 octets en amont (client → appareil).
- Jusqu'à 12 octets en aval (appareil → client), renvoyés dans le message State Update sous la forme
state.extension_data.
Spécification des données :
- Longueur du tableau : 20 octets
- Plage de valeurs : chaque valeur est de 0 à 255
{
"wireless_verse_grip": [
{
"device_id": "049D",
"commands": {
"set_extension_data": {
"extension_data": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
}
}
}
]
}
Exemples
Exemple : charge utile d'inventaire initial
Le service envoie un message contenant la liste complète des appareils lorsqu'un WebSocket est connecté. Le message initial respecte le format JSON suivant :
{
"inverse3": [
{
"device_id": "04BA",
"config": {
"type": "inverse3",
"port": "COM13",
"device_info": {
"major_version": 7,
"minor_version": 1,
"id": "04BA",
"device_type": 4,
"uuid": "2D35F80DD9005F599B68F49944CB04BA"
},
"extended_device_id": "2D35F80DD9005F599B68F49944CB04BA",
"extended_firmware_version": "8C20FDC8010AA1E15AA133CDA2534874",
"gravity_compensation": {
"enabled": true,
"scaling_factor": 1
},
"handedness": "right",
"streaming_mode": "USB",
"torque_scaling": {
"enabled": true
},
"home_return": {
"enabled": false
},
"filters": {
"force_gate": { "gain": 0.3 },
"damping": { "scalar": 0 }
},
"preset": "defaults",
"basis": { "permutation": "XYZ" },
"mount": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
}
},
"state": {
"cursor_position": {
"x": 0.07842738,
"y": -0.14836666,
"z": 0.14297646
},
"cursor_velocity": {
"x": -0.011969013,
"y": 0.0012009288,
"z": -0.043197
},
"angular_position": {
"x": -69.31704,
"y": 137.62952,
"z": 19.832787
},
"angular_velocity": {
"x": 0,
"y": 0,
"z": 0
},
"body_orientation": {
"x": -0.01940918,
"y": 0.7026367,
"z": 0.00048828125,
"w": 0.7113037
},
"current_cursor_force": { "x": 0, "y": 0, "z": 0 },
"current_cursor_position": { "x": 0, "y": 0, "z": 0 },
"current_angular_torques": { "x": 0, "y": 0, "z": 0 },
"current_angular_position": { "x": 0, "y": 0, "z": 0 },
"control_domain": "cartesian",
"control_mode": "force",
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"calibrated": false,
"in_use": false,
"power_supply": true,
"ready": true,
"started": true
}
}
],
"verse_grip": [
{
"device_id": "61548",
"config": {
"type": "verse_grip",
"port": "COM3",
"preset": "device_defaults",
"basis": { "permutation": "XYZ" },
"mount": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
}
},
"state": {
"button": false,
"hall": 0,
"orientation": {
"x": -0.5019531,
"y": 0.8632202,
"z": -0.048095703,
"w": -0.022338867
},
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"error": 0,
"ready": true
}
}
],
"wireless_verse_grip": [
{
"device_id": "0",
"config": {
"type": "wireless_verse_grip",
"port": "COM6",
"major_version": 1,
"minor_version": 4,
"hardware_version": 1,
"streaming_mode": "Radio",
"preset": "device_defaults",
"basis": { "permutation": "XYZ" },
"mount": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
}
},
"state": {
"battery_level": 0.816,
"battery_voltage": 3.77,
"buttons": {
"a": false,
"b": false,
"c": false
},
"hall": 16,
"orientation": {
"x": -0.019866943,
"y": -0.017486572,
"z": 0.05508423,
"w": -0.9963989
},
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"connected": true,
"awake": true,
"ready": true
}
}
]
}
Exemple : charge utile State Update
Le service enverra un message de mise à jour d'état contenant l'état de tous les appareils pour chaque message reçu.
Si vous souhaitez connaître l'état de la machine, vous devez lui envoyer un message au préalable (par exemple une commande de sondage ou une valeur de force, même si les valeurs sont nulles). Ceci est particulièrement important lorsque vous utilisez des appareils comme sources d'entrée (par exemple pour le suivi de position) sans appliquer de forces.
Le message State Update respecte le format JSON suivant :
{
"inverse3": [
{
"device_id": "04BA",
"state": {
"cursor_position": {
"x": 0.07842738,
"y": -0.14836666,
"z": 0.14297646
},
"cursor_velocity": {
"x": -0.011969013,
"y": 0.0012009288,
"z": -0.043197
},
"angular_position": {
"x": -69.31704,
"y": 137.62952,
"z": 19.832787
},
"angular_velocity": {
"x": 0,
"y": 0,
"z": 0
},
"body_orientation": {
"x": -0.01940918,
"y": 0.7026367,
"z": 0.00048828125,
"w": 0.7113037
},
"current_cursor_force": { "x": 0, "y": 0, "z": 0 },
"current_cursor_position": { "x": 0, "y": 0, "z": 0 },
"current_angular_torques": { "x": 0, "y": 0, "z": 0 },
"current_angular_position": { "x": 0, "y": 0, "z": 0 },
"control_domain": "cartesian",
"control_mode": "force",
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"calibrated": false,
"in_use": false,
"power_supply": true,
"ready": true,
"started": true
}
}
],
"verse_grip": [
{
"device_id": "61548",
"state": {
"button": false,
"hall": 0,
"orientation": {
"x": -0.5019531,
"y": 0.8632202,
"z": -0.048095703,
"w": -0.022338867
},
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"error": 0,
"ready": true
}
}
],
"wireless_verse_grip": [
{
"device_id": "0",
"state": {
"battery_level": 0.816,
"battery_voltage": 3.77,
"buttons": {
"a": false,
"b": false,
"c": false
},
"hall": 16,
"orientation": {
"x": -0.019866943,
"y": -0.017486572,
"z": 0.05508423,
"w": -0.9963989
},
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"connected": true,
"awake": true,
"ready": true
}
}
],
"custom_verse_grip": [
{
"device_id": "0",
"state": {
"battery_level": 0.816,
"battery_voltage": 3.77,
"buttons": {
"a": false,
"b": false,
"c": false
},
"hall": 16,
"orientation": {
"x": -0.019866943,
"y": -0.017486572,
"z": 0.05508423,
"w": -0.9963989
},
"extension_data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"transform_velocity": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
"scale": { "x": 0, "y": 0, "z": 0 }
}
},
"status": {
"ready": true
}
}
]
}