Hoppa till huvudinnehåll

Utveckla översättare

Detta är en praktisk guide för att skriva egna översättare. Den är följeslagaren till referensen Translator API - referensen definierar objektschemat och hur tjänsten kör kod; den här guiden visar hur man bygger, testar och laddar upp en bra översättare, med den enskilt viktigaste regeln först: var konsekvent med IoT-plattformens datamodell.

Den går igenom fem kompletta, kopieringsbara mallar i stigande komplexitet, och därefter en lokal testrigg och hur man paketerar en översättare för uppladdning:

  1. Hårdvaruöversättare - från en tillverkares referensdekoder
  2. Hårdvaruöversättare - från grunden, mot en byte-spec
  3. calculate - ett härlett värde (kedjad)
  4. set-alarm - tröskelvärden med hysteres (kedjad, tillståndsbaserad)
  5. Analys - ackumulering per period (kedjad, tillståndsbaserad)

1. Varför datamodellens konsekvens spelar roll (läs detta först)​

IoT-plattformen platttar ut varje enhets avläsningar till en enda nod (iotnoden) som en platt uppsättning fältnamn. Det finns inget enhetsspecifikt schema och ingen kontext för entitetstyp vid läsning - betydelsen av ett värde ligger helt i dess fält- namn, enhet och kvantitet.

Det är bara användbart om varje översättare använder samma namn. En relativeHumidity-avläsning som kommer från en Dragino, en Milesight, en Elsys och en Netvox måste vara det identiska fältet - samma namn (relativeHumidity), samma enhet (%), samma kvantitet (relativeHumidity). När det är så:

  • Dashboards, larmvyer, exporter och diagram fungerar för alla enheter utan tillverkarspecifik konfiguration.
  • Logik kan komponeras: en set-alarm- eller calculate-översättare körs ovanpå vilken enhet som helst som skickar ut det fält den läser.
  • Alla delar en mental modell istället för många tillverkarspecifika stavningar.

Hittar du på humidity, BatV eller temp_c dyker värdet fortfarande upp - men varje efterföljande regel, larm och dashboard slutar tyst att matcha det.

Reglerna som håller modellen konsekvent:

  • Använd kanoniska fältnamn, enheter och kvantiteter. De vanliga listas i datamodelltabellen i Translator API; hela mängden översättare som redan används kan listas med GET /api/translators. Matcha ett befintligt namn innan du hittar på ett nytt.

  • Namn är lowerCamelCase, platta. Ingen snake_case, inga VERSALER, ingen tillverkarjargong (TempC1, BatV) i utdatan. Behåll tillverkarens råa namn inuti dekodern; döp om till kanoniskt i det slutliga utdataobjektet.

  • Enheter är SI-symboler (V, A, W, Pa, m, m/s, kWh, °C, %). Dimensionslösa värden (antal, index, larm) använder enheten '' med en camelCase- quantity. Konvertera i omslaget (t.ex. hPa → Pa är × 100, mV → V är / 1000).

  • Temperatur bär sitt medium i namnet - ett klassiskt misstag:

    fältbetydelse
    temperatureluft/omgivning, avläsning ombord
    waterTemperaturevattenmedium (mätare, dränkbara sensorer)
    soilTemperaturejordsond
    surfaceTemperatureyta/kontakt
    externalTemperature / 2 / 3sond med ospecificerat medium
    internalTemperature / cpuTemperatureenhetens elektronik

Om du bara tar med dig en sak från den här guiden: mappa de råa enhetsnamnen till IoT-plattformens kanoniska fält på ett ställe - det slutliga utdataobjektet - och hitta aldrig på ett namn som redan finns.


2. Anatomi av en översättare​

En översättare är ett JSON-objekt (se Translator API för det fullständiga schemat):

{
name: 'acme-th-100', // lowercase, dashes, starts with a letter
version: '1.0.0', // semver
apiVersion: '1.0',
description: '...', // user-facing Markdown (see below)
match: {deviceModelName: 'acme-th-100'},
parameters: { /* optional user inputs */ },
spec: { /* the fields you emit, see below */ },
code: '...', // your translate() function as a string
}

spec deklarerar varje fält du skickar ut; tjänsten avvisar varje utskickad nyckel som inte finns i spec. Varje post är antingen en ren typsträng ('string', 'number', 'boolean', 'date', 'object', 'array') eller en mätvärdesbeskrivning {type, unit, quantity}:

