Guía de integración · Ingeniería frontend

Commissioning Zigbee sin estados imaginarios.

Alta y sustitución de dispositivos desde modo instalador: endpoint a endpoint, con feedback MQTT, polling autoritativo y cierre seguro del dispositivo anterior.

API V1 permit_join · 120 s polling · 5 s MQTT no retained actualizado · 23 jul 2026
01

El contrato en 90 segundos

El endpoint trabaja sobre un slot lógico que ya existe en BMS. No descubre una habitación ni inventa una categoría: reemplaza o completa el nodo físico asociado a ese slot.

1Slot existenteSe identifica con exactamente uno de device_uuid, device_ieee o device_base58.
2Canales paralelosMQTT muestra la entrevista en tiempo real; HTTP conserva y confirma el estado operativo.
3Cierre transaccionalEl dispositivo anterior se elimina de Zigbee2MQTT y se hace soft-delete en BMS solo al final.
new_device El slot ya existe, pero no tiene IEEE activo. Requiere room_uuid. El IEEE nuevo se detecta durante el pairing.
replacement El slot tiene IEEE activo. La room se deriva del slot y se exige new_device_ieee para aceptar solo el equipo esperado.
join_backend Es opcional. auto es el camino normal; coordinator y device son herramientas de recuperación.

No implementar MQTT de escritura. El frontend no publica permit_join, no renombra dispositivos y no acepta/rechaza candidatos. Todo eso pertenece al backend.

02

Flujo completo

La operación HTTP, el job y el feed MQTT avanzan juntos. El frontend debe abrir el canal de eventos antes de iniciar el pairing para no perder los primeros mensajes.

sequenceDiagram autonumber participant UI as Frontend instalador participant API as BMS API participant JOB as Job de join participant Z2M as Zigbee2MQTT participant EMQX as EMQX participant ACT as Activity UI->>API: GET /installer/rooms/{roomUuid}/devices UI->>API: GET /buildings/{buildingUuid}/jwt UI->>EMQX: SUB {hubUuid}/installer/join-events UI->>API: POST /installer/hubs/{hubUuid}/join-requests API-->>UI: 202 + operation_uuid + Retry-After: 5 API->>JOB: Encola operación JOB->>Z2M: GET bridge/devices (snapshot) JOB->>Z2M: request/permit_join (120 s) Z2M-->>EMQX: device_joined EMQX-->>UI: evento efímero EMQX-->>API: POST interno /hubs/join-events API->>ACT: installer.join_request.event_received Z2M-->>EMQX: device_interview started Z2M-->>EMQX: device_interview successful / failed EMQX-->>UI: progreso de entrevista UI->>API: GET join-request cada 5 s alt entrevista correcta JOB->>Z2M: rename from IEEE nuevo to topic canónico JOB->>API: crea o reutiliza Device por IEEE JOB->>Z2M: remove IEEE anterior JOB->>API: soft-delete del Device anterior API-->>UI: status=succeeded + joined_device else fallo o timeout JOB->>Z2M: limpia nodo parcial cuando aplica API-->>UI: status=failed + error end
Ver fuente Mermaid
sequenceDiagram
    autonumber
    participant UI as Frontend instalador
    participant API as BMS API
    participant JOB as Job de join
    participant Z2M as Zigbee2MQTT
    participant EMQX as EMQX
    participant ACT as Activity

    UI->>API: GET /installer/rooms/{roomUuid}/devices
    UI->>API: GET /buildings/{buildingUuid}/jwt
    UI->>EMQX: SUB {hubUuid}/installer/join-events
    UI->>API: POST /installer/hubs/{hubUuid}/join-requests
    API-->>UI: 202 + operation_uuid + Retry-After: 5
    API->>JOB: Encola operación
    JOB->>Z2M: GET bridge/devices (snapshot)
    JOB->>Z2M: request/permit_join (120 s)
    Z2M-->>EMQX: device_joined
    EMQX-->>UI: evento efímero
    EMQX-->>API: POST interno /hubs/join-events
    API->>ACT: installer.join_request.event_received
    Z2M-->>EMQX: device_interview started
    Z2M-->>EMQX: device_interview successful / failed
    EMQX-->>UI: progreso de entrevista
    UI->>API: GET join-request cada 5 s
    alt entrevista correcta
        JOB->>Z2M: rename from IEEE nuevo to topic canónico
        JOB->>API: crea o reutiliza Device por IEEE
        JOB->>Z2M: remove IEEE anterior
        JOB->>API: soft-delete del Device anterior
        API-->>UI: status=succeeded + joined_device
    else fallo o timeout
        JOB->>Z2M: limpia nodo parcial cuando aplica
        API-->>UI: status=failed + error
    end
