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:
- Hårdvaruöversättare - från en tillverkares referensdekoder
- Hårdvaruöversättare - från grunden, mot en byte-spec
calculate- ett härlett värde (kedjad)set-alarm- tröskelvärden med hysteres (kedjad, tillståndsbaserad)- 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- ellercalculate-ö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. Ingensnake_case, ingaVERSALER, 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ält betydelse 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åniotnode(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-consumptionfö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
resultoch läses nästa körning. Döp det*Stateoch 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.
- Lägg din funktion i
translate.js(som i mallarna ovan). - Ladda ner
translator-harness.jstill samma mapp, och redigera dessCASES-array med verkliga payloads och förväntad utdata. - Kör:
npm install lodashnode 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
- Lägg din metadata (name, version, apiVersion, description, match, spec,
parameters) i
manifest.json(som i mallarna ovan). - Ladda ner
translator-build.jstill samma mapp och kör den - den slår ihopmanifest.json+translate.jstilltranslator.json:node translator-build.js - Ladda upp den:
Securl -X POST "https://beta.yggio.net/api/translators" \-H "Authorization: Bearer $YGGIO_TOKEN" \-H "Content-Type: application/json" \--data @translator.json
https://beta.yggio.net/swaggerför exakt endpoint och autentisering. Efter uppladdning, koppla översättaren till en enhet via desstranslatorPreferences(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:
- Utdata avvisas av spec. Den vanligaste orsaken. Varje utskickat fält
måste vara deklarerat i
specmed 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. - Översättaren kastade ett fel under körning - vanligtvis ett odefinierat värde från en oväntad payload.
- 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
- Varje utdatafält använder ett kanoniskt namn, enhet och kvantitet - inga uppfunna
namn (kontrollera datamodelltabellen
eller
GET /api/translators). - Temperaturfält bär rätt medium (
temperaturevswaterTemperaturevssurfaceTemperature…). - Namn är
lowerCamelCase; enheter är SI; konverteringar görs i omslaget (mV→V, hPa→Pa, …). - Tillverkardekoder behålls oförändrad; varje hjälpfunktion den anropar finns i
translate.js; ursprungs-URL angiven. descriptionär i vanligt språk + en lista### Decoded output.- Huvudversionen höjd om (och endast om) datamodellen ändrats (fält tillagt/döpt om/borttaget, eller enhet/skalning/betydelse ändrad).
- 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.