spec: {
temperature: {type: 'number', unit: '°C', quantity: 'temperature'},
batteryLow: 'boolean',
}

code är din translate-funktion som en sträng. Två varianter:

  • Hårdvarudekoder: function translate({encodedData}) { ... } - tar emot den råa upplänken (encodedData.hexEncoded, encodedData.port).
  • Kedjad översättare: function translate(iotnode, parameters) { ... } - körs efter en annan översättare och läser redan avkodade kanoniska fält från iotnode (t.ex. iotnode.temperature). Se "Kedjning" i translator-api.md.

Sandlådan tillhandahåller alla standard-JavaScript-inbyggda funktioner (Date, Math, JSON, …) plus lodash (_), Buffer och console.log. Den tillhandahåller inte require, timers eller nätverks-/filsystemsåtkomst. Date.now() / new Date() fungerar visserligen - men föredra iotnode.reportedAt för observations-/händelsetider: klockan visar bearbetningstiden och är icke-deterministisk (det bryter reproducerbarheten när en översättning körs igen).

description är slutanvändardokumentation - texten en kund läser i IoT-plattformen för att avgöra om en översättare passar, så den är värd att skriva bra. Inled med en mening i vanligt språk om vad enheten är (eller, för en kedjad översättare, vad den beräknar), och sedan en lista ### Decoded output med `fieldName` (unit) - meaning för varje fält.

Var extra noggrann med analys-/kedjade översättare. Deras värde är inte uppenbart från en fältlista, så beskrivningen måste också förklara vad den beräknar, vilka indata den läser och användningsfallet - t.ex. "Omvandlar en kumulativ energimätaravläsning till förbrukning per dag/vecka/månad, med återställning vid lokal midnatt; kombinera med set-alarm-energy-consumption för att fånga överförbrukning." En tydlig beskrivning gör en översättare upptäckbar och korrekt tillämpad; en vag beskrivning leder till felaktig användning.

Mallarna nedan håller varje översättare i två filer - translate.js (funktionen) och manifest.json (allt annat) - eftersom det är det testriggen och paketeringsskriptet använder. Du kan också infoga funktionen direkt med code: translate.toString().


3. Mall 1 - Hårdvaruöversättare från en tillverkares referensdekoder​

Det vanligaste fallet. Tillverkaren (eller The Things Networks lorawan-devices-repo) tillhandahåller en JS-dekoder. Behåll den oförändrad och lägg till ett tunt omslag som döper om dess råa utdata till IoT-plattformens kanoniska fält - harmonisera som det sista steget, och skala enheter korrekt.

translate.js

/* global _, Buffer */

function translate ({encodedData}) {
const {hexEncoded, port} = encodedData;
if (!hexEncoded || !port) {
throw new Error('Expected fields hexEncoded and/or port are missing');
}

const bytes = [...Buffer.from(hexEncoded, 'hex')];
const decoded = decodeUplink({bytes, fPort: port}).data; // call the vendor decoder

// The ONLY harmonization step: map raw vendor names to canonical fields.
return {
result: {
temperature: _.get(decoded, 'TempC_SHT'), // vendor name -> canonical
relativeHumidity: _.get(decoded, 'Hum_SHT'),
batteryVoltage: _.get(decoded, 'BatV'),
},
};
}

// --- manufacturer decoder, kept verbatim ------------------------------------
// Imported from https://github.com/TheThingsNetwork/lorawan-devices/.../acme-th-100.js
function decodeUplink (input) {
// ...vendor code, unchanged...
return {data: {TempC_SHT: 24.3, Hum_SHT: 52.1, BatV: 3.01}};
}

manifest.json

{
"name": "acme-th-100",
"version": "1.0.0",
"apiVersion": "1.0",
"description": "Acme TH-100 is a wireless sensor that measures room air temperature and humidity.\n\n### Decoded output\n- `temperature` (°C) - air temperature\n- `relativeHumidity` (%) - relative air humidity\n- `batteryVoltage` (V) - battery voltage",
"match": {"deviceModelName": "acme-th-100"},
"spec": {
"temperature": {"type": "number", "unit": "°C", "quantity": "temperature"},
"relativeHumidity": {"type": "number", "unit": "%", "quantity": "relativeHumidity"},
"batteryVoltage": {"type": "number", "unit": "V", "quantity": "voltage"}
}
}

