Saltar al contenido principal
Versión: Next

Escribir un widget personalizado

Un widget personalizado es un componente de React más un pequeño fragmento de datos simples que lo describe. Escribes ambos con @devicechain/widget-sdk, los distribuyes en un paquete de npm y registras el paquete en la aplicación que muestra los paneles. Los widgets integrados se declaran con exactamente la misma interfaz, así que un widget personalizado obtiene el mismo enlace de datos, la misma validación de opciones y el mismo manejo de errores.

Tu widget recibe datos ya resueltos. No autentica, no resuelve fuentes de datos, no hace sondeos ni abre una conexión de telemetría. El anfitrión hace todo eso y entrega a tu componente una instantánea.

Un paquete completo que puedes copiar está en el repositorio, en examples/widget-tank-level. Se compila fuera del monorepo contra tarballs empaquetados del SDK.

Instala el SDK​

npm install @devicechain/widget-sdk @devicechain/dashboards react

En tu paquete de widgets, declara @devicechain/widget-sdk, @devicechain/dashboards y react como dependencias peer, nunca como dependencias. El SDK define los contextos de React que lee un widget (selección, zona horaria, raíz del tema), y un contexto se identifica por el objeto que lo creó. Por eso el anfitrión y cada paquete de widgets deben resolver una sola copia del SDK.

La entrada raíz del SDK no contiene código de gráficos ni de mapas. Un widget que solo dibuja DOM importa desde la raíz y no añade al empaquetado del anfitrión ni Apache ECharts, ni MapLibre, ni los widgets integrados. Las utilidades de gráficos (EChart, buildLineOption, buildGaugeOption, buildCompassOption, useChartTheme, resolveChartTheme y severityFill) están en @devicechain/widget-sdk/echarts, y echarts es una dependencia peer opcional que solo necesita un paquete que importe esa entrada.

Declara un widget​

import { WidgetFrame, defineWidget, formatValue, pickSample } from '@devicechain/widget-sdk';

export const tankLevelWidget = defineWidget({
descriptor: {
id: 'acme/tank-level',
version: '1.0.0',
sdkApiVersion: 1,
optionsVersion: 1,
display: { title: 'Tank level', category: 'Telemetry' },
channel: 'measurement',
datasource: { mode: 'required', selectors: ['device', 'slot'] },
binding: { window: { option: 'window', min: 1, default: 1 } },
options: {
unit: { kind: 'string', doc: 'Unit shown beside the level.', label: 'Unit' },
window: { kind: 'number', integer: true, min: 1, doc: 'Samples kept.', label: 'Samples kept' },
},
defaults: { unit: '%', window: 1 },
interactions: {},
},
implementation: {
kind: 'trusted-react',
component: ({ data }) => (
<WidgetFrame title="Tank level">{formatValue(pickSample(data.latest, undefined)?.value, 1)}</WidgetFrame>
),
},
});

defineWidget infiere el canal y el esquema de opciones a partir del literal. Un componente cuyos datos no coinciden con el canal, valores por defecto que no coinciden con el esquema, un enlace que nombra una opción inexistente o una interacción que el canal no puede transportar son errores de compilación. El registro vuelve a comprobar las mismas reglas cuando el anfitrión lo construye, para quienes eluden el compilador.

El descriptor​

El descriptor son datos simples serializables como JSON. No contiene componentes, callbacks, URL de importación ni tokens, así que el analizador de definiciones y los validadores pueden leerlo sin cargar ni confiar en tu código.

