# Sparkplug B et OPC UA PubSub JSON : l'atelier sous contrat

> Nœuds Sparkplug B, applications hôtes et éditeurs OPC UA PubSub JSON derrière la passerelle : rôles, table des nœuds, bascule, réglages.

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

> 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 nœud ou un éditeur : [guide développeur](https://flexway.io/docs/developer-mqtt-devices/#sparkplug-b-et-opc-ua-pubsub-json).

Sur l'[écouteur MQTT des appareils](https://flexway.io/docs/operations-mqtt-devices/), deux standards industriels passent sous le même
contrat : **Sparkplug B 3.0** - nœuds d'atelier, leurs appareils, applications hôtes (SCADA, MES) et leur `STATE` - et
**OPC UA PubSub** en JSON sur MQTT. La passerelle juge chaque paquet, le relaie, puis **lit** ce qui passe pour tenir
l'état des nœuds ; elle ne parle jamais Sparkplug elle-même (aucune renaissance demandée, aucun `STATE`, aucune mort
émise). Disponible depuis `1.7.0`.

> **Conformité** - testé de bout en bout contre Mosquitto, EMQX (un nœud) et HiveMQ CE. Le kit de conformité
> Sparkplug (TCK 3.0.0) et les nœuds Eclipse Tahu ne sont pas encore passés : aucune conformité certifiée n'est promise.

## Sparkplug B : l'exemple

Des nœuds publient naissances, données et morts ; une application hôte du même tenant les commande et annonce son
`STATE`. Chaque tenant vit dans **son** groupe de l'espace `spBv1.0`, chaque nœud sous **son** identité.

<!-- example: examples/sparkplug/flexway.yaml -->

```yaml
# Sparkplug B 3.0 behind the gateway (1.7.0): edge nodes publish their births, data and deaths, a host
# application (SCADA, MES) commands them and announces its STATE - each tenant inside its own group of
# the namespace spBv1.0, every node's id bound to its identity. The gateway reads births,
# deaths and sequences into each replica's node table (GET /sparkplug/nodes, the console's Devices
# screen) and never speaks Sparkplug itself.
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
auth:
  api_keys:
    store: "file:./api-keys.yaml"                # a node's key id is the edge node id its topics carry
listeners:
  mqtt:
    address: ":1883"
    mesh_only: true                              # with TLS and device certificates, bind edge_node to cert:cn
    advertised_address: "mqtt.plant.example.com:1883"
observability:
  sparkplug:
    max_nodes: 10000                             # per replica; full of online nodes, a new one is counted, not kept
    offline_retention: 24h                       # an offline node stays listed this long
```

```yaml
# examples/sparkplug/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Plant Sparkplug B, version: "1.0.0" }
channels:
  edgeNode:
    # The edge node's own topics: its births, deaths and data, its devices', its commands. The + is the
    # message type, admitted by the operation's role; the group is the tenant, the node its identity.
    address: "spBv1.0/{group}/+/{edge_node}/#"
    parameters:
      group: { x-flexway-bind: tenant }
      edge_node: { x-flexway-bind: subject }       # the key's id; "cert:cn" on a TLS listener
  group:
    # The host application's view of its group.
    address: "spBv1.0/{group}/#"
    parameters:
      group: { x-flexway-bind: tenant }
  hostState:
    # STATE has no group: the host id is bound to the tenant, so two tenants never share one.
    address: "spBv1.0/STATE/{host_id}"
    parameters:
      host_id: { x-flexway-bind: tenant }
operations:
  nodePublishes:
    action: send                                 # NBIRTH, NDEATH, DBIRTH, DDEATH, NDATA, DDATA; the will: NDEATH only
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-validation: full                   # every payload under the embedded sparkplug_b.proto: 0x99 otherwise
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: edge_node
    security: [ { type: userPassword } ]
  nodeCommands:
    action: receive                              # NCMD, DCMD addressed to the node
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: edge_node
    security: [ { type: userPassword } ]
  nodeWatchesHost:
    action: receive                              # the primary host's STATE
    channel: { $ref: "#/channels/hostState" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: state
    security: [ { type: userPassword } ]
  hostCommands:
    action: send                                 # NCMD, DCMD only: a host never publishes a birth
    channel: { $ref: "#/channels/group" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: host
    security: [ { type: userPassword } ]
  hostReads:
    action: receive
    channel: { $ref: "#/channels/group" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: host
    security: [ { type: userPassword } ]
  hostState:
    action: send                                 # {"online": true, "timestamp": ...}, its will the offline one
    channel: { $ref: "#/channels/hostState" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: state
    security: [ { type: userPassword } ]
```