Viktiga punkter:

  • "Fixa" inte tillverkarens dekoder (bitförskjutningar, skalning) på misstanke - verifiera mot enhetens spec först. Ange källans URL i en kommentar.
  • Alla hjälpfunktioner tillverkardekodern anropar måste finnas i samma translate.js- fil - hela strängen körs i sandlådan, så toppnivåhjälpfunktioner som inte inkluderas ger felet "X is not defined".

Variant - en dekoder som tar en Buffer​

Många tillverkardekodrar läser payloaden som en Node-Buffer (med buf.readInt16BE(...), buf.readUInt8(...), …) snarare än en byte-array. Buffer finns tillgänglig i sandlådan, så bygg en från hex-strängen och skicka den direkt till tillverkarfunktionen - harmonisera sedan exakt som ovan:

/* global _, Buffer */

function translate ({encodedData}) {
const {hexEncoded, port} = encodedData;
if (!hexEncoded || !port) {
throw new Error('Expected fields hexEncoded and/or port are missing');
}

const buf = Buffer.from(hexEncoded, 'hex'); // the vendor decoder wants a Buffer
const decoded = decode(buf, port); // manufacturer function, kept verbatim

return {
result: {
temperature: decoded.temp / 10, // raw 0.1 °C -> °C
relativeHumidity: decoded.hum,
batteryVoltage: decoded.batt_mv / 1000, // mV -> V
},
};
}

/* eslint-disable */
// Imported from <vendor url>
function decode (buf, port) {
return {
temp: buf.readInt16BE(0), // 0.1 °C, signed
hum: buf.readUInt8(2), // %
batt_mv: buf.readUInt16BE(3), // mV
};
}

Samma regel: behåll decode oförändrad, och gör all namnbyte och enhetsskalning i omslagets result.


4. Mall 2 - Hårdvaruöversättare från grunden (byte-spec)​

Ingen tillverkar-JS-dekoder, bara en byte-layout-spec: parsa byten själv. Antag: byte0 = meddelandetyp, byte1-2 = temperatur ×10 (signerad int16, big-endian), byte3 = luftfuktighet %, byte4-5 = batteri mV.

translate.js

/* global _, Buffer */

function translate ({encodedData}) {
const {hexEncoded, port} = encodedData;
if (!hexEncoded || !port) {
throw new Error('Expected fields hexEncoded and/or port are missing');
}

const bytes = [...Buffer.from(hexEncoded, 'hex')];
const int16 = (hi, lo) => { // signed 16-bit big-endian
const raw = (bytes[hi] << 8) | bytes[lo];
return raw & 0x8000 ? raw - 0x10000 : raw;
};
const uint16 = (hi, lo) => (bytes[hi] << 8) | bytes[lo];

return {
result: {
temperature: int16(1, 2) / 10, // ×10 in the payload
relativeHumidity: bytes[3], // whole %
batteryVoltage: uint16(4, 5) / 1000, // mV -> V (canonical unit is V)
},
};
}

manifest.json har samma form som Mall 1. Notera enhetskonverteringen (mV → V) som görs i omslaget så att utdatan är i den kanoniska enheten. För ett enhetsspecifikt värde utan kanonisk motsvarighet, ge det ändå en enhet + en kvantitet direkt: "chamberPressure": {"type": "number", "unit": "Pa", "quantity": "pressure"}.


5. Mall 3 - calculate (ett härlett värde, kedjad)​

En kedjad översättare körs efter hårdvaruöversättaren och läser kanoniska fält från iotnode. Denna härleder daggpunkt från temperature + relativeHumidity, så den fungerar på vilken enhet som helst som skickar ut de två fälten.

translate.js

/* global _ */

// Read a numeric field: an optional override path wins; otherwise try the
// default candidates in order and take the first finite number.
const resolveNumeric = (iotnode, overrideField, defaultPaths) => {
const paths = overrideField ? [overrideField] : defaultPaths;
return paths.map(p => {
const v = _.get(iotnode, p);
return typeof v === 'number' ? v : parseFloat(v);
}).find(Number.isFinite);
};

function translate (iotnode, parameters) {
const temperature = resolveNumeric(iotnode, _.get(parameters, 'temperatureField'), ['temperature']);
const relativeHumidity = resolveNumeric(iotnode, _.get(parameters, 'relativeHumidityField'), ['relativeHumidity']);

if (!_.isFinite(temperature) || !_.isFinite(relativeHumidity)) {
return {}; // nothing to do until both inputs are present
}

// Magnus formula
const a = 17.62;
const b = 243.12;
const gamma = Math.log(relativeHumidity / 100) + (a * temperature) / (b + temperature);
const dewPoint = Math.round(((b * gamma) / (a - gamma)) * 100) / 100;

return {result: {dewPoint}};
}

