# Brancher un appareil MQTT

> Brancher un capteur, un automate ou un nœud Sparkplug B en MQTT sur la passerelle : identité, topics, codes de refus, contre-pression.

Concept - Depuis la version 1.7.0 - Flexway v1.7.1 - https://flexway.io/docs/developer-mqtt-devices/

> Un capteur, un automate ou une passerelle d'atelier se branche sur la passerelle en MQTT 5 ou 3.1.1, avec n'importe quel client MQTT, sans changer de code. Disponible depuis `1.7.0`.
>
> Côté produit : [Appareils et IoT](https://flexway.io/docs/product-iot-devices/) ; côté exploitation : [Appareils MQTT : l'écouteur en proxy](https://flexway.io/docs/operations-mqtt-devices/).

## Se connecter

| Propriété | Valeur |
| --- | --- |
| Adresse | `mqtts://<hôte>:8883`, ou `wss://<hôte>:<port>/mqtt` sur WebSocket - donnée par l'exploitant |
| Versions | MQTT 5 (conseillé : des codes de refus précis) ou 3.1.1 |
| Identité | un **certificat d'appareil** ; ou un mot de passe : une **clé d'API** (son identifiant en nom d'utilisateur) ou un **jeton** de votre fournisseur d'identité (le nom d'utilisateur est libre) |
| Client id | libre : la passerelle écrit le sien chez le courtier (`<tenant>/<identité>`) ; un second appareil de la même identité prend la session du premier |
| Topics | ceux du contrat, sous **votre** tenant et **votre** identité (`plant/acme/press-07/telemetry`) |
| Keep-alive | MQTT 5 : le Server Keep Alive du `CONNACK` l'emporte ; 3.1.1 : entre 1 s et le `max_keepalive` de l'exploitant (1 200 s par défaut) - `0` est refusé |
| Durée d'une connexion | au plus `max_session_lifetime` (24 h par défaut) : puis `DISCONNECT 0xA0`, reconnectez-vous |
| Alias de topic | jusqu'à la valeur annoncée dans le `CONNACK` (MQTT 5) |
| Taille, QoS, en vol | le `CONNACK` dit Maximum Packet Size, Maximum QoS et Receive Maximum : les respecter |

```bash
# MQTT 5, an API key: its id as the user name, the key as the password
mosquitto_pub -V mqttv5 -h mqtt.plant.example.com -p 8883 --cafile ca.crt \
  -i press-07 -u press-07 -P "$DEVICE_KEY" \
  -t plant/acme/press-07/telemetry -q 1 -m '{"v":21.5}'

# A device certificate instead (no password)
mosquitto_sub -V mqttv5 -h mqtt.plant.example.com -p 8883 --cafile ca.crt \
  --cert device.crt --key device.key -t 'plant/acme/press-07/cmd' -q 1
```

Go (paho.golang, MQTT 5, reconnexion automatique) :

```go
u, _ := url.Parse("tls://mqtt.plant.example.com:8883")
cm, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{
	ServerUrls:      []*url.URL{u},
	TlsCfg:          &tls.Config{RootCAs: pool},
	KeepAlive:       30,
	ConnectUsername: "press-07",
	ConnectPassword: []byte(deviceKey), // or a token of your identity provider
	WillMessage:     &paho.WillMessage{Topic: "plant/acme/press-07/state", QoS: 1, Payload: []byte("offline")},
	ClientConfig:    paho.ClientConfig{ClientID: "press-07"},
})
if err != nil {
	return err
}
pr, err := cm.Publish(ctx, &paho.Publish{Topic: "plant/acme/press-07/telemetry", QoS: 1, Payload: []byte(`{"v":21.5}`)})
if pr != nil && pr.ReasonCode >= 0x80 {
	// refused by the gateway: 0x87 not authorized, 0x99 payload invalid, 0x97 quota exceeded
}
```

Python (paho-mqtt 2) :

```python
import ssl
import paho.mqtt.client as mqtt

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="press-07", protocol=mqtt.MQTTv5)
c.tls_set(ca_certs="ca.crt")                       # add certfile=, keyfile= for a device certificate
c.username_pw_set("press-07", device_key)          # or a token as the password
c.will_set("plant/acme/press-07/state", "offline", qos=1)

def on_publish(client, userdata, mid, reason_code, properties):
    if reason_code.is_failure:                     # 0x87, 0x99, 0x97...
        print("refused:", reason_code)

c.on_publish = on_publish
c.connect("mqtt.plant.example.com", 8883, keepalive=30)
c.loop_start()
c.publish("plant/acme/press-07/telemetry", '{"v": 21.5}', qos=1)
```

## Publier, s'abonner

- Un `PUBLISH` admis reçoit son `PUBACK` **après** celui du courtier : la passerelle ne retient rien.
- Un filtre peut porter un joker **à l'intérieur** de votre canal (`plant/acme/fleet/#`), jamais au-delà (`fleet/#`) ;
  `$share/<groupe>/<filtre>` seulement où le canal l'admet - votre groupe ne rencontre jamais celui d'un autre tenant.
- Le testament (`will`) est jugé au `CONNECT` comme une publication : sur un topic que vous pouvez publier.
- Un **Response Topic** doit être un topic auquel vous pouvez vous abonner.

## Débits : vous êtes ralenti, jamais refusé

Au-delà de votre débit (messages ou octets par seconde) ou de celui de votre tenant, la passerelle **cesse de lire**
votre connexion le temps nécessaire : vos `PUBLISH` partent plus lentement, aucun n'est refusé, la connexion n'est pas
coupée et votre testament ne part pas. Un client qui attend ses `PUBACK` (QoS 1) se cale tout seul. Seul le quota du
mois ou du jour de votre tenant refuse (`0x97`).

## Arrêts et reconnexions

À l'arrêt d'une réplique, MQTT 5 reçoit `DISCONNECT 0x9C` et une `Server Reference` ; 3.1.1 est simplement fermé.
Reconnectez-vous **avec le même identifiant** dans les 30 secondes (la `Server Reference`, ou la même adresse) : votre
session reprend chez le courtier. Pour que votre testament ne parte pas non plus à la reprise, déclarez en MQTT 5 un
délai de testament (Will Delay Interval) plus long que votre reconnexion et une expiration de session qui le couvre ;
sans délai (et toujours en 3.1.1), le courtier le publie quand votre nouvelle connexion reprend la session. Vérifié de bout en bout avec paho.golang (autopaho)
et paho.mqtt.golang en reconnexion automatique, qui reviennent par la même adresse.

## Sparkplug B et OPC UA PubSub JSON

Un **nœud Sparkplug B** (Eclipse Tahu, Ignition Edge, une passerelle d'atelier) se branche comme tout appareil, avec
les topics de l'espace `spBv1.0` :

| Propriété | Nœud (`edge_node`) | Application hôte (`host`, `state`) |
| --- | --- | --- |
| Groupe | Le nom de **votre** tenant (`spBv1.0/acme/...`) | Idem |
| Identifiant | L'identifiant du nœud = votre identité : l'identifiant de la clé, le sujet du jeton ou le champ du certificat que l'exploitant a lié | - |
| Publie | `spBv1.0/acme/NBIRTH/<nœud>`, `NDATA`, `NDEATH`, et `DBIRTH`, `DDATA`, `DDEATH` de ses appareils | `NCMD`, `DCMD` du groupe ; son `STATE` sur `spBv1.0/STATE/acme` - l'identifiant d'hôte est le nom du tenant |
| Reçoit | Ses `NCMD`, `DCMD` ; le `STATE` de l'hôte de son tenant | Tout le groupe |
| Testament | Son `NDEATH` (avec le `bdSeq` de son `NBIRTH`), rien d'autre | Son `STATE` `{"online": false, ...}`, rien d'autre |
| Charge | Protobuf Sparkplug B (`sparkplug_b.proto`), vérifiée si l'exploitant l'a choisi | `STATE` en JSON `{"online": true, "timestamp": ...}` |

- La passerelle **ne demande jamais de renaissance** et n'émet aucun message Sparkplug : c'est le travail de votre
  application hôte.
- Un écart de `seq` ou de `bdSeq` est compté, jamais refusé.
- Après un `DISCONNECT 0x9C`, reconnectez-vous avec le même identifiant : la session reprend, le nœud reste en ligne.

| Ce que vous recevez | Pourquoi | Que faire |
| --- | --- | --- |
| `CONNACK 0x87` | Le testament n'est pas le `NDEATH` du nœud (ou le `STATE` de l'hôte) | Déclarer le `NDEATH` comme testament |
| `PUBACK 0x87` | Un type que votre rôle ne publie pas (une naissance par l'hôte, une commande par le nœud), ou un autre groupe ou nœud | Publier sous votre groupe et votre identifiant |
| `PUBACK 0x99` | Charge hors de `sparkplug_b.proto`, ou `STATE` hors de son schéma | Corriger le codage |

Un **éditeur OPC UA PubSub** publie ses NetworkMessage en JSON (partie 14) sur
`<préfixe>/json/data/<PublisherId>/<WriterGroup>` : le préfixe porte votre tenant (`opcua/acme/...`), le PublisherId
est votre identité ; chaque message est vérifié par le schéma du contrat (`0x99` sinon). Le codage binaire UADP n'est
pas servi.

## Codes de refus

| Ce que vous recevez | Pourquoi | Que faire |
| --- | --- | --- |
| `CONNACK 0x86` (3.1.1 `0x04`) | Clé ou jeton refusé ; nom d'utilisateur qui n'est pas l'identifiant de la clé | Corriger le justificatif ; ne pas réessayer en boucle |
| `CONNACK 0x87` (3.1.1 `0x05`) | L'identité n'ouvre aucun topic, ou le testament vise un topic qu'elle ne peut publier | Demander les opérations à l'exploitant |
| `CONNACK 0x88`, `0x89` (3.1.1 `0x03`) | Le courtier est indisponible | Réessayer avec recul |
| `CONNACK 0x9C` / `DISCONNECT 0x9C` + `Server Reference` | La réplique s'arrête ou est pleine | Se reconnecter à la `Server Reference`, ou à la même adresse |
| `CONNACK 0x9F` | Trop de connexions par seconde pour cette identité ou ce tenant | Attendre au moins une seconde |
| `CONNACK 0x97` | La réplique, votre tenant ou votre identité (`per_client_id`) tient son maximum de connexions | Fermer une connexion, ou réessayer avec recul |
| 3.1.1 : code de retour `0x05` | Un keep-alive de `0`, ou au-delà du maximum de l'exploitant | Poser un keep-alive (30 à 300 s est courant) |
| `PUBACK 0x87` | Topic hors de vos canaux, ou d'un autre appareil, ou Response Topic interdit | Publier sous votre tenant et votre identité |
| `PUBACK 0x99` | La charge ne respecte pas le schéma du contrat, ou est déclarée UTF-8 sans l'être | Corriger le message |
| `PUBACK 0x97` | Le quota du mois (ou du jour) de votre tenant est épuisé | Attendre la fenêtre suivante |
| `PUBACK` ou `PUBREC 0x9B` | QoS au-delà de ce que le canal admet | Publier en QoS plus basse |
| `SUBACK 0x87` (3.1.1 `0x80`) | Filtre plus large que vos canaux (`plant/#`) ou d'un autre tenant | Restreindre le filtre |
| `DISCONNECT 0xA0` | Votre jeton (ou clé, ou certificat) a expiré, ou la connexion a atteint sa durée maximale | Se reconnecter avec un justificatif neuf |
| `DISCONNECT 0x87` | Votre clé, certificat ou vos opérations ont été retirés | Demander à l'exploitant ; ne pas réessayer en boucle |
| `DISCONNECT 0x96` | Trop de paquets refusés en une minute (60 par défaut) | Corriger les topics ou charges refusés avant de vous reconnecter |
| `DISCONNECT 0x8E` | Une autre connexion de la même identité a pris la session | Un appareil par identité, ou demander `per_client_id` |
| 3.1.1 : connexion fermée après un `PUBLISH` | MQTT 3.1.1 ne sait pas dire pourquoi une publication est refusée | Lire le même tableau en MQTT 5 |
