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.
ConceptExploitationDepuis la version 1.7.0
Sur cette page
Guide d'exploitation : configurer, déployer et observer la passerelle. Ce que fait la fonctionnalité côté produit : guide produit ; brancher un nœud ou un éditeur : guide développeur.
Sur l'écouteur MQTT des appareils, 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é.
# 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# 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_idest le nom du tenant (spBv1.0/STATE/acme). Le topicSTATEn'a pas de groupe ; lié à autre chose, deux tenants partageraient un mêmeSTATE. - Les commandes sont des mutations : avec un jeton, chaque alternative de la
securityd'une opérationhostensendnomme ses portées (refusé au chargement sinon) ; une clé ou un certificat sont liés à l'opération. - Un tenant nommé
STATEn'a jamais d'opération Sparkplug : son canal d'hôte couvrirait leSTATEde tous les tenants. - Charge : une opération Sparkplug sans message reçoit la charge B sous le
sparkplug_b.protoembarqué, ou le schéma JSON deSTATE. Avecx-flexway-validation: full, chaque charge y est vérifiée (0x99sinon) ; 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 :
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
seqqui n'est pas le suivant, ou lebdSeqd'une naissance différent de celui de son testament, sont comptés - jamais bloquants. - Commandes :
NCMD/DCMDadmises 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 HTTPx-flexway-publishne sont pas suivies. - Applications hôtes : leur
STATEest lu de leur publication, de leur testament et duSTATEretenu remis aux appareils du tenant. - Bornes : pleine, la table oublie d'abord un nœud
unknown, puis le plus ancienofflineoumoved; pleine de nœuds en ligne, un nouveau n'est pas gardé et il est compté (dropped). Elle est perdue au redémarrage.
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).
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.
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.
# 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"# 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. Ceux de la console (cinq règles intégrées,
devices.readerBounds) : Console : l'écran Appareils. 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é.
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: 1000Observer
| 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 |