manifest.json (parameters + spec)

{
"name": "calculate-dew-point-from-temperature-rh",
"version": "1.0.0",
"apiVersion": "1.0",
"description": "Calculates the dew point (°C) from a sensor's temperature and relative humidity.\n\nChained translator - runs on top of the device's hardware translator and reads its decoded temperature and relativeHumidity.\n\n### Decoded output\n- `dewPoint` (°C) - the calculated dew point",
"match": {"deviceModelName": "calculate-dew-point-from-temperature-rh"},
"parameters": {
"temperatureField": {"type": "string", "description": "Field to read temperature from. Default \"temperature\".", "default": "temperature", "optional": true},
"relativeHumidityField": {"type": "string", "description": "Field to read relative humidity from. Default \"relativeHumidity\".", "default": "relativeHumidity", "optional": true}
},
"spec": {
"dewPoint": {"type": "number", "unit": "°C", "quantity": "temperature"}
}
}

Tjänsten tar bort undefined/null från result, så en översättare kan skicka ut ett fält villkorligt och bara returnera det vanliga objektet.


6. Mall 4 - set-alarm (tröskelvärden + hysteres, kedjad och tillståndsbaserad)​

Larm behöver hysteres (så att de inte "chattrar" vid tröskeln) och tidigare tillstånd (läses från iotnoden - en larmöversättares egen senaste utdata är tillgänglig vid nästa körning).

translate.js

/* global _ */

const resolveNumeric = (iotnode, overrideField, defaultPaths) => {
const paths = overrideField ? [overrideField] : defaultPaths;
return paths.map(p => {
const v = _.get(iotnode, p);
return typeof v === 'number' ? v : parseFloat(v);
}).find(Number.isFinite);
};

function translate (iotnode, parameters) {
const highLevel = _.get(parameters, 'temperatureHighAlarmLevel', 30);
const lowLevel = _.get(parameters, 'temperatureLowAlarmLevel', 4);
const hysteresis = _.get(parameters, 'temperatureAlarmHysteresis', 1);
const temperature = resolveNumeric(iotnode, _.get(parameters, 'temperatureField'), ['temperature']);

if (highLevel <= lowLevel) return {result: {errorMessage: 'high alarm level must be greater than low alarm level'}};
if (!_.isFinite(temperature)) return {result: {errorMessage: 'temperature is missing'}};

const prevHigh = _.get(iotnode, 'temperatureHighAlarm');
const prevLow = _.get(iotnode, 'temperatureLowAlarm');

// Fire above the level; clear only once back past (level - band). undefined = keep state.
const highAlarm = temperature > highLevel ? true
: (temperature < highLevel - hysteresis || _.isUndefined(prevHigh)) ? false : undefined;
const lowAlarm = temperature < lowLevel ? true
: (temperature > lowLevel + hysteresis || _.isUndefined(prevLow)) ? false : undefined;

const result = {};
if (highAlarm !== undefined) result.temperatureHighAlarm = highAlarm;
if (lowAlarm !== undefined) result.temperatureLowAlarm = lowAlarm;

return _.isEmpty(result) ? {} : {result};
}

manifest.json (parameters + spec) - larmfält är subjekt-först-booleaner:

{
"parameters": {
"temperatureHighAlarmLevel": {"type": "number", "description": "High threshold (°C). Default 30.", "default": 30, "optional": true},
"temperatureLowAlarmLevel": {"type": "number", "description": "Low threshold (°C). Default 4.", "default": 4, "optional": true},
"temperatureAlarmHysteresis": {"type": "number", "description": "Hysteresis band (°C). Default 1.", "default": 1, "optional": true},
"temperatureField": {"type": "string", "description": "Field to read. Default \"temperature\".", "optional": true}
},
"spec": {
"temperatureHighAlarm": "boolean",
"temperatureLowAlarm": "boolean",
"errorMessage": "string"
}
}

Eftersom den läser temperature generiskt, fungerar samma larm på luft-, vatten- eller ytatemperatur genom att peka temperatureField på waterTemperature / surfaceTemperature.


7. Mall 5 - Analys (ackumulering per period, kedjad och tillståndsbaserad)​