CampoSignificado
idUn id con espacio de nombres, scope/name, como acme/tank-level, en letras minúsculas, dígitos y guiones. Los ids sin barra están reservados para los widgets integrados. En esta versión la propiedad del espacio de nombres es una convención entre operadores; no demuestra quién publicó un paquete.
versionLa versión de este widget, como MAJOR.MINOR.PATCH. Es procedencia. Consulta versiones y compatibilidad de widgets.
sdkApiVersionLa generación del contrato del SDK para la que se escribió el widget. Actualmente 1.
optionsVersionLa forma de las opciones almacenadas. Increméntala solo cuando las opciones almacenadas dejen de significar lo mismo.
displaytitle y, opcionalmente, description y category, para el menú Añadir de un anfitrión. titleId y categoryId son ids de mensaje opcionales, descritos en etiquetas y textos de ayuda.
channelQué datos consume el widget. Ver abajo.
datasource{ mode: 'none' }, o mode: 'optional' o 'required' con los selectors que un autor puede elegir: device, anchor o slot. Un widget del canal control apunta a un único dispositivo, así que solo ofrece device y slot.
bindingCómo se suscribe el anfitrión, escrito como constantes y referencias a tus propias claves de opción.
optionsEl esquema de opciones.
constraintsReglas opcionales que abarcan más de una opción, como greater-than.
defaultsLas opciones iniciales de un widget recién añadido.
interactionsLos callbacks del anfitrión que el widget solicita.
freshnessOpcional, true para activarlo. Solo en el canal measurement: el marco muestra la antigüedad de la muestra más reciente del widget. Si el widget declara además una opción numérica staleAfterSeconds, un valor más antiguo que ese umbral se atenúa y se marca como obsoleto. Sin esa opción solo se muestra la antigüedad y nunca se marca nada como obsoleto. Un widget que muestra un único valor pasa la hora de esa muestra como <WidgetFrame sampleTime={sample?.occurredTime ?? null}>, para que la antigüedad sea la del valor en pantalla y no la de la última medición recibida.
layoutLímites de tamaño opcionales, en celdas de la cuadrícula. Consulta Tamaño.

Canales y política de enlace​

Un widget declara un canal:

CanalEl componente recibePolítica de enlace
measurementlatest por nombre de medición, una ventana móvil samples y un error; members en un widget de flotawindow: cuántas muestras conservar. windowSeconds: hace que el widget dibuje en su lugar un lapso de tiempo (consulta el intervalo de tiempo del panel). members: lo convierte en un widget de flota (ver más abajo)
aggregateaggregates (el recuento, el mínimo, el máximo, la suma y la media de cada medición en el lapso, o por qué no se pudieron calcular) y la lectura en vivo más reciente de cada mediciónwindowSeconds: el lapso que cubren las cifras
alarmLas filas de alarma actuales, un recuento total e indicadores de carga y errorpageSize y, opcionalmente, los filtros state, severity y acknowledged
controlEl historial de comandos de un dispositivo destinopageSize
locationMuestras de ubicación de la entidad enlazadaninguna
selectionSin datos. El widget gobierna un slot a partir de la búsqueda de candidatos del anfitrión.ninguna
noneSin datos. Un widget estático.ninguna

Un valor de enlace es una constante, { const: 25 }, o una referencia a una clave de opción, { option: 'window', min: 1, default: 1 }. La referencia se comprueba contra el tipo de la opción al compilar. No puedes aportar una cadena GraphQL ni una función de suscripción: el transporte es del anfitrión. Los canales selection y none no abren ningún canal de datos y deben declarar datasource: { mode: 'none' }.

Un widget measurement que declara members lee su fuente de datos dispositivo a dispositivo: enlazado a un grupo, data.members lista cada dispositivo con su propia lectura más reciente por medición, de modo que dos dispositivos que informan del mismo nombre se mantienen separados. { const: true } activa el modo siempre, y una referencia a una opción con equals lo activa para un valor de una opción enum, como hace el modo una barra por dispositivo del gráfico de barras. Un widget measurement o aggregate sin ese modo muestra un solo dispositivo: ofrece solo device y slot como selectores, porque el anfitrión rechaza un grupo que le llegue, con una frase que remite a la tabla de flota, en lugar de mostrar el dispositivo que informó en último lugar.

Cuando un slot no tiene dispositivo, o un dispositivo enlazado se elimina, el anfitrión muestra su estado estándar de sin enlazar o no disponible y no entrega a tu componente los datos de la entidad anterior.

Opciones​

Una opción tiene un kind (string, number, boolean, enum o list) y una frase doc. Un número puede declarar min, max e integer. Un enum declara sus values. Una cadena puede declarar format: 'json', que exige que el valor se pueda analizar como JSON. required marca una opción cuya ausencia deja al widget sin nada que mostrar; una cadena vacía cuenta como ausente. El anfitrión valida las opciones almacenadas contra el esquema, y la consola genera su formulario de ajustes a partir de él.

