mercredi 2 novembre 2022

Tensiometre Bluetooth sur Linux

Suite à une visite médicale, on m'a conseillé de suivre ma tension... J'avais bien un tensiomètre de poignet qui traînait dans un tiroir depuis des années mais il paraît que c'est mieux d'en utiliser un avec un brassard... Petit tour dans une grande surface de trucs électroniques, je repère un Medisana BU-546 Connect (542~546; ~50.00 euros) qui a une connexion Bluetooth. Petit tour sur Internet. Cela n'a pas l'air trop mauvais, c'est une société allemande de l'ouest qui semble avoir bonne réputation. Rien sur Linux en lien avec le bidule, ça va être l'occasion de découvrir Bluetooth et d'expérimenter. Bref, c'est irrésistible...

Le tensiomètre fontionne avec une application (gratuite) pour smartphone, Vitadock+ ...qui demande d'activer la géolocalisation et envoie toutes les données (somme toute assez confidentielles) dans le cloud. Ça, ce n'est pas une bonne idée. En fait, c'est un peu scandaleux, c'est pire que le driver d'imprimante dont Richard Stallman voulait les sources. Pour dispositif médical, je dois pouvoir contrôler l'utilisation des données.

D'abord, découvrir Bluetooth sous Linux. Les outils et le b-a-ba de la communication. La première idée était de trouver un autre gadget compatible Linux et d'observer son fonctionnement. En fait, je pouvais déjà commencer à essayer de communiquer entre mon laptop et un Raspberry Pi. Mettons que je prenne le RPi comme 'device' (serveur) :
$ sudo btmgmt le on              # pour activer le Low Energy
$ sudo hciconfig hci0 piscan     # pour se laisser scanner (?)
$ bluetoothctl                   # pour le rendre découvrable et activer l'annonce (advertising)
> help
> discoverable on
> advertise on
> show
Du côté du laptop (client), je peux utiliser gatttool(1) pour me connecter et interroger le Rpi :
$ gatttool -I
> connect 11:22:33:44:55:66
> help
> primary
> characteristics
> char-desc
> char-read-hdn 0x0001
> char-read-uuid 0x2a01
...
C'est très bien. Maintenant, il faut essayer de faire la même chose avec le tensiomètre... D'abord, connaître sa mac-address. Ce n'est évidemment marqué nulle part. Après une mesure de la tension, il active le Bluetooth et s'annonce (advertising) pendant une minute. En principe, je devrais pouvoir le détecter avec hcitool(1)
$ sudo hcitool lescan
Mais pour une raison qui m'échappe, le lescan me renvoie un tas de mac-addresses dont quelques unes seulement sont associées à un nom d'appareil (la TV des voisins, une voiture qui passe, mon smartphone si j'active le Bluetooth,...) mais rien qui ressemble à Medisana ou Transtek, la société chinoise qui fabrique le bidule. Pas plus que le manufacturer-id du constructeur chinois (Medisana n'en a pas). En utilisant le smartphone (Samsung A40), les parasites sont absents et un BS1490 apparaît mystérieusement à la fin d'une mesure de tension. C'était donc ça! Maclookup m'apprend que c'est une 'Localy Administred Address' (adresse aléatoire utilisée par discrétion pour éviter le repérage). Et, de fait, on retrouve TMB-190-BS dans le manuel du tensiomètre et une recherche Internet mène à la page du constructeur chinois. En plus, il y a moyen de trouver des documents concernant la demande d'autorisation au FCC américain.

Avec la MAC-address, je peux maintenant me connecter sur le tensiomètre et lire différentes choses mais rien ne semble correspondre à une tension. Tout est quasi constant d'une mesure à l'autre. La seule chose qui varie (une horloge?) varie même à l'intérieur d'une seule session. Là, ça va être difficile... Si il faut écrire quelque chose quelque part et lire le résultat ailleurs, il va falloir capturer ue session entre le tensiomètre et l'application du smartphone. Sniffer une communication Bluetooth n'est pas aussi simple que de capturer du traffic Ethernet avec tcpdump(8). Bluetooth fait du saut de fréquence (frequency hopping) et il est difficile de capturer toute la bande ISM 2.4 GHz. Heureusement, il existe un bidule génial de Great Scott Gadgets dont Michael Ossmann décrit la genèse dans une conférence au Shmoocon de 2011 le Ubertooth One. Radin, comme d'habitude, je commence par commander un clone chinois qui se révélera fort capricieux. Je fais des tests entre le laptop et le Raspberry Pi et cela ne fonctionne correctement que très rarement. Un forum finit par me convaincre que cela fonctionne mieux avec la version officielle. Au diable l'avarice, j'en commande un vrai (aux Pays-Bas) que je reçois deux jours plus tard. Et cela fonctionne du premier coup entre le tensiomètre et le laptop (j'utilise l'Ubertooth One sur le Raspberry Pi pour avoir une version plus récente des outils (sur le laptop, je suis toujours en Ubuntu 18.04...). J'ai juste dû faire une mise à jour avec ubertooth-dfu(1) du dernier firmware. Quelques messages d'erreurs, mais cela fonctionne.