Den mest komplexa formen: den behåller tillstånd mellan upplänkar. Det finns ingen annan persistens - skriv tillstånd tillbaka som ett vanligt fält (ett *State-objekt) och läs det vid nästa körning. Detta omvandlar en kumulativ mätaravläsning till förbrukning per dag/vecka/månad/kvartal/år, med återställning varje period vid dess gräns i lokal tidszon.

translate.js

/* global _ */

const isValidTimeZone = tz => { try { Intl.DateTimeFormat(undefined, {timeZone: tz}); return true; } catch (e) { return false; } };
const weekStart = d => { const x = new Date(d); const day = (x.getDay() + 6) % 7; x.setDate(x.getDate() - day); x.setHours(0, 0, 0, 0); return x; };

// One flat output field per rolling period + a "changed" predicate.
const PERIODS = [
{field: 'energyConsumptionDay', changed: (c, p) => c.toDateString() !== p.toDateString()},
{field: 'energyConsumptionWeek', changed: (c, p) => weekStart(c).getTime() !== weekStart(p).getTime()},
{field: 'energyConsumptionMonth', changed: (c, p) => c.getFullYear() !== p.getFullYear() || c.getMonth() !== p.getMonth()},
{field: 'energyConsumptionQuarter', changed: (c, p) => c.getFullYear() !== p.getFullYear() || ((c.getMonth() / 3) | 0) !== ((p.getMonth() / 3) | 0)},
{field: 'energyConsumptionYear', changed: (c, p) => c.getFullYear() !== p.getFullYear()},
];

function translate (iotnode, parameters) {
const energyField = _.get(parameters, 'energyField', 'activeEnergyImport');
const timeZone = _.get(parameters, 'timeZone', 'Europe/Stockholm');
const current = _.get(iotnode, energyField);

if (!_.isFinite(current)) return {result: {errorMessage: `field "${energyField}" not found or not numeric`}};
if (!isValidTimeZone(timeZone)) return {result: {errorMessage: `invalid timeZone "${timeZone}"`}};

// Use the uplink time; new Date() works but reflects processing time (non-deterministic).
const reportedAt = _.get(iotnode, 'reportedAt', new Date().toISOString());
const prevReportedAt = _.get(iotnode, 'energyConsumptionPrevReportedAt', reportedAt);
const now = new Date(new Date(reportedAt).toLocaleString('sv-SE', {timeZone}));
const prev = new Date(new Date(prevReportedAt).toLocaleString('sv-SE', {timeZone}));

// Read prior state; seed baselines on first appearance.
const state = _.get(iotnode, 'energyConsumptionState', {});
let baselines = Array.isArray(state.baselines) && state.baselines.length === PERIODS.length
? state.baselines.slice()
: PERIODS.map(() => current);
const latest = _.isFinite(state.latest) ? state.latest : current;

// Meter replacement / rollover (reading dropped) -> re-baseline everywhere.
if (current < latest) baselines = PERIODS.map(() => current);

const result = {};
baselines = baselines.map((baseline, i) => {
const rebase = PERIODS[i].changed(now, prev) ? current : baseline; // period rolled over -> restart
result[PERIODS[i].field] = Math.round((current - rebase) * 1000) / 1000;
return rebase;
});

result.energyConsumptionState = {baselines, latest: current}; // write state back for next run
result.energyConsumptionPrevReportedAt = reportedAt;
return {result};
}

manifest.json (spec-utdrag)

{
"parameters": {
"energyField": {"type": "string", "description": "Cumulative energy field (kWh). Default \"activeEnergyImport\".", "default": "activeEnergyImport", "optional": true},
"timeZone": {"type": "string", "description": "IANA time zone for period boundaries. Default \"Europe/Stockholm\".", "default": "Europe/Stockholm", "optional": true}
},
"spec": {
"energyConsumptionDay": {"type": "number", "unit": "kWh", "quantity": "energy"},
"energyConsumptionWeek": {"type": "number", "unit": "kWh", "quantity": "energy"},
"energyConsumptionMonth": {"type": "number", "unit": "kWh", "quantity": "energy"},
"energyConsumptionQuarter": {"type": "number", "unit": "kWh", "quantity": "energy"},
"energyConsumptionYear": {"type": "number", "unit": "kWh", "quantity": "energy"},
"energyConsumptionState": "object",
"energyConsumptionPrevReportedAt": "string",
"errorMessage": "string"
}
}