Una opción list almacena un arreglo de objetos pequeños, como los umbrales de un widget. Declara un objeto item cuyos campos son especificaciones string, number, boolean o enum (un campo puede ser required, y una lista no se puede anidar) un maxItems opcional y ordered: true cuando el orden de las entradas significa algo (el formulario de ajustes ofrece entonces subir y bajar cada fila). Se rechaza una entrada almacenada con un campo que item no declara, y el formulario de ajustes muestra una fila por entrada con un control por campo. La opción integrada thresholds, que llevan la mayoría de los widgets de telemetría integrados, es una de ellas: cada entrada nombra una severidad (normal, warning, minor, major o critical), nunca un color, de modo que el tema decide su aspecto en claro y en oscuro. Solo los niveles anómalos tienen color: un valor en una banda normal se ve exactamente igual que sin umbrales, de modo que una lectura sana nunca se colorea.

optThresholds, bandAt y thresholdBands leen esa misma opción en tu propio widget, y useSeverityColors da el color del tema para cada nivel anómalo.

Lee las opciones de forma defensiva con los ayudantes del SDK optString, optNumber y optBoolean. Ante un valor editado a mano del tipo equivocado no lanzan una excepción: optString y optNumber devuelven undefined, y optBoolean devuelve false para cualquier cosa que no sea un true almacenado.

Etiquetas y textos de ayuda​

Da a cada opción un label portable y, cuando una frase ayude, un help. Un anfitrión que nunca ha oído hablar de tu widget muestra ese texto tal cual, de modo que tus opciones se entienden sin ninguna traducción en el anfitrión. Cada texto también puede llevar un id de mensaje que el anfitrión busca primero en sus propias traducciones:

TextoTexto portableId de mensaje opcional
Etiqueta de la opciónlabellabelId
Ayuda de la opciónhelphelpId
Opciones de un enumvalueLabels, por valorvalueLabelIds, por valor
La entrada sin valor de un enumplaceholderplaceholderId
Título y categoría del widgetdisplay.title, display.categorydisplay.titleId, display.categoryId

La consola usa el id de mensaje cuando sus propias traducciones lo definen y, si no, tu texto portable. Una etiqueta sin ninguno de los dos recurre a la clave de la opción hecha legible (maxRows se muestra como «Max rows»), y una opción de enum sin ninguno muestra su valor tal cual. Un id de mensaje que el anfitrión no define no es un error, así que tu paquete funciona en un anfitrión que nunca lo ha visto.

options: {
show: {
kind: 'enum',
values: ['bar', 'number'],
doc: 'Whether the card draws the fill bar under the number.',
label: 'Show',
valueLabels: { bar: 'Number and fill bar', number: 'Number only' },
placeholder: 'Number and fill bar',
},
},

Controles de edición especializados​

Una opción puede nombrar un flujo de edición especializado en su campo control. No tiene relación con el canal de datos control. Sin él, la opción recibe el campo genérico de su tipo: un cuadro de texto, un cuadro numérico, una casilla o una lista desplegable.

El vocabulario es abierto. Cualquier cadena no vacía se registra, y un anfitrión que no implementa el control nombrado muestra en su lugar el campo genérico de esa opción. Así, un paquete sigue siendo instalable en un anfitrión más antiguo que los controles que nombra. Nombra tus propios controles en tu espacio de nombres, como acme/capacity-presets. Los nombres sin prefijo pertenecen al anfitrión.

La consola implementa los controles enumerados en HOST_OPTION_CONTROLS, exportado por @devicechain/dashboards:

ControlFlujo
command-vocabularyElige uno de los comandos del dispositivo enlazado y escribe juntos su clave, su etiqueta y sus parámetros.
slot-targetElige uno de los slots del panel.
device-typeElige un tipo de dispositivo del catálogo del inquilino y guarda su token. En una opción de texto o en un campo de texto de un elemento de lista.
basemapMarca la fuente de teselas ráster del mapa y su atribución.

Un control que la consola no implementa, incluido cualquier control con espacio de nombres, recibe el campo genérico. Para dar a tus opciones una experiencia de edición propia, distribuye un editor.

Un editor personalizado​

implementation.editor es un componente de React opcional. Cuando un widget tiene uno, la consola lo muestra en lugar de los campos de opción generados. Los campos de la fuente de datos y los avisos de la consola siguen siendo de la consola. El editor recibe WidgetEditorProps, exportado por @devicechain/widget-sdk:

