# Appareils MQTT : l'écouteur en proxy devant votre courtier

> Des appareils MQTT 5 et 3.1.1 derrière la passerelle, devant votre courtier : identité, topics liés au tenant, contre-pression, bascule.

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

> Guide d'exploitation : configurer, déployer et observer la passerelle. Ce que fait la fonctionnalité côté produit : [guide produit](https://flexway.io/docs/product-iot-devices/) ; brancher un appareil : [guide développeur](https://flexway.io/docs/developer-mqtt-devices/).

Capteurs, automates et passerelles d'atelier se connectent à la passerelle en **MQTT 5 ou 3.1.1**, sur TLS ou
WebSocket, au lieu de votre courtier. La passerelle est un **proxy**, jamais un courtier : chaque appareil a sa
connexion à votre courtier (Mosquitto, EMQX, HiveMQ...), ouverte au nom de la passerelle ; chaque paquet est jugé
contre le contrat AsyncAPI - qui est l'appareil, quels topics son tenant et son identité ouvrent, ses débits et son
quota - puis relayé tel quel. Sessions, messages retenus, testaments et QoS 2 restent chez votre courtier.
Disponible depuis `1.7.0`.

> **Sur cet écouteur** : [Sparkplug B et OPC UA PubSub JSON](https://flexway.io/docs/operations-sparkplug/) ; l'écran Appareils de la
> console : [Console : l'écran Appareils](https://flexway.io/docs/operations-console-devices/). Les défauts `max_connections`,
> `max_connections_per_ip` et `max_connection_rate_per_ip` sont provisoires, en attendant les mesures de
> performance : écrivez-les si vous en dépendez.

<!-- example: examples/mqtt-devices/flexway.yaml -->

```yaml
# Devices behind the gateway (1.7.0): sensors and controllers connect in MQTT 5 or 3.1.1 and reach the
# plant's broker through the gateway - one broker connection per device, each packet judged against the
# AsyncAPI contract, each tenant inside its own topics. Here clear text inside a mesh, devices by API key
# or token; the TLS listener and device certificates are in the guide.
version: 1
contracts:
  asyncapi: ./asyncapi.yaml
upstreams:
  plant:
    protocol: mqtt
    brokers: ["mqtts://broker.plant.svc.cluster.local:8883"]
    sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/broker/password" }
    timeout: 5s                                  # a connection to the broker, per device
auth:
  issuers:
    - name: devices
      discovery: https://login.example.net/.well-known/openid-configuration
      audience: plant-devices
      tenant_claim: site                         # every topic a device reaches is its tenant's
  api_keys:
    store: "file:./api-keys.yaml"                # the key's id as the user name, the key as the password
listeners:
  mqtt:
    address: ":1883"
    mesh_only: true                              # clear text only where the mesh encrypts between pods
    websocket: { address: ":8884" }              # MQTT over WebSocket, path /mqtt
    advertised_address: "mqtt.plant.example.com:1883"   # the load balancer: where a drained device goes
    device:
      messages_per_second: 50                    # past it the device is held, never refused
    failover: { handover_timeout: 30s }
```

```yaml
# examples/mqtt-devices/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Plant devices, version: "1.0.0" }
channels:
  telemetry:
    address: "plant/{site}/{device}/telemetry"
    parameters:
      site: { x-flexway-bind: tenant }           # the verified tenant, never read from the topic
      device: { x-flexway-bind: subject }        # the key's id, the token's subject
    messages:
      reading:
        payload: { type: object, required: [v], properties: { v: { type: number } } }
  commands:
    address: "plant/{site}/{device}/cmd"
    parameters:
      site: { x-flexway-bind: tenant }
      device: { x-flexway-bind: subject }
    messages: { command: { payload: { type: object } } }
  fleet:
    address: "plant/{site}/fleet/#"                    # a wildcard after the bound level: the tenant's fleet
    parameters:
      site: { x-flexway-bind: tenant }
    messages: { event: { payload: { type: object } } }
operations:
  publishTelemetry:
    action: send                                 # the device publishes
    channel: { $ref: "#/channels/telemetry" }
    x-flexway-upstream: plant
    x-flexway-validation: full                   # a reading against its schema: 0x99 otherwise
    x-flexway-device: { qos_max: 1 }
    security:
      - type: userPassword
      - type: oauth2
        flows: { clientCredentials: { tokenUrl: "https://login.example.net/token", availableScopes: { "telemetry:write": publish } } }
        scopes: ["telemetry:write"]
  readCommands:
    action: receive                              # the device subscribes
    channel: { $ref: "#/channels/commands" }
    x-flexway-upstream: plant
    x-flexway-device: true
    security: [ { type: userPassword } ]
  readFleet:
    action: receive
    channel: { $ref: "#/channels/fleet" }
    x-flexway-upstream: plant
    x-flexway-device: { shared: true, qos_max: 1 }   # $share/<group>/plant/<site>/fleet/#, the group in the tenant's name
    security: [ { type: userPassword } ]
```

## Déclarer ce qu'un appareil peut faire

Une opération AsyncAPI marquée `x-flexway-device` ouvre son canal aux appareils, dans **son** sens : `send`, l'appareil
publie ; `receive`, il s'abonne. Rien d'autre ne passe.

| Clé de `x-flexway-device` | Défaut | Effet |
| --- | --- | --- |
| `qos_max` | 2 | QoS la plus haute ; un `PUBLISH` au-delà est refusé `0x9B`, un abonnement accordé à elle |
| `messages_per_second`, `bytes_per_second` | ceux de `listeners.mqtt.device` | Les débits d'un `PUBLISH` sur ce canal (`send`) |
| `shared` | `false` | Admet `$share/<groupe>/<filtre>` sur un canal `receive` ; le groupe est écrit `<tenant>~<groupe>` chez le courtier |

Chaque paramètre du canal est **lié** à un fait vérifié (`x-flexway-bind`) et l'un d'eux au **tenant** - refusé au
chargement sinon : `tenant`, `subject` (l'identifiant de la clé, le sujet du jeton, l'identifiant du client `auth.mtls`),
`claim:<nom>`, ou un champ du certificat de l'appareil, `cert:<champ>` : `cn`, `serial`, `fingerprint` (SHA-256 du
certificat, hexadécimal), ou avec son indice `ou[i]`, `o[i]`, `san.dns[i]`, `san.uri[i]`, `san.ip[i]`, `san.email[i]`.
Une valeur vide ou qui contient `/`, `+`, `#`, `$`, `{`, `}` ou un caractère nul laisse l'opération fermée : jamais
écrite dans un topic, et une valeur écrite n'est jamais relue comme paramètre. Un joker (`+`, `#`) s'écrit dans un canal
`receive` seulement, après les niveaux liés : un filtre d'appareil doit y être **inclus**.

Tous les canaux d'appareils écrivent le **tenant au même niveau**, après le même préfixe littéral (`plant/{site}/...`
partout) - refusé au chargement sinon : un canal avec le tenant au niveau 1 et un autre au niveau 2 laisseraient le
tenant nommé comme un niveau littéral de l'un lire les topics de l'autre.

