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:
- Hardware translator - from a manufacturer's reference decoder
- Hardware translator - from scratch, against a byte spec
calculate- a derived value (chained)set-alarm- thresholds with hysteresis (chained, stateful)- 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-alarmorcalculatetranslator 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. Nosnake_case, noALL_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 camelCasequantity. Convert in the wrapper (e.g.hPa → Pais× 100,mV → Vis/ 1000). -
Temperature carries its medium in the name - a classic mistake:
field meaning 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 theiotnode(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-consumptionto 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.jsfile - 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
resultand read next run. Name it*Stateand 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.
- Put your function in
translate.js(as in the templates above). - Download
translator-harness.jsinto the same folder, and edit itsCASESarray with real payloads and expected output. - Run:
npm install lodashnode 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
- Put your metadata (name, version, apiVersion, description, match, spec,
parameters) in
manifest.json(as in the templates above). - Download
translator-build.jsinto the same folder and run it - it mergesmanifest.json+translate.jsintotranslator.json:node translator-build.js - Upload it:
Seecurl -X POST "https://staging.yggio.net/api/translators" \-H "Authorization: Bearer $YGGIO_TOKEN" \-H "Content-Type: application/json" \--data @translator.json
https://staging.yggio.net/swaggerfor the exact route and auth. After upload, attach the translator to a device via itstranslatorPreferences(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:
- Output rejected by the spec. The most common cause. Every emitted field
must be declared in
specwith a matching type - an exact character/case match. Coerce types explicitly (Number(...),String(...),Array.isArray(...)) and confirm every field name is canonical. - The translator threw at runtime - usually an undefined value from an unexpected payload.
- 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
- Every output field uses a canonical name, unit and quantity - no invented
names (check the data-model table
or
GET /api/translators). - Temperature fields carry the correct medium (
temperaturevswaterTemperaturevssurfaceTemperature…). - Names are
lowerCamelCase; units are SI; conversions done in the wrapper (mV→V, hPa→Pa, …). - Vendor decoder kept verbatim; every helper it calls is inside
translate.js; provenance URL cited. descriptionis plain-language + a### Decoded outputlist.- Major version bumped if (and only if) the data model changed (field added/renamed/removed, or unit/scaling/meaning changed).
- 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.