flexway
FRENParler à un expert

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

Markdown

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_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 :

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.
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: 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

Étapes suivantes