Retrospectiva sobre el desarrollo de integraciones externas

Retorno de experiencia: 11 integraciones externas y lo que nos ha costado idas y vueltas (doc, SDK, plantilla, núcleo)

Hola a todos, hola @pierre-gilles,

Desde agosto, he publicado 11 integraciones externas: UniFi, Android TV Remote, IPP, SolarEdge, Plex, Subsonic, Speedtest, ecojoko, Astronomía, Jellyfin & Emby y Dreame. El sistema de integraciones externas es realmente agradable de usar, ¡gracias!

Al revisar el historial de los repositorios y los hilos del foro, una buena parte de las versiones «correctivas» no venían de errores en mi código. Venían de cosas que Gladys espera sin que esté escrito en ninguna parte, o que la documentación dice de otra manera. La mayoría de las veces, el error era silencioso: un dispositivo aceptado pero nunca consultado, un componente de widget que desaparece, una integración ausente de la tienda. Lo agrupo todo aquí, clasificado por costo, con para cada punto lo que propongo del lado de la documentación, SDK o núcleo.

Todo ha sido verificado de nuevo hoy en master (núcleo 03d4696d, SDK 500db06, plantilla 4ea0c7b, tienda a052400), con archivo y línea en los bloques plegables. He descartado lo que ya está corregido o ya solicitado, y lo listo al final. Pienso especialmente en los tickets de @prohand (SDK #36, plantilla #19) y en el tema Dreame (#3153 a #3156).

En resumen: los 5 puntos que habrían evitado más versiones

  1. Polling: should_poll: true es obligatorio, pero ninguna especificación ni la documentación pública lo mencionan. La plantilla publica poll_frequency: 300 (en segundos, sin should_poll), y su propia página de Descubrimiento es rechazada con un 400. Costo para mí: aproximadamente 11 versiones publicadas, en 5 integraciones.
  2. min / max obligatorios en todas las funcionalidades, incluyendo text. El descubrimiento los acepta ausentes, luego «Agregar» falla con un 422. La especificación los califica como «opcionales». 4 integraciones afectadas.
  3. Pares categoría/tipo: se validan en dos listas separadas, por lo que cualquier par pasa. Se descubre en la práctica que un par no tiene etiqueta, no tiene icono, o que se trata como un sensor en lugar de un comando. 5 integraciones afectadas.
  4. Tienda: un rechazo del indexador no avisa a nadie. La razón solo está en un rejected.json del cual la documentación no da la URL. 7 integraciones afectadas, incluyendo ecojoko, que permaneció introuvable para su probador durante 2 horas.
  5. Estados publicados antes de que el dispositivo sea añadido: el núcleo responde 200, luego los descarta. Una integración que deduplica, como recomienda la documentación, nunca los envía de nuevo, por lo que «no hay valor reciente» justo después de la adición. 3 integraciones afectadas.

1. Polling: should_poll, unidades y frecuencias

Lo que Gladys realmente espera:

  • El planificador solo consulta un dispositivo si should_poll === true y si poll_frequency está definido.
  • should_poll vale false por defecto. Nada se deduce de poll_frequency, y la página de Descubrimiento publica el dispositivo tal cual.
  • Resultado: un dispositivo publicado con solo poll_frequency es aceptado, pero nunca consultado. No hay error ni registro, solo «no hay valor reciente».

Lo que dice la documentación:

  • Las especificaciones host-api-endpoints.md:40, websocket-protocol.md:12 y command-routing.md:5 describen el poll «para un dispositivo con una poll_frequency». Ningún archivo de docs/specs/external-integrations/ menciona should_poll.
  • La documentación pública (/docs/dev/external-integrations/) solo tiene una línea sobre onPoll.
  • El README del SDK en master ha ganado hoy una sección «Polling devices» (gracias @prohand), pero aún no se ha publicado en npm (0.14.0).

La plantilla muestra el ejemplo que no funciona:

  • src/config.js:19 contiene poll_frequency: 300, // segundos, retomado por weatherStation.js:45 y plug.js:64, sin should_poll.
  • El núcleo rechaza este lote con 400 devices[0].poll_frequency: invalid poll frequency. El falso Gladys del SDK en master lo reproduce.
  • La página de Descubrimiento de una integración creada desde la plantilla está por lo tanto vacía de origen. Este punto de SDK #36 ha sido corregido del lado del SDK, no en la plantilla.

Otras trampas del mismo tema:

  • Lista cerrada de 1 a 60 segundos. Nada está previsto para una frecuencia lenta (5 min, 15 min, 1 h), que sin embargo es el caso común de una API en la nube con cuota: SolarEdge, ecojoko, Speedtest. Cada integración reimplementa por lo tanto su temporizador en el contenedor. Si este es el comportamiento deseado, merece una frase en la documentación.
  • Un solo campo inválido hace rechazar todo el lote, y el SDK solo registra un error de manejador a nivel de depuración. En SolarEdge 1.0.1, el Descubrimiento estaba vacío sin ningún rastro a nivel de registro por defecto.
  • poll_frequency y should_poll nunca vuelven a pasar en un dispositivo ya creado. No forman parte de la firma de structure_changed, por lo que corregir el polling en una nueva versión no repara los dispositivos existentes (IPP, Subsonic). Y la página Dispositivos de una integración externa solo propone el nombre y la habitación: el usuario no puede corregirlo él mismo.
  • Detalle del front: el selector de frecuencia genérico (UpdateDeviceForm.jsx:57-77) no propone 15 segundos, aunque el planificador lo gestiona.

Propuestas:

  • Núcleo (lo más simple): para una integración externa, considerar should_poll = true tan pronto como una poll_frequency válida es publicada. De lo contrario, rechazar con un 400 una poll_frequency sin should_poll.
  • Núcleo: incluir should_poll y poll_frequency en la firma de structure_changed.
  • Plantilla: should_poll: true, un valor de DEVICE_POLL_FREQUENCIES en ms, y un ejemplo de temporizador interno para frecuencias lentas.
  • Documentación: citar should_poll en las tres especificaciones y en el sitio, y decir francamente «más allá de 60 segundos, temporizador en el contenedor».
  • SDK: registrar los errores de los manejadores (onScanRequest…) a nivel error, no debug.
Pruebas (master 03d4696d)
  • server/lib/device/device.add.js:37: if (device.should_poll === true && device.poll_frequency)
  • server/models/device.js:46-50: should_poll vale false por defecto
  • server/lib/external-integration/externalIntegration.setDiscoveredDevices.js:55-56: rechaza una poll_frequency fuera de la lista, no controla should_poll
  • front/src/routes/integration/all/external-integration/discover-page/index.js:136-151: publica el objeto publicado tal cual
  • externalIntegration.getDiscoveredDevices.js:274-283: la firma solo contiene campos de funcionalidad
  • Historial: UniFi 1.2.8 → 1.3.2 (5 versiones en un día), SolarEdge 1.0.1 → 1.0.3, Subsonic 1.0.2 → 1.0.3, Speedtest 1.0.1 → 1.0.2, e IPP y Plex antes de la publicación

2. min y max obligatorios, incluso para text

El constatado:

  • t_device_feature declara min, max, read_only y has_feedback en NOT NULL sin valor por defecto.
  • El descubrimiento no controla ninguno de los cuatro. El error solo llega al hacer clic en «Agregar a Gladys»: un 422. Desde #2733 al menos nombra la funcionalidad culpable, lo que ayuda.
  • La especificación dice lo contrario: host-api-endpoints.md:40 habla de «every other optional column (unit, min, max)».
  • Los tipos del SDK también dicen min?: number; max?: number.

Esto ocurrió con IPP, Plex, Subsonic y Astronomía. En IPP, los límites fueron retirados porque «inútiles para texto», luego puestos de nuevo el mismo día después del 422.

Propuestas:

  • O el núcleo pone 0/0 por defecto en la publicación, como hace Zigbee2MQTT, o rechaza con un 400 tan pronto como POST /discovered_device. En ambos casos, no más fallos tardíos.
  • Hacer min/max obligatorios en index.d.ts y corregir la frase de la especificación.

3. Pares categoría/tipo: cualquier par pasa

El constatado:

  • La detección prueba la categoría en una lista y el tipo en otra (setDiscoveredDevices.js:69-74). La unidad se controla sin mirar la categoría, y DEVICE_FEATURE_UNITS_BY_CATEGORY no se utiliza en ningún lugar del lado del servidor.
  • La documentación hace referencia a las constantes, pero los tipos genéricos (decimal, integer, binary…) se aceptan con cualquier categoría.
  • Por lo tanto, se descubre el significado de una pareja en la realidad. Lo que hemos pagado:
    • UniFi: categoría sensor inexistente, luego speed-sensor/integer a reemplazar por datarate/rate, luego has_feedback faltante. Tres versiones.
    • Android TV: button/click tratado como un sensor, por lo que los botones de la aplicación no son cliqueables (1.0.5 → 1.1.0). Fue necesario cambiar a un text/select.
    • IPP: level-sensor con un tipo genérico no tiene icono.
    • Astronomía: light-sensor/binary no tiene etiqueta, por lo que « Dispositivo (undefined) » en Descubrimiento. Reemplazado por input/binary.
    • ecojoko: la potencia publicada en energy-sensor/power con min: 0 hacía que la aguja del medidor se saliera en exceso solar. Se necesitaba grid-sensor/power, firmado, con límites simétricos. Ni el sentido « consumo » de uno ni el sentido « intercambio firmado » del otro están escritos.
  • Pequeño error de paso: front/src/utils/consts.js declara dos veces la clave LIGHT_SENSOR en DeviceFeatureCategoriesIcon (l. 189 y 271). El segundo sobrescribe al primero, por lo que light-sensor/integer ya no tiene icono.

Propuestas:

  • Validar la pareja (y la unidad por categoría) en setDiscoveredDevices.
  • Exportar desde el SDK una tabla « categoría → tipos permitidos », con cada uno: sensor o comando, read_only esperado, signo y límites típicos.
  • Hacer que todo esto sea verificado por el falso Gladys del SDK.

4. Store: rechazos silenciosos

El diagnóstico:

  • El validador del store es valioso, pero un depósito que no pasa el indexador no recibe ninguna respuesta.
  • La razón solo está en rejected.json, cuyo README del store ni el sitio web dan la URL real (https://integration-store-storage.gladysassistant.com/rejected.json). DEFAULT_STORE_BASE_URL aún apunta a GitHub Pages.
  • Lo que hemos pagado así:
    • descripción de más de 100 caracteres: ecojoko ausente del store durante 2 horas, y Pat no la encontraba;
    • placeholder no multilingüe (Speedtest);
    • portada de más de 150 Ko (Astronomía);
    • tipos text/password a renombrar a string/secret, y display_if rechazado (UniFi);
    • una versión anunciada sin imagen: la integración sale del catálogo (IPP), o la actualización no se propone y el probador debe desinstalar y luego reinstalar (Android TV 1.1.0).
  • Cadencia: la documentación anuncia un paso « cada hora » (cron 13 * * * *). En la práctica, GitHub solo ejecuta 3 a 6 pases programados por día desde finales de septiembre, por ejemplo 08:56 y luego 16:08 hoy. Es mejor anunciar « en el día ».

Propuestas:

  • Dar la URL real de rejected.json en la documentación.
  • Notificar al desarrollador: un estado de commit, o un issue abierto automáticamente en su depósito.
  • Ejecutar npx github:GladysAssistant/integration-store --skip-image-check en la CI del template. El comentario de ci.yml:15-17, que dice lo contrario, se ha vuelto obsoleto con --skip-image-check.

5. Estados publicados antes de la adición del dispositivo: 200, luego descartados

El diagnóstico:

  • POST /state responde 200 { success: true } para una funcionalidad que aún no existe.
  • El núcleo luego lo descarta con un simple logger.info (device.newStateEvent.js:16-21), y el cupo de 300 estados por minuto aún se consume.
  • Sin embargo, la documentación recomienda, con razón, publicar solo los cambios. La integración ya « ha enviado » el valor, y el dispositivo recién agregado se queda en « sin valor reciente » hasta el próximo cambio (Plex, IPP, Subsonic).
  • La solución, vaciar la caché y volver a publicar todo en onDeviceCreated, no está escrita en ningún lado.

Propuestas:

  • Decirlo en la documentación y en el template (onDeviceCreated → volver a publicar).
  • Mejor: devolver en la respuesta la lista de external_id desconocidos, para que el SDK pueda mantenerlos en espera.

6. Comportamientos silenciosos para documentar, o para registrar

« Actualizar » en la pestaña Descubrimiento (structure_changed).

  • Solo compara external_id, category, type, unit, min, max y step de cada funcionalidad, más la adición o la eliminación de una funcionalidad.
  • No se activa en los nombres, read_only, has_feedback, params, supported_options ni en la encuesta. supported_options y params se resincronizan en silencio en cada publicación.
  • La especificación (host-api-endpoints.md:52) solo dice « características añadidas/modificadas ». Una lista explícita habría evitado dos eliminaciones y recreaciones de dispositivo en el probador Dreame.
  • Para confirmar de tu lado (lo leí en el código, sin haberlo visto en la realidad): « Actualizar » parece reescribir el nombre del dispositivo elegido por el usuario con el nombre publicado (device.create.js:140-143). La habitación, en cambio, se preserva. Esto sería contrario a la especificación, que dice que el nombre y la habitación pertenecen al usuario.

Nombre de una funcionalidad sola de su tipo.

  • El tablero de control muestra la etiqueta genérica del tipo en lugar del nombre publicado, excepto para MQTT (DISPLAY_FEATURE_NAME_FOR_THOSE_SERVICES = { mqtt: true }). Lo había pedido en el tema Dreame, sin respuesta.
  • El mismo efecto en la pantalla Descubrimiento: cuatro puertos PoE UniFi se mostraban como cuatro « Conmutador » indiscernibles.
  • Una integración externa elige sus nombres: propongo agregarla a esta regla.

Idioma del usuario.

  • setValue, poll, scene.action.run y las acciones de configuración no reciben el idioma. Solo los widgets y el clima lo reciben.
  • Cada integración que produce texto agrega un campo language a su configuración (IPP, Astronomía, Jellyfin, Dreame).
  • Propongo transmitir language en estos payloads, o documentar el límite.

Cupo de 300 estados por minuto.

  • La documentación solo menciona el 429. Sería útil precisar tres cosas:
    • es el lote completo el que se rechaza;
    • un lote rechazado en 400 aún consume el cupo;
    • cada estado aceptado reevalúa las escenas.

gladys_version y actualizaciones.

  • Un núcleo antiguo rechaza cualquier campo de manifiesto desconocido. Declarar un widget fuerza gladys_version >= 5.1.0, y el índice solo guarda el último manifiesto: los núcleos más antiguos ya no tienen ninguna actualización.
  • Está documentado y es comprensible. Sin embargo, en un núcleo antiguo:
    • isUpdateAvailable / getLatestVersion solo comparan los números, por lo que la insignia « Actualización disponible » se enciende;
    • al hacer clic, el manifiesto se descarta con un simple warn (update.js:33-49) y el contenedor se recrea de la misma manera;
    • la insignia permanece encendida, sin mensaje para el usuario.
  • Las especificaciones core/store.md:23 y contracts/management-api.md:13 afirman que el catálogo está « filtrado por gladys_version ». Propongo probar la compatibilidad en isUpdateAvailable.

Energía.

  • Solo un energy-sensor/index acumulado activa el consumo de 30 minutos y el costo. Un energy-production-sensor/index no deriva nada: la funcionalidad thirty-minutes-production no es creada por nadie.
  • host-api-endpoints.md:63 cita los controles deslizantes de producción, lo que lleva a creer lo contrario. ecojoko publica un índice de producción para los productores solares, y solo sirve para el historial.

7. Widgets (5.1): lo que desaparece sin previo aviso

La capacidad es excelente, y el validador del SDK ya atrapa muchas cosas. Quedan algunos agujeros:

  • El presupuesto de 8 componentes se aplica antes de resolver las referencias (getWidgetContent.js:62-69).
    • Una baldosa vinculada a un dispositivo aún no añadido ocupa un espacio y luego se retira. Puede haber desplazado a un componente válido.
    • El resultado mutilado permanece en caché hasta el TTL (hasta 1 hora), ya que la adición del dispositivo no invalida la caché.
    • Por lo tanto, es necesario pensar en llamar a requestWidgetRefresh en onDeviceCreated: debe documentarse o, mejor aún, invalidarse en el núcleo.
  • Botón cuya clave de acción ya está tomada: se descarta con un warn en el lado del servidor, y la integración no sabe nada. En Dreame, solo se mostraba un atajo de tres (0.3.0 → 0.4.0). La especificación dice « único », pero no dice que el duplicado se elimina.
  • Línea status sin value: se descarta sin ningún registro.
  • Toast de acción: se trunca a 200 caracteres por slice, sin elipsis.
    • Un objeto multilingüe sin clave en no da ningún toast.
    • MAX_WIDGET_MESSAGE_LENGTH existe en el SDK, pero nada lo usa.
  • card-list: la date reemplaza el subtítulo, en lugar de añadirse. La especificación dice « subtitle or date »; debería especificarse « la fecha prevalece » (Jellyfin, Plex).
  • validateWidgetContent solo funciona en modo debug. Propongo ejecutarlo en cada onWidgetGet con un warn en el lado de la integración, para que el desarrollador vea lo que el núcleo va a retirar.

8. Plantilla y documentación pública

  • .gitignore y .prettierignore: la regla data/, prevista para el volumen /data, también excluye src/data/. La carpeta entonces falta en la imagen construida por la CI (Astronomía, antes de la 1.0.0). Debe anclarse en /data/.

  • Campos number decimales: se ha corregido en master (#3167, step="any"), pero aún no se ha publicado.

    • En la 5.1.4, el ejemplo de latitud/longitud de la plantilla (48.8566) sigue siendo imposible de ingresar. Astronomía perdió una versión por esto.
    • El esquema del manifiesto aún rechaza step: no se puede declarar una resolución.
  • Documentación pública:

    • anuncia el SDK 0.12.0, mientras que npm está en 0.14.0;
    • describe el antiguo flujo de lanzamiento, sin CHANGELOG ni lanzamiento de GitHub;
    • no dice que « Ver el changelog de esta versión » abre el lanzamiento de GitHub de la etiqueta;
    • aún no habla del falso Gladys.
    • La plantilla ahora hace todo esto (#20, gracias): solo falta la página.
  • Publicar el SDK 0.15: el falso Gladys y la documentación del polling esperan en master. Este falso Gladys podría convertirse en la red de seguridad si también verifica:

    • poll_frequency sin should_poll;
    • la ausencia de min/max;
    • los pares categoría/tipo;
    • el cupo de 300 estados por minuto;
    • la longitud de los toasts.

    Hoy, { poll_frequency: 60000 } sin should_poll, con una funcionalidad level-sensor/decimal sin límites, responde { success: true }.

Ya corregido o ya solicitado: no lo vuelvo a solicitar

  • Botón primary en modo oscuro (#3153 → #3162), secret y default en las acciones (#3154 → #3163, #3155 → #3164), listas del aspirador y supported_options (#3156 → #3171), campos number decimales (#3167). Todo está en master, nada está aún en una versión publicada.
  • SDK #36 y plantilla #19 de @prohand: unidad de poll_frequency, falso Gladys, lanzamiento que rompía Prettier, lanzamiento de GitHub y changelog, límite de 100 caracteres, placeholder multilingüe.
  • Límite de 200 dispositivos por descubrimiento, elevado en agosto.

¡Gracias por leer o hacer que Claude lo haga! :wink: !

2 Me gusta