PropSignificado
optionsLas opciones almacenadas del widget, de solo lectura.
setOptions(patch)La única forma de cambiarlas. Varias claves en una llamada se aplican juntas.
servicesServicios del anfitrión, pasados de forma explícita. Ver abajo.
descriptorEl descriptor de tu registro, para las etiquetas, los límites y los valores de enum de sus opciones.

setOptions se comprueba contra tu esquema de opciones antes de aplicarse. Una clave que el esquema no declara, o un valor del tipo equivocado, lanza una excepción en el momento en que el editor comete el error, en lugar de almacenarse sin que nada la lea. La consola captura ese error y lo muestra junto a tu editor, y no se escribe nada. undefined y una cadena vacía eliminan una clave, igual que false en un booleano. Un número fuera de los límites declarados se ajusta a ellos.

services (WidgetEditorServices) contiene:

ServicioSignificado
slotsLos slots del panel, por nombre. Vacío en un panel sin slots.
deviceTokenEl token del dispositivo al que el widget está enlazado directamente, cuando lo está.
commandChoices(deviceToken)Los comandos que acepta un dispositivo. Opcional: solo se proporciona cuando tu descriptor declara el control command-vocabulary en una opción, y un anfitrión sin vocabulario de comandos lo omite.

Un editor no recibe hub, ni cliente, ni token de acceso, y no debe importar nada de la aplicación anfitriona. Un servicio que el anfitrión no puede proporcionar está ausente, y tu editor debe tolerarlo. Si el editor lanza una excepción al mostrarse, la consola lo sustituye por un mensaje de error y un botón para reintentar, y se conservan los demás cambios del autor en el panel.

implementation: {
kind: 'trusted-react',
component: TankLevelCard,
editor: function TankLevelCardEditor({ options, setOptions, descriptor }) {
return (
<label>
{descriptor.options.unit?.label ?? 'unit'}
<input value={options.unit ?? ''} onChange={(e) => setOptions({ unit: e.target.value })} />
</label>
);
},
},

Dentro de defineWidget, options y setOptions se tipan a partir de tu esquema de opciones, así que una clave mal escrita también es un error de compilación. El widget de tarjeta del paquete de ejemplo tiene un editor completo.

Tamaño​

Un widget declara qué tan grande puede ser en un bloque opcional layout de su descriptor. Los tamaños se cuentan en celdas de la cuadrícula (columnas a lo ancho, filas a lo alto), no en píxeles:

layout: {
defaultSpan: { cols: 4, rows: 3 }, // el tamaño de un widget recién añadido
minSpan: { cols: 3, rows: 2 }, // el mínimo al que se puede redimensionar (por defecto 1 x 1)
maxSpan: { cols: 8, rows: 6 }, // el máximo (por defecto: sin límite)
resize: 'both', // 'both' (por defecto), 'horizontal', 'vertical' o 'none'
},

Cada valor es un número entero positivo, y minSpan no puede superar a defaultSpan, que a su vez no puede superar a maxSpan. Un modo resize distinto de both mantiene el otro eje en defaultSpan: 'horizontal' fija las filas, 'vertical' las columnas y 'none' ambos. Un minSpan o maxSpan que declares debe coincidir con defaultSpan en un eje fijo. El registro rechaza un bloque que incumpla estas reglas cuando el anfitrión lo construye.

El editor añade un widget nuevo con defaultSpan. lintLayout, de @devicechain/dashboards, informa como error de un panel que coloque el widget fuera de sus límites. Elige un minSpan que sea la caja más pequeña en la que tu widget siga siendo legible. Un widget sin bloque layout se añade con 4 x 2 celdas y sin límites, y lintLayout indica que no declaró ninguno.

Las celdas son cuadradas, y un panel nunca muestra una celda más pequeña que su tamaño mínimo de celda (56 píxeles por defecto; en una pantalla más estrecha el panel se reorganiza). Por eso conviene calcular minSpan con esa celda mínima: con la separación por defecto de 8 píxeles, 2 celdas son 120 píxeles, 3 son 184 y 4 son 248. En una pantalla más ancha las celdas crecen y tu widget solo se hace más grande. Las versiones anteriores a la cuadrícula de celdas cuadradas contaban celdas en una cuadrícula de 24 columnas estrechas y filas de 40 píxeles; un paquete que aún declare esos números sigue funcionando, pero sus tamaños son aproximadamente el doble de anchos de lo que deberían, y lintLayout informa de cualquier panel que lo coloque fuera de ellos. Divide las columnas entre dos y revisa las filas.

