Skip to main content

Translator Development

This is a hands-on guide to writing your own translators. It is the companion to the Translator API reference - the reference defines the object schema and how the service runs code; this guide shows how to build, test, and upload a good translator, starting with the single most important rule: be consistent with the IoT platform's data model.

It walks through five complete, copy-pasteable templates in rising complexity, then a local test harness and how to package a translator for upload:

  1. Hardware translator - from a manufacturer's reference decoder
  2. Hardware translator - from scratch, against a byte spec
  3. calculate - a derived value (chained)
  4. set-alarm - thresholds with hysteresis (chained, stateful)
  5. Analytics - per-period accumulation (chained, stateful)

1. Why data-model consistency matters (read this first)

The IoT platform flattens every device's readings onto a single node (the iotnode) as a flat set of field names. There is no per-device schema and no entity-type context at read time - the meaning of a value lives entirely in its field name, unit, and quantity.

That is only useful if every translator uses the same names. A relativeHumidity reading coming off a Dragino, a Milesight, an Elsys and a Netvox must be the identical field - same name (relativeHumidity), same unit (%), same quantity (relativeHumidity). When it is:

  • Dashboards, alarm views, exports and charts work for any device without per-vendor configuration.
  • Logic composes: a set-alarm or calculate translator runs on top of any device that emits the field it reads.
  • Everyone shares one mental model instead of many vendor spellings.

Invent humidity, BatV or temp_c and the value still shows up - but every downstream rule, alarm and dashboard silently stops matching it.

The rules that keep the model consistent:

  • Use canonical field names, units and quantities. The common ones are listed in the Translator API data-model table; the full set of translators already in use can be listed with GET /api/translators. Match an existing name before inventing one.

  • Names are lowerCamelCase, flat. No snake_case, no ALL_CAPS, no manufacturer jargon (TempC1, BatV) in the output. Keep the vendor's raw names inside the decoder; rename to canonical in the final output object.

  • Units are SI symbols (V, A, W, Pa, m, m/s, kWh, °C, %). Dimensionless values (counts, indices, alarms) use unit '' with a camelCase quantity. Convert in the wrapper (e.g. hPa → Pa is × 100, mV → V is / 1000).

  • Temperature carries its medium in the name - a classic mistake:

    fieldmeaning
    temperatureair / ambient on-board reading
    waterTemperaturewater medium (meters, submersibles)
    soilTemperaturesoil probe
    surfaceTemperaturesurface / contact
    externalTemperature / 2 / 3probe of unspecified medium
    internalTemperature / cpuTemperaturedevice electronics

If you take nothing else from this guide: map the raw device names to the IoT platform's canonical fields in one place - the final output object - and never invent a name that already exists.


2. Anatomy of a translator

A translator is a JSON object (see the Translator API for the full schema):