03

Preparar contexto y MQTT

Antes del pairing hacen falta los UUID públicos, el slot, el hub y una suscripción MQTT de solo lectura.

Bases recomendadas: producción https://api.voltic.es/api/v1/bms, desarrollo https://api-dev.voltic.es/api/v1/bms y local http://localhost:8090/api/v1/bms. Las llamadas requieren bearer o sesión autenticada.

GET/installer/rooms/{roomUuid}/devices

Obtener slots y hub de la habitación

Usar data[] para mostrar los slots y context.hub.uuid como hubUuid. Para recuperación con router explícito, ofrecer únicamente otros dispositivos con categoría switch, thermostat o repeater.

const room = await api.get(`/installer/rooms/${roomUuid}/devices`);
const slots = room.data;
const hubUuid = room.context.hub.uuid;
GET/hubs/{hubUuid}?fields=cloud_mqtt_server

Resolver broker cloud si el cliente aún no lo conoce

El host se devuelve en cloud_mqtt_server. En browser se usa la URL WebSocket configurada por la webapp; para herramientas MQTT TLS se utiliza el puerto 8883.

GET/buildings/{buildingUuid}/jwt

Obtener credencial MQTT de una hora

La implementación devuelve {"jwt":"..."}. Ese JWT incluye permiso de suscripción a {hubUuid}/installer/join-events para roles installer, maintenance_technician, admin y super_admin, sujeto a las features MQTT del plan.

{
  "jwt": "eyJhbGciOiJIUzUxMiIs..."
}

Suscripción obligatoria antes del POST

const topic = `${hubUuid}/installer/join-events`;
const mqttClient = connectToConfiguredBroker({
  username: authenticatedUser.username,
  password: buildingJwt.jwt,
});

mqttClient.subscribe(topic);
mqttClient.on("message", (receivedTopic, payload) => {
  if (receivedTopic !== topic) return;
  renderJoinEvent(JSON.parse(payload.toString()));
});

El feed es no retained. Si la pantalla se suscribe tarde o se reconecta, recupera el estado mediante el GET de la operación; MQTT no es un historial.

04

Crear la operación

El body contiene el modo y exactamente un identificador del slot. Una operación activa en el hub puede ser reutilizada por el backend; guardar siempre el operation_uuid realmente devuelto.

POST/installer/hubs/{hubUuid}/join-requests

Alta sobre slot vacío: new_device

{
  "mode": "new_device",
  "room_uuid": "ROOM_UUID",
  "device_uuid": "EMPTY_SLOT_UUID"
}

También puede identificarse el slot con device_base58 al llegar desde QR, o con device_ieee cuando exista un identificador compatible. No enviar más de uno.

Sustitución: replacement

{
  "mode": "replacement",
  "device_ieee": "0x94b216fffe90b13d",
  "new_device_ieee": "0x583bc2fffe9021f1"
}

new_device_ieee debe ser un IEEE completo con forma 0x + 16 hexadecimales y distinto del actual. La room se deriva del slot.

Respuesta inicial

HTTP/1.1 202 Accepted
Retry-After: 5

{
  "data": {
    "operation_uuid": "019f8e9d-928f-70e3-8481-d2c3af57310c",
    "mode": "replacement",
    "new_device_ieee": "0x583bc2fffe9021f1",
    "requested_backend": {
      "type": "auto",
      "device_uuid": null
    },
    "status": "queued",
    "message": "Join request queued.",
    "selected_backend": null,
    "attempts": 0,
    "retry_after_seconds": 5
  }
}

Selección avanzada del backend