$ sudo ubertooth-btle -f -t 11:22:33:44:55:66 -r tensio.pcapng
Et là, surprise! Wireshark(1) décode les packets contenant la tension.
On peut aussi utiliser tshark(1) pour produire un fichier JSON que l'on peut imprimer, traiter avec grep(1), jq(1),... :
$ tshark -r tensio.pcapng -T json > tensio.json
À noter que le fichier JSON est un peu foireux, il n'y a pas de racine unique, il faut encadrer le contenu par quelque chose comme '{ "results": [' et ']}' pour pouvoir utiliser jq(1) et, dans jq, il faut utiliser des '"' (double quotes) parce qu'il y a des '.' dans les identifiants. Par exemple,
$ cat medisana.json |jq '.results[]._source.layers.frame."frame.protocols"' |less
$ cat medisana.json |jq '.results[]._source.layers.btatt."btatt.blood_pressure_measurement.compound_value.systolic.mmhg"' |less
Mais sinon, par exemple, dans une indication contenant une mesure, on peut lire:
        "btatt": {
          "btatt.opcode": "0x0000001d",
          "btatt.opcode_tree": {
            "btatt.opcode.authentication_signature": "0",
            "btatt.opcode.command": "0",
            "btatt.opcode.method": "0x0000001d"
          },
          "btatt.handle": "0x0000000b",
          "btatt.handle_tree": {
            "btatt.service_uuid16": "6160",
            "btatt.uuid16": "10805"
          },
          "btatt.blood_pressure_measurement.flags": "0x0000001e",
          "btatt.blood_pressure_measurement.flags_tree": {
            "btatt.blood_pressure_measurement.flags.reserved": "0x00000000",
            "btatt.blood_pressure_measurement.flags.measurement_status": "1",
            "btatt.blood_pressure_measurement.flags.user_id": "1",
            "btatt.blood_pressure_measurement.flags.pulse_rate": "1",
            "btatt.blood_pressure_measurement.flags.timestamp": "1",
            "btatt.blood_pressure_measurement.flags.unit": "0x00000000"
          },
          "btatt.blood_pressure_measurement.compound_value.systolic.mmhg": "120",
          "btatt.blood_pressure_measurement.compound_value.diastolic.mmhg": "62",
          "btatt.blood_pressure_measurement.compound_value.arterial_pressure.mmhg": "91",
          "btatt.blood_pressure_measurement.compound_value.timestamp": {
            "btatt.year": "2022",
            "btatt.month": "10",
            "btatt.day": "27",
            "btatt.hours": "17",
            "btatt.minutes": "52",
            "btatt.seconds": "0"
          },
          "btatt.blood_pressure_measurement.pulse_rate": "58",
          "btatt.blood_pressure_measurement.user_id": "0x00000001",
          "btatt.blood_pressure_measurement.status": "0x00000000",
          "btatt.blood_pressure_measurement.status_tree": {
            "btatt.blood_pressure_measurement.status.reserved": "0x00000000",
            "btatt.blood_pressure_measurement.status.improper_measurement_position": "0",
            "btatt.blood_pressure_measurement.status.pulse_rate_range_detection": "0x00000000",
            "btatt.blood_pressure_measurement.status.irregular_pulse": "0",
            "btatt.blood_pressure_measurement.status.cuff_fit_too_loose": "0",
            "btatt.blood_pressure_measurement.status.body_movement": "0"
          }
        }