## Rôles d'une opération Sparkplug

Une opération d'appareil (`x-flexway-device`) prend un **rôle** par `x-flexway-sparkplug`. Son canal a **exactement**
la forme du rôle, le groupe lié au tenant - toute autre forme, et tout canal en `spBv1.0/` sans rôle, est refusée au
chargement : deux tenants ne se rencontrent jamais dans l'espace de noms.

| Rôle | Canal | `send` | `receive` | Testament admis |
| --- | --- | --- | --- | --- |
| `edge_node` | `spBv1.0/{groupe}/+/{nœud}/#` - le `+` est le type de message, le nœud lié à l'identité (`subject`, `claim:<nom>`, `cert:<champ>`) | `NBIRTH`, `NDEATH`, `DBIRTH`, `DDEATH`, `NDATA`, `DDATA` | Ses `NCMD`, `DCMD` | Son `NDEATH` seulement |
| `host` | `spBv1.0/{groupe}/#` | `NCMD`, `DCMD` seulement - jamais une naissance | Tout le groupe | Aucun |
| `state` | `spBv1.0/STATE/{host_id}` - `host_id` lié au **tenant** | Son `STATE` `{"online": ..., "timestamp": ...}` | Le `STATE` de son tenant | Son `STATE` seulement |

- **Une application hôte par tenant** à travers la passerelle : `host_id` est le nom du tenant (`spBv1.0/STATE/acme`).
  Le topic `STATE` n'a pas de groupe ; lié à autre chose, deux tenants partageraient un même `STATE`.
- **Les commandes sont des mutations** : avec un jeton, chaque alternative de la `security` d'une opération `host` en
  `send` nomme ses portées (refusé au chargement sinon) ; une clé ou un certificat sont liés à l'opération.
- Un tenant nommé `STATE` n'a jamais d'opération Sparkplug : son canal d'hôte couvrirait le `STATE` de tous les tenants.
- **Charge** : une opération Sparkplug sans message reçoit la charge B sous le `sparkplug_b.proto` embarqué, ou le
  schéma JSON de `STATE`. Avec `x-flexway-validation: full`, chaque charge y est vérifiée (`0x99` sinon) ; sans, une
  charge mal formée est relayée et ne change rien à la table.

Sur un écouteur TLS, liez le nœud à son certificat :

```yaml
channels:
  edgeNode:
    address: "spBv1.0/{group}/+/{edge_node}/#"
    parameters:
      group: { x-flexway-bind: tenant }
      edge_node: { x-flexway-bind: "cert:cn" }   # the device certificate's common name
operations:
  nodePublishes:
    action: send
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: edge_node
    security: [ { type: X509 } ]
```

## La table des nœuds

Chaque réplique tient la table des nœuds et des applications hôtes qui la traversent, lue **après** le relais :
`seq` et la métrique `bdSeq` seulement, sans jamais retarder ni refuser un paquet admis.

| État | Quand |
| --- | --- |
| `online` | Son `NBIRTH` a traversé cette réplique, sa connexion y est ouverte |
| `offline` | Son `NDEATH`, ou la fin de sa connexion avec son testament |
| `moved` | Cette réplique a remis sa connexion à une autre (bascule) : une trace, **jamais** hors ligne |
| `unknown` | Seules des commandes vers lui ont été vues ici |

