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://beta.yggio.net/api/translators" \
    -H "Authorization: Bearer $YGGIO_TOKEN" \
    -H "Content-Type: application/json" \
    --data @translator.json
    See https://beta.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.