ValorComportamientoUso de frontend
autoEscoge el mejor router disponible de la room por orden switch → thermostat → repeater; coordinador si no hay router.Predeterminado. No hace falta enviarlo.
coordinatorFuerza una única ventana a través del coordinador.Mostrar como opción avanzada tras un fallo de reconexión.
deviceFuerza el router indicado en join_backend_device_uuid. Debe ser otro dispositivo de la misma room y hub y aparecer como Router en Zigbee2MQTT.Mostrar un selector filtrado. No hay fallback si no está disponible.
{
  "mode": "replacement",
  "device_ieee": "OLD_IEEE",
  "new_device_ieee": "NEW_IEEE",
  "join_backend": "device",
  "join_backend_device_uuid": "ROUTER_DEVICE_UUID"
}
05

Tiempo real y polling

No existe una actualización HTTP distinta para cada fase. El frontend compone la experiencia con los eventos MQTT y confirma el resultado mediante polling.

Eventos del feed MQTT

{
  "operation_uuid": "019f8e9d-928f-70e3-8481-d2c3af57310c",
  "type": "device_interview",
  "status": "successful",
  "ieee_address": "0x583bc2fffe9021f1",
  "friendly_name": "0x583bc2fffe9021f1",
  "supported": true,
  "model": "Frostline 2",
  "vendor": "Voltic",
  "reported_at": "2026-07-23T10:56:12Z"
}
EventoCopy recomendadoAcción
device_joined“Dispositivo Zigbee detectado.”Mantener la operación abierta.
device_interview / started“Identificando modelo y capacidades…”Mostrar progreso indeterminado.
device_interview / successful“Dispositivo identificado. Finalizando configuración…”No declarar éxito todavía; esperar GET succeeded.
device_interview / failed“No se ha podido identificar el dispositivo.”Esperar estado terminal y mostrar error.message.
GET/installer/hubs/{hubUuid}/join-requests/{operationUuid}

Consultar estado autoritativo cada 5 segundos

Usar retry_after_seconds y/o el header Retry-After. Detener el polling en succeeded o failed.

async function pollJoin(hubUuid, operationUuid) {
  while (true) {
    const { data } = await api.get(
      `/installer/hubs/${hubUuid}/join-requests/${operationUuid}`
    );

    updateScreen(data);
    if (data.status === "succeeded" || data.status === "failed") return data;

    await wait((data.retry_after_seconds ?? 5) * 1000);
  }
}

queued

Operación persistida, todavía pendiente de worker.

running

El job está procesando snapshot, permit join, entrevista o configuración final.

succeeded

Rename, persistencia y cleanup obligatorio han terminado correctamente.

failed

Mostrar error.message y permitir un nuevo intento, opcionalmente con backend explícito.

join_event en el GET contiene solo el último evento reducido. La secuencia completa queda auditada en Activity como installer.join_request.event_received, correlacionada por operation_uuid.

06

Qué significa “succeeded”

La entrevista correcta no basta. El backend solo debe cerrar con éxito después de configurar el topic nuevo y limpiar el dispositivo sustituido.

01

Entrevista correcta

Se acepta únicamente el IEEE esperado en replacement.

02

Topic canónico

Zigbee2MQTT recibe rename con from = IEEE nuevo y to = ruta del slot + IEEE nuevo. No se renombra temporalmente el anterior.

03

Device nuevo

Se crea un registro BMS o se reutiliza el existente si ese IEEE ya estaba en la base de datos.

04

Cleanup anterior

Se fuerza la eliminación del IEEE anterior en Zigbee2MQTT. Solo después se hace soft-delete de su fila BMS.

05

Respuesta terminal

joined_device devuelve IEEE, friendly name descubierto, fabricante, modelo, tipo y topic final.

{
  "status": "succeeded",
  "message": "Join request completed successfully.",
  "joined_device": {
    "ieee_address": "0x583bc2fffe9021f1",
    "friendly_name": "hotel/room/1132/bed/thermostat/0x583bc2fffe9021f1",
    "discovered_friendly_name": "0x583bc2fffe9021f1",
    "manufacturer": "Voltic",
    "model": "Frostline 2",
    "type": "Router",
    "topic": "hotel/room/1132/bed/thermostat/0x583bc2fffe9021f1"
  },
  "error": null
}

V1 no añade el IEEE anterior a una blocklist. Si la eliminación en Zigbee2MQTT falla, la operación debe quedar failed; el registro anterior no se elimina de BMS antes de tiempo.

07

Errores y decisiones de UI

Hay errores síncronos del request y errores asíncronos del pairing. Se presentan de forma distinta.