## Identifier les appareils

L'écouteur lit, dans l'ordre : le **certificat** vérifié par `tls.client_ca_file` et déclaré dans `auth.mtls`, sinon le
**mot de passe** du `CONNECT` - une **clé d'API** dont l'identifiant est le nom d'utilisateur, sinon un **jeton** (`exp`
obligatoire ; à son terme, `DISCONNECT 0xA0`). Une alternative de la `security` de l'opération nomme ce qu'elle admet :
`X509`, `userPassword` (ou `plain`, `apiKey` `in: password`), `oauth2`/`openIdConnect`.

Certificats d'appareil (TLS obligatoire) :

```yaml
listeners:
  mqtt:
    address: ":8883"
    tls:
      cert_file: /etc/flexway-mqtt-tls/tls.crt
      key_file: /etc/flexway-mqtt-tls/tls.key
      client_ca_file: /etc/flexway-mqtt-tls/ca.crt   # verifies device certificates
    websocket: { address: ":8884", path: /mqtt }      # MQTT over WebSocket, same TLS
    advertised_address: "mqtt.plant.example.com:8883"
auth:
  mtls:
    clients:
      - { id: press-07, subject: "CN=press-07", tenant: acme, operations: [publishCertified] }
```

```yaml
channels:
  certified:
    address: "plant/{site}/{cn}/certified/{dns}"
    parameters:
      site: { x-flexway-bind: tenant }
      cn:   { x-flexway-bind: "cert:cn" }           # the device certificate's common name
      dns:  { x-flexway-bind: "cert:san.dns[0]" }   # its first DNS name: a multi-valued field names its index
operations:
  publishCertified:
    action: send
    channel: { $ref: "#/channels/certified" }
    x-flexway-upstream: plant
    x-flexway-device: true
    security: [ { type: X509 } ]
```