Lärdomar om analys:

  • Tillstånd är ett fält, skrivs tillbaka i result och läses nästa körning. Döp det *State och dokumentera "ska inte konsumeras direkt".
  • Hantera sensoråterställningar/rollover (avläsningen sjunker) genom omreferering (re-baselining).
  • Använd upplänkstid (reportedAt) konverterad till den konfigurerade tidszonen för periodgränser - Date.now() fungerar men är icke-deterministisk, så den får inte styra periodlogiken.

8. Testa lokalt​

Du behöver inte någon av IoT-plattformens infrastruktur för att testa - en liten Node-rigg återskapar sandlådan och kör exakt den kod du kommer att ladda upp.

  1. Lägg din funktion i translate.js (som i mallarna ovan).
  2. Ladda ner translator-harness.js till samma mapp, och redigera dess CASES-array med verkliga payloads och förväntad utdata.
  3. Kör:
    npm install lodash
    node translator-harness.js

Riggen skriver ut PASS/FAIL per fall. Den speglar plattformens borttagning av undefined/null från result, så dina förväntade objekt ska bara innehålla de fält som faktiskt skickas ut.


9. Paketera och ladda upp​

  1. Lägg din metadata (name, version, apiVersion, description, match, spec, parameters) i manifest.json (som i mallarna ovan).
  2. Ladda ner translator-build.js till samma mapp och kör den - den slår ihop manifest.json + translate.js till translator.json:
    node translator-build.js
  3. Ladda upp den:
    curl -X POST "https://beta.yggio.net/api/translators" \
    -H "Authorization: Bearer $YGGIO_TOKEN" \
    -H "Content-Type: application/json" \
    --data @translator.json
    Se https://beta.yggio.net/swagger för exakt endpoint och autentisering. Efter uppladdning, koppla översättaren till en enhet via dess translatorPreferences (se translator-api.md).

translate.js du testade laddas upp byte-för-byte som code, så en godkänd riggkörning betyder att sandlådan kör samma kod. Den betyder inte samma resultat: plattformen tillämpar en timeout på 1 sekund och en minnesgräns på 16 MB på translate-anropet, löser upp translate som en global, och tillhandahåller en Buffer-polyfill i stället för Nodes egen.


10. Felsökning​

Om en översättare fungerar lokalt men inte gör något (eller gör fel saker) när den väl laddats upp, är det vanligtvis en av tre saker som händer:

  1. Utdata avvisas av spec. Den vanligaste orsaken. Varje utskickat fält måste vara deklarerat i spec med en matchande typ - en exakt matchning av tecken/versaler. Framtvinga typer explicit (Number(...), String(...), Array.isArray(...)) och bekräfta att varje fältnamn är kanoniskt.
  2. Översättaren kastade ett fel under körning - vanligtvis ett odefinierat värde från en oväntad payload.
  3. Den misslyckades att kompilera - ett syntaxfel efter strängomvandling, eller en hjälpfunktion funktionen anropar som inte inkluderades i den uppladdade koden.

Var man ser felet. När en översättare kraschar registrerar IoT-plattformen felet i enhetens Loggar, som behålls i 6 timmar. Öppna enhetens loggar och filtrera:

  • Type = Debug
  • Category = System

Kopiera den felande indatan från loggposten till din lokala rigg för att återskapa och åtgärda den.

En lyckad översättning skriver ingen logg - så en tom Debug/System- logg innebär att översättaren antingen körde bra eller aldrig utlöstes. För att avgöra vilket, kontrollera enhetens kolumn Last reported i enhetslistan: om den uppdaterades körde översättaren.


11. Checklista innan du laddar upp​

  1. Varje utdatafält använder ett kanoniskt namn, enhet och kvantitet - inga uppfunna namn (kontrollera datamodelltabellen eller GET /api/translators).
  2. Temperaturfält bär rätt medium (temperature vs waterTemperature vs surfaceTemperature…).
  3. Namn är lowerCamelCase; enheter är SI; konverteringar görs i omslaget (mV→V, hPa→Pa, …).
  4. Tillverkardekoder behålls oförändrad; varje hjälpfunktion den anropar finns i translate.js; ursprungs-URL angiven.
  5. description är i vanligt språk + en lista ### Decoded output.
  6. Huvudversionen höjd om (och endast om) datamodellen ändrats (fält tillagt/döpt om/borttaget, eller enhet/skalning/betydelse ändrad).
  7. Testad med riggen; förväntad utdata matchar.

Se referensen Translator API för det fullständiga objektschemat, datamodellens fälttabell, kedjning, gateways (additionalDeviceUpdates), upgradePolicy och versionshantering.