{
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 declares every field you emit; the service rejects any emitted key not in spec. Each entry is either a bare type string ('string', 'number', 'boolean', 'date', 'object', 'array', 'geoJsonPoint') or a measurement descriptor {type, unit, quantity}:

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

code is your translate function as a string. Two shapes:

  • Hardware decoder: function translate({encodedData}) { ... } - receives the raw uplink (encodedData.hexEncoded, encodedData.port).
  • Chained translator: function translate(iotnode, parameters) { ... } - runs after another translator and reads already-decoded canonical fields off the iotnode (e.g. iotnode.temperature). See "Chaining" in translator-api.md.

The sandbox provides all standard JavaScript built-ins (Date, Math, JSON, …) plus lodash (_), Buffer and console.log. It does not provide require, timers, or network/filesystem. Date.now() / new Date() do work - but prefer iotnode.reportedAt for observation/event times: the wall clock is the processing time and is non-deterministic (it breaks reproducibility when a translation is replayed).

description is end-user documentation - the text a customer reads in the IoT platform to decide whether a translator fits, so it's worth writing well. Lead with a plain-language sentence about what the device is (or, for a chained translator, what it computes), then a ### Decoded output list of `fieldName` (unit) - meaning for every field.

Take extra care with analytics / chained translators. Their value isn't obvious from a field list, so the description must also explain what it computes, which inputs it reads, and the use case - e.g. "Turns a cumulative energy-meter reading into consumption per day/week/month, resetting at local midnight; pair with set-alarm-energy-consumption to catch overspend." A clear description makes a translator discoverable and correctly applied; a vague one gets mis-used.

The templates below keep each translator in two files - translate.js (the function) and manifest.json (everything else) - because that is what the test harness and the packaging script use. You can also inline the function with code: translate.toString().


3. Template 1 - Hardware translator from a manufacturer reference decoder

The most common case. The manufacturer (or The Things Network's lorawan-devices repo) ships a JS decoder. Keep it verbatim and add a thin wrapper that renames its raw output to the IoT platform's canonical fields - harmonize as the final step, scaling units accurately.

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, 'AirTempC'), // vendor name -> canonical
relativeHumidity: _.get(decoded, 'RH_pct'),
batteryVoltage: _.get(decoded, 'Vbat'),
},
};
}