HTTP / estadoCausa habitualRespuesta frontend
202Operación creada o activa reutilizada.Guardar el UUID devuelto y abrir polling.
400UUID de ruta mal formado.Bloquear acción y registrar error de navegación/contexto.
401Sesión o bearer inválido.Renovar sesión; no reintentar pairing automáticamente.
404Hub, room, slot u operación inexistente o sin acceso.Recargar inventario y mostrar pérdida de acceso.
409Modo incompatible: slot ocupado en new_device, vacío en replacement, o sin topic/category.Proponer el modo correcto o completar configuración del slot.
422Body inválido, identificadores múltiples, IEEE nuevo ausente o backend explícito incoherente.Mostrar el mensaje de details[field] junto al control.
500Error inesperado al crear/leer el recurso.Mostrar reintento seguro solo si no se recibió operation_uuid.
failedTimeout, interview fallida, router explícito no disponible, rename o cleanup fallido.Mostrar error.message. Permitir nueva operación; ofrecer selección avanzada de backend.

Evitar duplicados

Si se pierde la respuesta del POST, consultar primero el estado visible del hub o repetir el POST sabiendo que el backend reutiliza la operación activa del hub. Nunca lanzar operaciones en paralelo desde dos pantallas para el mismo hub.

08

Cliente frontend mínimo

Una función para crear, una para consultar y un listener MQTT. No hace falta modelar el protocolo Zigbee2MQTT en la webapp.

type JoinBackend =
  | { type?: "auto" }
  | { type: "coordinator" }
  | { type: "device"; deviceUuid: string };

type ReplacementInput = {
  hubUuid: string;
  currentDeviceIeee: string;
  newDeviceIeee: string;
  backend?: JoinBackend;
};

async function replaceDevice(input: ReplacementInput) {
  const backend = input.backend ?? { type: "auto" as const };

  const body = {
    mode: "replacement",
    device_ieee: input.currentDeviceIeee,
    new_device_ieee: input.newDeviceIeee,
    ...(backend.type !== "auto" && { join_backend: backend.type }),
    ...(backend.type === "device" && {
      join_backend_device_uuid: backend.deviceUuid,
    }),
  };

  const response = await api.post(
    `/installer/hubs/${input.hubUuid}/join-requests`,
    body
  );

  return response.data;
}

Modelo de pantalla recomendado

A

Preparado

Slot, IEEE nuevo y broker resueltos; feed MQTT conectado.

B

Esperando dispositivo

POST aceptado, queued/running, todavía sin device_joined.

C

Identificando

Eventos device_joined e interview started.

D

Finalizando

Interview successful, backend renombrando, persistiendo y limpiando.

E

Completado o error

Solo depende de succeeded/failed en el GET autoritativo.

09

Prueba manual de aceptación

La prueba completa necesita ver los tres planos a la vez: UI/feed, API y Zigbee2MQTT.

  • Suscribirse al feed {hubUuid}/installer/join-events antes del POST.
  • Crear la operación y conservar operation_uuid; comprobar 202 y Retry-After: 5.
  • Poner únicamente el IEEE esperado en pairing dentro de la ventana de 120 segundos.
  • Ver device_joined → interview started → interview successful en el feed.
  • Ver el último evento reflejado en join_event durante el polling.
  • Confirmar succeeded y el topic nuevo terminado en el IEEE nuevo.
  • Confirmar en Zigbee2MQTT que el anterior fue eliminado y el nuevo conserva el friendly name canónico.
  • Confirmar en BMS que el nuevo Device está activo y el anterior tiene soft-delete.
  • Repetir un fallo controlado para validar timeout, copy de error y reintento con coordinador/router explícito.

Comandos de observación

# Feed reducido para frontend
mosquitto_sub -h "$MQTT_HOST" -p 8883 \
  -u "$MQTT_USER" -P "$MQTT_JWT" \
  -t "$HUB_UUID/installer/join-events" -v

# Operación autoritativa
curl -sS -H "Authorization: Bearer $API_TOKEN" \
  "$API_BASE/installer/hubs/$HUB_UUID/join-requests/$OPERATION_UUID"

No considerar correcta una prueba que solo vea la entrevista en Zigbee2MQTT. Deben verificarse también el rename, la fila BMS nueva/reutilizada, la eliminación Zigbee del anterior y el estado HTTP terminal.