Ce qui est curieux, c'est que tout ce décodage semble être fait par du code ad-hoc dans packet-btatt.c Il n'y aurait donc pas de définitions formelles comme les MIBs du SNMP. Et parmi les UUID-16, on trouve 0x2A35 pour la notification d'une caractéristique concernant la pression sanguine... (voir aussi Bluetooth Assigned numbers - HealthDeviceProfiles.pdf) Le tensiomètre répond donc bien à un standard : Blood-pressure-profile-1-1-1. Ce n'est pas très compréhensible. C'est comme le bouquin 'Bluetooth Low Energy: The Developer's Handbook' de Robin Heydon. C'est intéressant, très détaillé mais pas très pratique. Il faut le lire plusieurs fois, expérimenter avec les outils Linux et Wireshark, parcourir des blogs et des forums pour finir par avoir une idée de quoi il est question. Et, il n'y a, curieusement pas de références (ou de guide pour s'orienter dans la documention étendue de bluetooth.com)

Ainsi, avec Wireshark (en éliminant les packets sans intérêt avec le filtre 'btle.data_header.length > 0 || btle.advertising_header.pdu_type == 0x05', après une découverte du tensiomètre par l'application Vitadock+, elle active les 'indications' en écrivant 0200 dans un canal particulier. Les mesures non encore transmises sont alors envoyées une par une, avec un timestamp comme 'indication'. On peut reproduire le mécanisme avec gatttool(1) On reçoit alors des listes de 19 caractères en hexadécimal contenant la pression systolique, diatolique, la pression, le temps de la mesure (année, mois, jour, heure, minute, seconde) et le nombre de pulsations par minute) qu'il est facile de décoder en regardant la correspondance sur Wireshark(1).
$ gatttool -b 11:22:33:44:55:66 -t random -I
[11:22:33:44:55:66][LE]> connect
Attempting to connect to 11:22:33:44:55:66
Connection successful
[11:22:33:44:55:66][LE]> char-write-req 0x000c 0200
Characteristic value was written successfully
Indication   handle = 0x000b value: 1e 71 00 42 00 59 00 e6 07 0a 1c 0b 2b 00 3f 00 01 00 00 
Indication   handle = 0x000b value: 1e 85 00 49 00 67 00 e6 07 0a 1c 0c 1d 00 51 00 01 00 00 
...
                                    XX SS SS DD DD PP PP YY YY MM dd HH MM SS pp pp id
(SSSS=Systolic mmhg, DDDD=Diatolic, PPPP pressure, YYYY=year, MM=month, dd=day, HH=hour, MM=minute, SS=seconds, id=person)
Vers le milieu, on retrouve l'année 2022 (0x07E6), octobre (0x0A), 28 (0x1C), etc...

Reste maintenant à écrire une petite application qui fait juste ça : connect, write(0200) et accepte/affiche les indications reçues. Ce qui n'est pas spécialement trivial parce que la documentation de l'API Bluetooth sur Linux est inexistante. La stratégie la plus simple serait de partir des sources de gatttool(1) mais il y a une masse de dépendances... On trouve beaucoup de choses en Python aussi mais Python est infernal au niveau des versions et des dépendances.

Ce qui serait bien aussi, ce serait de remettre le pointeur de lecture à zéro (ou à une valeur arbitraire) parce que là, le tensiomètre n'envoie que les nouvelles mesures, effectuées après le dernier transfert. Alors qu'il peut contenir jusqu'à 250 mesures par utilisateur. Il semblerait cependant que cela ne soit pas possible parce qu'on ne trouve pas de 'Record Access Control Point' (0x2a52) dans le caractéristiques du tensiomètre (voir ci-dessous) et que la fonctionalité est optionnelle.

Exploration avec gatttool(1)

(gatttool(1) serait à l'abandon et remplacé par bluetoothctl(1) (tellement 'intuitif' que le manuel est virtuellement inexistant... (il faut taper 'menu gatt' pour avoir le sous-menu gatt...)).

Les 'services' du tensiomètre

$ gatttool -b 11:22:33:44:55:66 -t random -I
[11:22:33:44:55:66][LE]> connect
Attempting to connect to 11:22:33:44:55:66
Connection successful
[11:22:33:44:55:66][LE]> primary
attr handle: 0x0001, end grp handle: 0x0007 uuid: 00001800-0000-1000-8000-00805f9b34fb
attr handle: 0x0008, end grp handle: 0x0008 uuid: 00001801-0000-1000-8000-00805f9b34fb
attr handle: 0x0009, end grp handle: 0x000e uuid: 00001810-0000-1000-8000-00805f9b34fb
attr handle: 0x000f, end grp handle: 0x0012 uuid: 0000180f-0000-1000-8000-00805f9b34fb
attr handle: 0x0013, end grp handle: 0x0021 uuid: 0000180a-0000-1000-8000-00805f9b34fb
attr handle: 0x0022, end grp handle: 0xffff uuid: 00001805-0000-1000-8000-00805f9b34fb
[11:22:33:44:55:66][LE]> 
La commande 'primary' liste les 'services' disponibles sur le tensiomètre. La partie variable des UUIDs sont des UUID-16 de 'services' dont on trouve la liste dans https://www.bluetooth.com/specifications/assigned-numbers/ : assigned_numbers_release.pdf :
0x1800  Generic Access service 
0x1801  Generic Attribute service
0x1810  Blood Pressure service
0x180f  Battery service
0x180a  Device Information service
0x1805  Current Time service
Ce sont des informations que l'on peut aller chercher dans le tensiomètre. Ces 'services' sont décrits dans les documents Specifications and Tests Documents List/Services. Par exemple, Blood Pressure Service (blood-pressure-service-1-1-1.pdf et d'autres documents).

La manière d'accéder à ces informations est décrite dans des documents 'Profile' : Specifications and Tests Documents List/Profiles. Par exemple, Blood Pressure Profile (blood-pressure-profile-1-1-1.pdf et d'autres documents).

Il faut noter que tous les bidules Bluetooth ne sont pas forcément 'conformes' à un standard accepté. Ceux qui ont passé les tests de conformance se retrouvent dans la base de données cherchable : https://launchstudio.bluetooth.com/Listings/Search. La recherche avancée permet de trouver les bidules qui ont passé les tests avec succès. On peut, par exemple, lister ceux qui implémente le 'Blood Pressure Service' (BLS) et le 'Blood Pressure Profile' (BLP) ou, éventuellement regarder quels bidules d'une marque données ont un profil standard. Dans ce cas-ci, on arrive à une fiche concernant le BU-546 (en fait, un TMB-1490-TS de Transtek) où on ne trouve (bizarrement?) pas de qualification 'blood pressure service/profile'...

Les 'caractéristiques' du tensiomètre

$ gatttool -b 11:22:33:44:55:66 -t random -I
[11:22:33:44:55:66][LE]> connect
Attempting to connect to 11:22:33:44:55:66
Connection successful
[11:22:33:44:55:66][LE]> char-desc
/* 0x1800  Generic Access service */ 
handle: 0x0001, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0002, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0003, uuid: 00002a00-0000-1000-8000-00805f9b34fb
handle: 0x0004, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0005, uuid: 00002a01-0000-1000-8000-00805f9b34fb
handle: 0x0006, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0007, uuid: 00002a04-0000-1000-8000-00805f9b34fb
     /* 0x1801  Generic Attribute service */
handle: 0x0008, uuid: 00002800-0000-1000-8000-00805f9b34fb
     /* 0x1810  Blood Pressure service */
handle: 0x0009, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x000a, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x000b, uuid: 00002a35-0000-1000-8000-00805f9b34fb
handle: 0x000c, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x000d, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x000e, uuid: 00002a49-0000-1000-8000-00805f9b34fb
     /* 0x180f  Battery service */
handle: 0x000f, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0010, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0011, uuid: 00002a19-0000-1000-8000-00805f9b34fb
handle: 0x0012, uuid: 00002902-0000-1000-8000-00805f9b34fb
     /* 0x180a  Device Information service */
handle: 0x0013, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0014, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0015, uuid: 00002a29-0000-1000-8000-00805f9b34fb
handle: 0x0016, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0017, uuid: 00002a24-0000-1000-8000-00805f9b34fb
handle: 0x0018, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0019, uuid: 00002a25-0000-1000-8000-00805f9b34fb
handle: 0x001a, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001b, uuid: 00002a27-0000-1000-8000-00805f9b34fb
handle: 0x001c, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001d, uuid: 00002a26-0000-1000-8000-00805f9b34fb
handle: 0x001e, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001f, uuid: 00002a28-0000-1000-8000-00805f9b34fb
handle: 0x0020, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0021, uuid: 00002a23-0000-1000-8000-00805f9b34fb
     /* 0x1805  Current Time service */
handle: 0x0022, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0023, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0024, uuid: 00002a2b-0000-1000-8000-00805f9b34fb
handle: 0x0025, uuid: 00002902-0000-1000-8000-00805f9b34fb
[11:22:33:44:55:66][LE]> 
De nouveau, on retrouve les UUID-16 dans assigned_numbers_release.pdf :
0x2800  Primary Service
0x2803  Characteristic
0x2902  Client Characteristic Configuration
0x2a00  Device Name
0x2a01  Appearance
0x2a04  Peripheral Preferred Connection Parameters
0x2a19  Battery Level
0x2a23  System ID
0x2a24  Model Number String
0x2a25  Serial Number String
0x2a26  Firmware Revision String
0x2a27  Hardware Revision String
0x2a28  Software Revision String
0x2a29  Manufacturer Name String
0x2a2b  Current Time
0x2a35  Blood Pressure Measurement
0x2a49  Blood Pressure Feature
Bref, un peu de tout... On notera cependant qu'on écrit 0200 dans le 'Client Characteristic Configuration' (0x2902, handle 0x000c) du 'Blood Pressure service' (0x1810) pour activer les indications 'Blood Pressure Measurement' (0x2a35, handle 0x000b).