// --- manufacturer decoder, kept verbatim ------------------------------------
// Imported from https://github.com/TheThingsNetwork/lorawan-devices/.../acme-th-100.js
function decodeUplink (input) {
// ...vendor code, unchanged...
return {data: {AirTempC: 24.3, RH_pct: 52.1, Vbat: 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"}
}
}

Key points:

  • Do not "fix" the vendor decoder (bit offsets, scaling) on suspicion - verify against the device spec first. Cite the source URL in a comment.
  • Any helper the vendor decoder calls must live in the same translate.js file - the whole string runs in the sandbox, so top-level helpers not included will throw "X is not defined".

Variant - a decoder that takes a Buffer

Many manufacturer decoders read the payload as a Node Buffer (using buf.readInt16BE(...), buf.readUInt8(...), …) rather than a byte array. Buffer is available in the sandbox, so build one from the hex and pass it straight to the vendor function - then harmonize exactly as above:

/* 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
};
}

Same rule: keep decode verbatim, and do all renaming and unit scaling in the wrapper's result.


4. Template 2 - Hardware translator from scratch (byte spec)

No vendor JS decoder, only a byte-layout spec: parse the bytes yourself. Assume: byte0 = message type, byte1-2 = temperature ×10 (signed int16, big-endian), byte3 = humidity %, byte4-5 = battery 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)
},
};
}

The manifest.json is the same shape as Template 1. Note the unit conversion (mV → V) done in the wrapper so the output is in the canonical unit. For a device-specific value with no canonical equivalent, still give it a unit + quantity inline: "chamberPressure": {"type": "number", "unit": "Pa", "quantity": "pressure"}.


5. Template 3 - calculate (a derived value, chained)

A chained translator runs after the hardware translator and reads canonical fields off the iotnode. This one derives dew point from temperature + relativeHumidity, so it works on any device that emits those two fields.

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"}
}
}

The service removes undefined and NaN from result, so a translator can emit a field conditionally and just return the plain object. null is not removed: it fails the declared type in spec and rejects the whole translation, so strip it before returning, or use undefined instead.


6. Template 4 - set-alarm (thresholds + hysteresis, chained & stateful)

Alarms need hysteresis (so they don't chatter at the threshold) and previous state (read off the iotnode - an alarm translator's own last output is available on the next run).

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) - alarm fields are subject-first booleans:

{
"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"
}
}

Because it reads temperature generically, the same alarm works on air, water or surface temperature by pointing temperatureField at waterTemperature / surfaceTemperature.


7. Template 5 - Analytics (per-period accumulation, chained & stateful)

The most complex shape: it maintains state across uplinks. There is no other persistence - write state back as an ordinary field (a *State object) and read it on the next run. This turns a cumulative meter reading into consumption per day/week/month/quarter/year, resetting each period at its local-timezone boundary.

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 excerpt)

{
"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"
}
}

Analytics lessons:

  • State is a field, written back in result and read next run. Name it *State and document "do not consume directly".
  • Handle sensor resets/rollover (reading drops) by re-baselining.
  • Use uplink time (reportedAt) converted to the configured timezone for period boundaries - Date.now() works but is non-deterministic, so it must not drive period logic.

8. Test locally

You don't need any of the IoT platform's infrastructure to test - a tiny Node harness recreates the sandbox and runs the exact code you will upload.

  1. Put your function in translate.js (as in the templates above).
  2. Download translator-harness.js into the same folder, and edit its CASES array with real payloads and expected output.
  3. Run:
    npm install lodash
    node translator-harness.js

The harness prints PASS/FAIL per case. It mirrors the platform's removal of undefined and NaN from result, so your expected objects contain only the fields actually emitted.


9. Package and upload

  1. Put your metadata (name, version, apiVersion, description, match, spec, parameters) in manifest.json (as in the templates above).
  2. Download translator-build.js into the same folder and run it - it merges manifest.json + translate.js into translator.json:
    node translator-build.js
  3. Upload it:
    curl -X POST "https://staging.yggio.net/api/translators" \
    -H "Authorization: Bearer $YGGIO_TOKEN" \
    -H "Content-Type: application/json" \
    --data @translator.json
    See https://staging.yggio.net/swagger for the exact route and auth. After upload, attach the translator to a device via its translatorPreferences (see translator-api.md).

The translate.js you tested is uploaded byte-for-byte as code, so a green harness run means the sandbox runs the same code. It does not mean the same result: the platform applies a 1 second timeout and a 16 MB memory limit to the translate call, resolves translate as a global, and supplies a Buffer polyfill rather than Node's own.


10. Troubleshooting

If a translator works locally but does nothing (or the wrong thing) once uploaded, one of three things is usually happening:

  1. Output rejected by the spec. The most common cause. Every emitted field must be declared in spec with a matching type - an exact character/case match. Coerce types explicitly (Number(...), String(...), Array.isArray(...)) and confirm every field name is canonical.
  2. The translator threw at runtime - usually an undefined value from an unexpected payload.
  3. It failed to compile - a syntax error after stringification, or a helper the function calls that wasn't included in the uploaded code.

Where to see the error. When a translator crashes, the IoT platform records the error in the device's Logs, kept for 6 hours. Open the device's Logs and filter:

  • Type = Debug
  • Category = System

Copy the failing input from the log entry into your local harness to reproduce and fix it.

A successful translation writes no log - so an empty Debug/System log means the translator either ran fine or never triggered. To tell which, check the device's Last reported column in the device list: if it updated, the translator ran.

Note: only the first translator in a chain reports a throw this way. If a later translator throws, the platform keeps the result produced so far and writes no log entry, so an empty log does not by itself prove a chained translator succeeded. Test chained translators in the harness.


11. Checklist before you upload

  1. Every output field uses a canonical name, unit and quantity - no invented names (check the data-model table or GET /api/translators).
  2. Temperature fields carry the correct medium (temperature vs waterTemperature vs surfaceTemperature…).
  3. Names are lowerCamelCase; units are SI; conversions done in the wrapper (mV→V, hPa→Pa, …).
  4. Vendor decoder kept verbatim; every helper it calls is inside translate.js; provenance URL cited.
  5. description is plain-language + a ### Decoded output list.
  6. Major version bumped if (and only if) the data model changed (field added/renamed/removed, or unit/scaling/meaning changed).
  7. Tested with the harness; expected output matches.

See the Translator API reference for the full object schema, the data-model field table, chaining, gateways (additionalDeviceUpdates), upgradePolicy and versioning.