Jetons d'un fournisseur OIDC, le tenant dans une claim :

```yaml
auth:
  issuers:
    - name: devices
      discovery: https://login.example.net/.well-known/openid-configuration
      audience: plant-devices
      tenant_claim: site
```

Chez votre courtier, l'appareil a le client id **`<tenant>/<mécanisme>:<identité>`** (`acme/apikey:press-07`), jamais
celui qu'il a choisi : un appareil d'un tenant n'évince jamais celui d'un autre. Celui d'un jeton porte une empreinte de
son émetteur, `acme/oidc:<8 hex>:<sujet>` : deux émetteurs qui délivrent le même sujet ne partagent jamais une session.
Une seconde connexion de la même identité reprend la session (`0x8E` à la première) ; `device.sessions: per_client_id`
donne une session par client id de l'appareil sous une identité partagée, au plus `device.max_sessions_per_identity` à la
fois (`0x97` au-delà).

> **`subject` et plusieurs émetteurs** - `x-flexway-bind: subject` lie le sujet seul : deux émetteurs du même tenant qui
> délivrent le même sujet atteignent les mêmes topics. Donnez un émetteur par tenant, ou liez une claim qu'un seul
> émetteur pose (`claim:<nom>`). Une liaison `issuer` est proposée pour un lot ultérieur, non construite.

**Keep-alive.** Un appareil MQTT 5 reçoit `max_keepalive` (Server Keep Alive) quand il demande plus, ou rien. MQTT 3.1.1
ne permet pas de l'imposer : un appareil qui demande plus que `max_keepalive` est refusé `0x05`, celui qui ne demande
rien (`0`) aussi, sauf avec `allow_zero_keepalive: true` - un appareil muet ne serait jamais vu parti. Toute connexion
prend fin après `max_session_lifetime` (24 h) quel que soit son justificatif, et se reconnecte avec un justificatif
vérifié à nouveau.