- **Écarts** : un `seq` qui n'est pas le suivant, ou le `bdSeq` d'une naissance différent de celui de son testament,
  sont comptés - jamais bloquants.
- **Commandes** : `NCMD`/`DCMD` admises et refusées comptées par nœud (un refus seulement sur un nœud de son propre
  tenant). Les commandes publiées par la porte HTTP `x-flexway-publish` ne sont pas suivies.
- **Applications hôtes** : leur `STATE` est lu de leur publication, de leur testament et du `STATE` retenu remis aux
  appareils du tenant.
- **Bornes** : pleine, la table oublie d'abord un nœud `unknown`, puis le plus ancien `offline` ou `moved` ; pleine de
  nœuds en ligne, un nouveau n'est pas gardé et il est compté (`dropped`). Elle est perdue au redémarrage.

```bash
curl -s 'localhost:9090/sparkplug/nodes?tenant=acme'
# {"replica":"flexway-7c9d-x2","capacity":10000,"offline_retention_seconds":86400,"dropped":0,
#  "nodes":[{"tenant":"acme","edge_node":"press-line-1","state":"online","since":"2026-10-23T08:12:04Z",
#    "bd_seq":4,"will_bd_seq":4,"seq_gaps":0,"bd_seq_gaps":0,"protocol_version":"5","auth":"api_key",
#    "devices":[{"device":"press-07","state":"online"}],"devices_truncated":false,
#    "commands":{"admitted":3,"refused":0}}],
#  "hosts":[{"tenant":"acme","host_id":"acme","state":"online","since":"2026-10-23T08:11:58Z"}]}
```