El texto sigue la densidad del panel. WidgetFrame añade el relleno y configura el área de contenido del widget como un contenedor de tamaño, de modo que dentro de él las unidades de contenedor CSS (cqw, cqh) miden el espacio que el widget tiene de verdad. Toma los tamaños de texto de la escala tipográfica (fontVar('md') y los demás pasos de xs a display) y el espaciado de spaceVar(1) a spaceVar(4) en lugar de escribir valores en píxeles: ambos se multiplican por la densidad del panel (compacta, cómoda o mural), que define quien crea el panel y puede cambiar quien lo ve. Para una lectura grande, fitValueFontCss(chars) devuelve el tamaño de fuente CSS para un número de caracteres. Un canvas no puede leer variables CSS: llama a useWidgetScale() desde un componente dibujado dentro del marco (o pasa al marco una función como hijo) para obtener sizeClass (compact, regular o large), el tamaño del contenido en píxeles y px('font-md') con el valor en píxeles de un token. El marco oculta el subtítulo y después el título cuando el widget se vuelve bajo; el título sigue disponible para las tecnologías de asistencia.

El intervalo de tiempo del panel​

Un panel tiene un intervalo de tiempo que el espectador puede cambiar y pausar (consulta Paneles). Un widget measurement que declara binding.windowSeconds dibuja un lapso de tiempo en lugar de un número de muestras: el lapso que fija la opción referenciada, o el intervalo del panel cuando la opción no está definida. El anfitrión precarga el historial del lapso y conserva como máximo MAX_SPAN_SAMPLES (2.000) muestras en vivo; cuando tuvo que descartar muestras antiguas que seguían dentro del lapso, data.truncatedBefore indica desde cuándo cubre realmente la ventana, y tu widget debería decirlo en lugar de etiquetar una ventana corta con el lapso largo.

useWidgetTimeRange() devuelve el intervalo: spanSeconds, paused y, mientras está en pausa, pausedAt. Es undefined cuando el widget se renderiza fuera de un panel, así que trata ese caso en lugar de suponer una hora. withinSpan(samples, spanSeconds, end) descarta las muestras más antiguas que el lapso. Mientras el panel está en pausa, el anfitrión mantiene todos los canales de datos salvo control en la instantánea que hay en pantalla, así que tu widget no necesita lógica de pausa propia.

Un panel también puede mostrar un intervalo fijo de fecha a fecha. El intervalo que lee tu widget se parece entonces a uno móvil congelado: paused es true, pausedAt es el final del intervalo y spanSeconds su duración, así que un widget que solo entiende intervalos móviles dibuja exactamente ese intervalo, ya sin seguir los datos en vivo, y no necesita cambios. Un widget que quiera indicar «intervalo fijo» lee el campo opcional absolute (fromMs y toMs, milisegundos de época), que solo existe en ese caso.

formatValue(value) sin precisión muestra como máximo 3 decimales, o 4 dígitos significativos por debajo de 1; pasa una precisión para fijar el número de decimales.

Interacciones y modelo de confianza​

interactions enumera los callbacks del anfitrión que usa el widget: alarmActions (reconocer y cerrar; solo canal de alarmas), commandSend (solo canal de control), selection y candidates. El anfitrión proporciona únicamente los callbacks que declaraste, limitados además a lo que el anfitrión admite y a lo que el espectador tiene permitido. Un widget sin callback se comporta como de solo lectura. El servidor sigue siendo la autoridad: una acción que el espectador no puede realizar es rechazada por el servidor, y el rechazo llega a tu componente.

Un paquete de widgets es código de confianza. Se empaqueta en la aplicación anfitriona en tiempo de compilación y se ejecuta en la página del anfitrión con los privilegios del anfitrión. Las declaraciones de un descriptor dan forma a la API; no son una frontera de seguridad y no detienen código malicioso en un paquete. El SDK no pasa a tu componente el token de acceso, el cliente ni el hub sin procesar, pero nada impide que código de la misma página los alcance. Instala solo paquetes que hayas revisado, de editores en los que confíes. El anfitrión envuelve cada widget en un límite de error, de modo que un widget que lanza una excepción muestra un error en su propio marco y sus vecinos siguen mostrándose. Eso no contiene un bucle infinito ni código hostil.

A continuación​