**Révocation.** À chaque rechargement, chaque connexion ouverte est vérifiée de nouveau en moins d'une demi-seconde
contre la nouvelle configuration - un appareil qui ne fait qu'écouter aussi : une clé, un client ou une opération retirés
y mettent fin par `DISCONNECT 0x87`, et à sa connexion au courtier par `DISCONNECT 0x00`, si bien que le courtier
écarte son testament (un appareil révoqué n'est pas un appareil perdu). Chaque livraison du courtier est jugée sur la
configuration en vigueur.

## Réglages et défauts

Chaque défaut est réglable ; les bornes s'appliquent au paquet ou au `CONNECT` suivant, sauf l'adresse, TLS,
`mesh_only`, le WebSocket et `max_connections`, lus au démarrage.

| Clé (`listeners.mqtt.`) | Défaut | Pourquoi |
| --- | --- | --- |
| `address`, `tls`, `mesh_only` | aucun écouteur sans déclaration ; TLS ou `mesh_only` | Le jeton ou la clé d'un appareil ne traverse jamais le réseau en clair hors d'un maillage |
| `broker_mesh_only` | `false` | Un courtier en clair (`mqtt://`, `ws://`) est refusé au chargement hors `mesh_only` : à poser quand un maillage chiffre le saut vers le courtier |
| `websocket.path` | `/mqtt` | Le chemin des clients MQTT sur WebSocket (navigateurs, passerelles d'atelier) |
| `max_packet_bytes` | 256 Kio | Un paquet est lu en entier avant d'être jugé : la borne de mémoire par paquet |
| `connect_timeout` | `5s` | Un `CONNECT` lent ou une poignée TLS qui traîne ne tient pas une place |
| `max_connections` | 50 000 par réplique | Provisoire : à confirmer au banc M1 ; au-delà `0x97` |
| `max_unauthenticated_connections` | 1 024 | Une rafale sans `CONNECT` ne prive pas les appareils authentifiés |
| `max_connections_per_ip`, `max_connection_rate_per_ip` | 64 ; 20 par seconde | Provisoire (banc M1) ; à poser à `0` derrière un équilibreur ou un proxy qui masque les adresses (le chart les garde : `externalTrafficPolicy: Local`) - sinon tous les appareils partagent une adresse et une borne |
| `max_connections_per_tenant`, `max_connection_rate_per_tenant` | 10 000 ; 100 par seconde | Un tenant ne prend pas la réplique aux autres ; une tempête de reconnexions est étalée |
| `max_keepalive`, `max_session_expiry`, `topic_alias_maximum` | 1 200 s ; 1 h ; 10 (1 000 au plus) | Les bornes des courtiers du marché (AWS, Event Grid, HiveMQ) ; un alias n'est retenu qu'une fois son topic accordé |
| `allow_zero_keepalive` | `false` | Un appareil 3.1.1 qui ne demande aucun keep-alive est refusé : il ne serait jamais vu parti |
| `max_session_lifetime` | 24 h (1 min à 30 jours) | Une connexion ne survit jamais à une révocation que le rechargement manquerait |
| `device.messages_per_second`, `bytes_per_second`, `burst` | 100 ; 512 Kio ; 2 | Ceux d'AWS IoT Core ; contre-pression au-delà, jamais un refus |
| `device.receive_maximum`, `max_subscriptions`, `max_filters_per_subscribe` | 100 ; 50 ; 10 | Bornent ce qu'un appareil tient en vol et souscrit |
| `device.connect_rate_per_identity` | 1 par seconde | Un appareil qui boucle ne s'évince pas lui-même sans fin (`0x9F`) ; compté sur l'identité vérifiée |
| `device.max_refusals_per_minute` | 60 | Chaque refus dépense le débit de messages de l'appareil, son audit est échantillonné (une entrée par seconde et par cause) ; au-delà `DISCONNECT 0x96` |
| `device.max_user_properties_bytes`, `max_correlation_data_bytes` | 8 Kio ; 8 Kio | Ce qu'un paquet porte à côté de sa charge |
| `device.sessions` | `single` | Une session par identité : un second appareil évince le premier (`0x8E`) |
| `device.max_sessions_per_identity` | 10 (ou `max_connections` s'il est plus bas) | Avec `per_client_id`, une identité ne prend pas la réplique (`0x97`) |
| `tenant.messages_per_second`, `bytes_per_second` | 5 000 ; 20 Mio | Le plafond d'un site entier ; le plan d'abonnement viendra en `1.9.0` |
| `failover.mode`, `reason` | `redirect` ; `use_another_server` | Un arrêt redirige plutôt que de couper ; `0x9C` est temporaire |
| `failover.server_reference` | `advertised_address` | Toujours joignable, même par un client qui ignore la référence |
| `failover.redirect_above`, `handover_timeout` | 95 % ; 30 s | Redirige avant la saturation ; laisse revenir un client courant avec recul |
| `failover.will_delay_on_handover` | `false` | Activé : un testament MQTT 5 sans délai survit à une reprise, celui d'un appareil perdu part jusqu'à `handover_timeout` plus tard |
| `server.shutdown.mqtt_drain_spread` | 30 s (au plus la moitié de `drain`) | Pas de tempête de reconnexions sur les autres répliques |
| `observability.mqtt.closures` | `true` | Compter les fermetures normales à part des refus |

## Débits, contre-pression et quota

Au-delà d'un débit de l'appareil ou de son tenant, la passerelle **cesse de lire** la socket de l'appareil le temps
nécessaire - jamais un refus, jamais une fermeture qui déclencherait son testament - et **tient le keep-alive** vers le
courtier pendant ce temps. Le quota jour ou mois du tenant (une politique `quota`, comme en HTTP) est, lui, un refus :
`PUBACK 0x97`, QoS 0 jeté et compté, fermeture en 3.1.1.

`device.max_subscriptions` compte les filtres qu'un appareil souscrit sur cette connexion. Un appareil qui revient avec
`Session Present=1` garde chez le courtier les abonnements de sa connexion précédente, que la passerelle ne voit pas :
bornez-les aussi au courtier (sa limite d'abonnements par client), la borne de la passerelle étant par connexion.

```yaml
listeners:
  mqtt:
    device:
      messages_per_second: 100      # past it: held, never refused
      bytes_per_second: 524288
      burst: 2                      # seconds of each rate a burst may take
    tenant:
      messages_per_second: 5000
      bytes_per_second: 20971520
      quota: plant-monthly          # a policy of type quota: 0x97 once the month is spent
```

## Bascule : arrêt et réplique pleine

À l'arrêt d'une réplique, chaque appareil MQTT 5 reçoit `DISCONNECT 0x9C` et la **`Server Reference`** (l'adresse de
l'équilibreur par défaut) ; un appareil 3.1.1 est fermé et se reconnecte par l'équilibreur. Sa connexion au courtier est
**tenue** `handover_timeout` : revenu par une autre réplique, le courtier reprend sa session et **aucun testament** ne
part ; pas revenu, la connexion est fermée par `DISCONNECT 0x04` et le testament part - l'appareil est vraiment perdu.
Au-delà de `redirect_above`, un nouvel appareil est redirigé dès son `CONNECT`. Une réplique perdue brutalement coupe
ses connexions : le courtier publie les testaments, vrais.

```yaml
server:
  shutdown:
    drain: 45s                      # covers the spread and the handover
    mqtt_drain_spread: 30s
listeners:
  mqtt:
    advertised_address: "mqtt.plant.example.com:8883"
    failover:
      mode: redirect                # off: 0x8B and the will at once
      reason: use_another_server    # server_moved (0x9D) for a planned migration
      redirect_above: 95            # % of max_connections: new devices go elsewhere first
      handover_timeout: 30s
      will_delay_on_handover: false # true : un testament sans délai reçoit handover_timeout (R11)
```

Le délai de grâce du pod suit `server.shutdown.drain` : couvrez l'étalement et la reprise.

> **Testament à la reprise** - Mosquitto, EMQX et HiveMQ CE publient le testament d'une session **reprise** quand
> son délai de testament (Will Delay Interval) est nul : c'est la règle de MQTT 5, pas la passerelle. La reprise garde
> la session (abonnements, messages en vol) ; pour garder aussi le testament, un appareil MQTT 5 déclare un délai de
> testament supérieur à sa reconnexion et une expiration de session qui le couvre (20 s et 40 s par exemple). Un
> appareil 3.1.1 n'a pas de délai : son testament part à la reprise. `flexway.mqtt.failover{outcome="taken_over"}` compte
> ces reprises à part.
>
> Pour des appareils que vous ne pouvez pas modifier, `failover.will_delay_on_handover: true` (désactivé par défaut)
> fait écrire par la passerelle vers le courtier un délai de testament égal à `handover_timeout`, et une expiration de
> session qui le couvre, quand le testament n'en déclare pas : le testament survit à la reprise. **La contrepartie** : le
> testament d'un appareil vraiment perdu part jusqu'à `handover_timeout` plus tard. Sans effet sur un appareil MQTT
> 3.1.1 - sa connexion au courtier est en 3.1.1, sans délai de testament.

## Le chart

Les ports du conteneur, le `Service` et sa `NetworkPolicy` suivent `listeners.mqtt` et sont rendus seulement avec
`mqtt.enabled` (désactivé par défaut) ; le `Certificate` cert-manager avec `listeners.mqtt.tls`. Le certificat est monté
sous `/etc/flexway-mqtt-tls` (`tls.crt`, `tls.key`, `ca.crt`). Avec `mesh_only`, le `Service` reste `ClusterIP` et
`mqtt.networkPolicy.from` est exigé, `mqtt.enabled` ou non : le processus écoute en clair dans les deux cas. Un
`Service` `LoadBalancer` (ou `NodePort`) reçoit `externalTrafficPolicy: Local` (`mqtt.service.externalTrafficPolicy`) :
les adresses des appareils parviennent à la passerelle, et `max_connections_per_ip` borne un appareil - `Cluster` les
masque, posez alors `max_connections_per_ip: 0`. L'écouteur WebSocket compte ses sockets dès leur acceptation, avant TLS
et la montée en WebSocket.

```yaml
mqtt:
  enabled: true                     # the Service; off by default
  service: { type: LoadBalancer }
  tls:
    dnsNames: [mqtt.plant.example.com]
    issuerRef: { name: devices-ca, kind: ClusterIssuer }
  networkPolicy:
    from: [{ ipBlock: { cidr: 10.20.0.0/16 } }]
```

Une adresse par réplique (un `Service` par pod, pour une `Server Reference` par réplique) n'est pas rendue : l'équilibreur
suffit, et beaucoup de clients ignorent la référence.

## Observer et simuler

| Signal | Ce qu'il dit |
| --- | --- |
| `flexway.mqtt.connections{listener, tenant, protocol_version, auth}` | Appareils connectés : l'occupation de `max_connections` |
| `flexway.mqtt.packets{type, decision, cause, tenant}` | Paquets admis, refusés, jetés, et pourquoi |
| `flexway.mqtt.connections.refused{bound}`, `flexway.mqtt.connections.closed{cause}` | Refus par borne ; fermetures normales à part |
| `flexway.mqtt.publish.duration{tenant, qos}` | Du `PUBLISH` de l'appareil à l'accusé du courtier |
| `flexway.mqtt.throttled`, `flexway.mqtt.throttled.devices` | Retenues de contre-pression ; appareils retenus maintenant |
| `flexway.mqtt.failover{reason, outcome}` | Redirections, reprises, reprises expirées |
| `flexway.mqtt.upstream.connections{outcome}` | Connexions au courtier, coupe-circuit compris |

Un span par paquet échantillonné ; chaque refus dans l'audit des décisions (`audit.decisions`). `/config` publie
`listeners.mqtt` et `devices` (les opérations, leurs canaux et leurs liaisons). `POST /simulate/mqtt` juge un paquet
décrit sans rien envoyer au courtier :

```bash
curl -s localhost:9090/simulate/mqtt -d '{
  "packet": "subscribe", "topic": "$share/workers/plant/acme/fleet/#", "qos": 2,
  "identity": { "kind": "api_key", "id": "press-07" } }'
# {"verdict":"served","operation":"readFleet","upstream":"plant",
#  "written":"$share/acme~workers/plant/acme/fleet/#","qos":1,"client_id":"acme/apikey:press-07"}
```

## Dépannage

| Ce que voit l'appareil | Pourquoi | Où regarder |
| --- | --- | --- |
| `CONNACK 0x86` (3.1.1 `0x04`) | Clé ou jeton refusé, nom d'utilisateur qui n'est pas l'identifiant de la clé, jeton sans `exp` | Le journal `mqtt connection refused` dit la cause ; `flexway.mqtt.packets{type="connect",cause="authentication"}` |
| `CONNACK 0x87` (3.1.1 `0x05`) | Identité sans tenant, certificat non déclaré dans `auth.mtls`, aucune opération ne l'admet, testament hors de ses topics | Vérifier `operations` de la clé ou du client, `x-flexway-bind` du canal du testament |
| `CONNACK 0x8C` | Le client demande une authentification étendue (`AUTH`) | Non servie : clé, jeton ou certificat |
| `CONNACK 0x88` (3.1.1 `0x03`) | Courtier injoignable ou coupe-circuit ouvert | `flexway.mqtt.upstream.connections{outcome}`, l'amont `brokers` |
| `CONNACK 0x97`, `0x9F` | `max_connections`, une borne du tenant ou `max_sessions_per_identity` ; débit de connexions du tenant ou de l'identité | `flexway.mqtt.connections.refused{bound}` |
| Code de retour 3.1.1 `0x05` d'emblée | Un keep-alive de `0` ou au-delà de `max_keepalive` | Régler le keep-alive de l'appareil, ou `allow_zero_keepalive` |
| `CONNACK 0x9C` + `Server Reference` | Réplique au-delà de `redirect_above` | Normal : le client va à `server_reference` |
| `PUBACK 0x87` | Topic hors de ses canaux (`binding`, `not_authorized`), topic `$`, Response Topic hors de ses abonnements | `POST /simulate/mqtt` avec le topic et l'identité |
| `PUBACK 0x99` | Charge refusée par le schéma (`x-flexway-validation: full`), ou déclarée UTF-8 (Payload Format Indicator 1) sans l'être | Le message du contrat |
| `PUBACK 0x97` | Quota jour ou mois du tenant épuisé - jamais un débit | `listeners.mqtt.tenant.quota` |
| `PUBACK`/`PUBREC 0x9B` | QoS au-delà du `qos_max` de l'opération | `x-flexway-device.qos_max` |
| `SUBACK 0x87` (3.1.1 `0x80`) | Filtre hors d'un canal lu (`wildcard`, `binding`), `$share/` sur un canal non `shared` | `POST /simulate/mqtt` avec `packet: subscribe` |
| `DISCONNECT 0x93`, `0x94`, `0x95` | Receive Maximum dépassé, alias inconnu ou au-delà du maximum, paquet trop grand | Les bornes `device.*` et `max_packet_bytes` |
| `DISCONNECT 0xA0` | Fin du justificatif (jeton, clé, certificat) ou de `max_session_lifetime` | Le client se reconnecte avec un justificatif neuf |
| `DISCONNECT 0x87` | Un rechargement a retiré sa clé, son client ou son opération | `/config`, le magasin de clés |
| `DISCONNECT 0x96` | Plus de `device.max_refusals_per_minute` paquets refusés | Les refus dans `flexway.mqtt.packets{decision="refused"}` et l'audit échantillonné |
| `DISCONNECT 0x8E` | Une autre connexion de la même identité a repris la session | `device.sessions: per_client_id` pour plusieurs appareils sous une identité |