`GET /sparkplug/nodes` vit sur le port d'administration (`404` sans écouteur MQTT) ; `bd_seq` et `will_bd_seq` sont
absents quand rien n'a été lu. La console lit la table de chaque réplique et les fusionne
([Console : l'écran Appareils](https://flexway.io/docs/operations-console-devices/)).

## Applications hôtes branchées en direct

Les commandes `NCMD`/`DCMD` ne sont gouvernées - portées, audit, comptes - que si l'application hôte passe **par la
passerelle**. Une application hôte branchée directement sur le courtier échappe à tout cela : bornez-la par les ACL du
courtier (publication seulement sur `spBv1.0/<son groupe>/NCMD/#` et `DCMD/#`, `STATE` sur son seul topic).

## Réplique perdue, arrêt et bascule

| Cas | Ce que voient le courtier et la console |
| --- | --- |
| Arrêt qui draine | Le nœud revient par une autre réplique avec le même client id : le courtier reprend sa session, aucun `NDEATH` ; l'ancienne réplique garde une trace `moved`, la console montre le nœud en ligne et **rattaché** |
| Réplique perdue | Ses connexions tombent : le courtier publie un `NDEATH` **vrai** - il suit une panne de la passerelle, pas du nœud. Aucune trace `moved` : le nœud renaît (`NBIRTH`, `bdSeq` + 1) en ligne sur la réplique qui le reçoit |
| Alerte | `sparkplug-node-offline` attend 2 min (`for`) sur la table fusionnée : un nœud revenu entre-temps ne déclenche rien |

Testament à la reprise, `handover_timeout` et `will_delay_on_handover` : [Bascule](https://flexway.io/docs/operations-mqtt-devices/#bascule--arr%C3%AAt-et-r%C3%A9plique-pleine).

## Courtiers

| Courtier | Testé | À savoir |
| --- | --- | --- |
| Mosquitto 2 | Oui | - |
| EMQX 6 | Oui, **un nœud** | Licence BSL 1.1 depuis 5.9 : un nœud gratuit, un cluster sous licence commerciale |
| HiveMQ CE | Oui | Porteur du kit de conformité Sparkplug (extension HiveMQ) |

Le serveur « averti » de Sparkplug (`$sparkplug/certificates/...`) reste au courtier : la passerelle ne le fait pas.

## OPC UA PubSub JSON

Aucune extension ni pile OPC UA : un éditeur qui publie ses NetworkMessage JSON (partie 14, `ua-data`) sur MQTT est un
appareil sous un **contrat ordinaire**. Topic de la partie 14, `<préfixe>/json/data/<PublisherId>/<WriterGroup>` : le
tenant dans le préfixe, le PublisherId lié à l'identité ; le message validé par son schéma JSON (`0x99` sinon). Le
codage binaire UADP n'est pas servi.

<!-- example: examples/opcua-pubsub/flexway.yaml -->

```yaml
# OPC UA PubSub over MQTT with the JSON message mapping (OPC UA Part 14, 1.7.0): publishers of a plant
# send their NetworkMessages through the gateway, each under its tenant and its own publisher id, each
# message checked against the contract's schema; subscribers read their tenant's data. No OPC UA stack
# in the gateway: a NetworkMessage is a JSON payload under a JSON Schema, like any other.
version: 1
contracts:
  asyncapi: ./asyncapi.yaml
upstreams:
  plant:
    protocol: mqtt
    brokers: ["mqtts://broker.plant.svc.cluster.local:8883"]
    timeout: 5s
auth:
  api_keys:
    store: "file:./api-keys.yaml"                # the key's id is the publisher id the topic carries
listeners:
  mqtt:
    address: ":1883"
    mesh_only: true
    advertised_address: "mqtt.plant.example.com:1883"
```

```yaml
# examples/opcua-pubsub/asyncapi.yaml
asyncapi: 3.0.0
info: { title: OPC UA PubSub JSON, version: "1.0.0" }
channels:
  data:
    # Part 14's topic: <prefix>/json/data/<PublisherId>/<WriterGroup>, the prefix carrying the tenant.
    address: "opcua/{site}/json/data/{publisher}/line"
    parameters:
      site: { x-flexway-bind: tenant }
      publisher: { x-flexway-bind: subject }
    messages:
      networkMessage: { $ref: "#/components/messages/networkMessage" }
  allData:
    address: "opcua/{site}/json/data/#"
    parameters:
      site: { x-flexway-bind: tenant }
    messages:
      networkMessage: { $ref: "#/components/messages/networkMessage" }
operations:
  publishData:
    action: send
    channel: { $ref: "#/channels/data" }
    x-flexway-upstream: plant
    x-flexway-validation: full
    x-flexway-device: { qos_max: 1 }
    security: [ { type: userPassword } ]
  readData:
    action: receive
    channel: { $ref: "#/channels/allData" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    security: [ { type: userPassword } ]
components:
  messages:
    networkMessage:
      contentType: application/json
      payload:
        # A NetworkMessage of the JSON mapping (OPC UA Part 14, section 7.2.5.3): ua-data, its
        # DataSetMessages and their fields.
        type: object
        required: [MessageId, MessageType, Messages]
        properties:
          MessageId: { type: string, maxLength: 64 }
          MessageType: { const: ua-data }
          PublisherId: { type: string, maxLength: 128 }
          WriterGroupName: { type: string }
          Messages:
            type: array
            minItems: 1
            maxItems: 64
            items:
              type: object
              required: [Payload]
              properties:
                DataSetWriterId: { type: integer, minimum: 0, maximum: 65535 }
                SequenceNumber: { type: integer, minimum: 0 }
                Timestamp: { type: string, format: date-time }
                MessageType: { enum: [ua-keyframe, ua-deltaframe, ua-event, ua-keepalive] }
                Payload: { type: object, maxProperties: 256 }
```

## Réglages et défauts

Ceux de l'écouteur (`listeners.mqtt.*`, `failover.*`, `observability.mqtt.closures`, chart `mqtt.enabled`) :
[Réglages et défauts](https://flexway.io/docs/operations-mqtt-devices/#r%C3%A9glages-et-d%C3%A9fauts). Ceux de la console (cinq règles intégrées,
`devices.readerBounds`) : [Console : l'écran Appareils](https://flexway.io/docs/operations-console-devices/). Propres à Sparkplug, rechargés à
chaud :

| Clé (`observability.sparkplug.`) | Défaut | Pourquoi |
| --- | --- | --- |
| `max_nodes` | 10 000 par réplique (1 à 50 000 : un nœud est une connexion) | Nœuds et applications hôtes gardés ; la borne de mémoire de la table |
| `max_nodes_per_tenant` | 1 000, ou `max_nodes` s'il est plus bas (1 à `max_nodes`) | Nœuds d'un même tenant : un tenant ne remplit jamais la table des autres ; à sa borne, ses propres nœuds inconnus puis hors ligne les plus anciens sont oubliés, sinon le nouveau est compté (`dropped`) |
| `offline_retention` | `24h` (1 min à 720 h) | Durée pendant laquelle un nœud `offline`, `moved` ou `unknown` reste listé |
| `max_devices_per_node` | 1 000 (1 à 10 000) | Appareils (`DBIRTH`, `DDEATH`) gardés par nœud ; au-delà, la liste est dite tronquée |

Un groupe, un nœud, un appareil ou un identifiant d'hôte de plus de **255 octets** n'est pas un topic Sparkplug pour
la passerelle : la publication est refusée (`0x87`) et rien n'est gardé. Une commande refusée ne crée jamais d'entrée
dans la table. Un nom de tenant qui commence par `_` est réservé à la passerelle (`_anonymous`, `_other` dans les
métriques) : une clé, un client de certificat ou une claim qui en porte un est refusé.

```yaml
observability:
  sparkplug:
    max_nodes: 20000
    max_nodes_per_tenant: 2000
    offline_retention: 72h          # a weekend without a node still shows it
    max_devices_per_node: 1000
```

## Observer

| Signal | Ce qu'il dit |
| --- | --- |
| `flexway.sparkplug.nodes{tenant, state}` | Nœuds de la table, `online` et `offline` ; un nœud `moved` n'est ni l'un ni l'autre |
| `flexway.sparkplug.sequence.gaps{tenant, kind}` | Écarts `seq` et `bdseq` |
| `flexway.mqtt.packets{type, decision, cause}` | Paquets refusés par le rôle (`not_authorized`) ou le schéma (`payload_invalid`) |
| `GET /sparkplug/nodes` | Les noms - jamais dans une métrique |

## Dépannage

| Ce que voit le nœud ou l'exploitant | Pourquoi | Où regarder |
| --- | --- | --- |
| Refus au chargement : `a x-flexway-sparkplug edge_node operation's channel is spBv1.0/{group}/+/{edge_node}/#` | Le canal n'a pas la forme du rôle, ou le groupe n'est pas lié au tenant | Le tableau des rôles |
| Refus au chargement : `writes ... in the Sparkplug namespace spBv1.0/` | Un canal en `spBv1.0/` sans `x-flexway-sparkplug` | Marquer l'opération de son rôle |
| Refus au chargement : `admits a token without scopes` | Une opération `host` en `send` admet un jeton sans portée | Nommer les portées des commandes |
| `CONNACK 0x87` d'un nœud | Son testament n'est pas son `NDEATH` (ou, pour `state`, son `STATE`) | Le testament configuré dans le nœud |
| `PUBACK 0x87` | Un type que le rôle ne publie pas (une naissance par l'hôte, une commande par le nœud), ou le groupe ou le nœud d'un autre | `POST /simulate/mqtt` avec le topic |
| `PUBACK 0x99` | Charge hors du `sparkplug_b.proto` ou du schéma OPC UA (`x-flexway-validation: full`) | La charge envoyée |
| Nœud absent de la table | Commandes passées par HTTP (non suivies), table pleine (`dropped`), réplique redémarrée | `GET /sparkplug/nodes` de chaque réplique |
| Nœud `offline` après une panne de réplique | Le `NDEATH` du courtier est vrai ; le nœud renaît ailleurs dès sa reconnexion | La console : en ligne sur une autre réplique |
