Scripts run your own JavaScript on the Virtuino Cloud server. A script reads values from one or more fields, does whatever calculation or logic you need, and writes the results to other fields — without changing the firmware of your boards. This guide shows how to set one up, what your code can use, and eleven tested examples you can copy.
Every script follows the same pattern: read the fields mapped as inputs, calculate, and write to the fields mapped as outputs. Typical jobs:
A script's results are ordinary field values: they appear on dashboards, can be sent to boards via MQTT, and can trigger rules — see the Data Flow guide for how they travel through the platform.
Scripts belong to a dashboard. Open the dashboard and choose Tools → Scripts.
The icon in front of each script in the list shows its state:
| Icon | Meaning |
|---|---|
| Green | Active and ready — the script runs. |
| Amber | Active, but the IO mapping is incomplete — the script does not run until the mapping is fixed (Section 3). |
| Grey | Not active — switched off in the settings. |
| runtime error | The last run failed (an exception or the time limit). The note disappears after the next successful run. |
A script never names fields in its code. Instead, the mapping table links each field to a short JS variable name. The same code can then be reused with other fields just by changing the mapping. Each row has:
| Column | What it does |
|---|---|
| IO | IN the script reads this field. OUT the script can write to this field. |
| Field | The field, by its full name — e.g. kitchen/temperature. |
| JS Variable | The name used in the code: inputs.temp for an IN row, outputs.heater for an OUT row. |
| trg (IN only) | A new value on this field starts the script. |
| pub (OUT only) | A value the script writes is also sent via MQTT to the field's subscribers — tick it when a board must receive the result, e.g. a relay command. |
When trg is ticked, two more options appear under the row:
Your code sees two objects, named after the JS variables of the mapping:
Every IN row becomes inputs.<variable> with two properties:
| Property | Content |
|---|---|
inputs.temp.value | The field's latest value. For the field that started the script, it is the new value that just arrived. |
inputs.temp.time | When that value was recorded, as an ISO text in UTC — e.g. 2026-09-26T08:15:30.000Z. |
"23.5", not 23.5 — and a field that has never received anything gives an empty value. Use parseFloat() and stop if the result is not a number:
const temp = parseFloat(inputs.temp.value);
if (isNaN(temp)) return; // no value yet, or not a number
Avoid Number() for this check: Number("") is 0, so an empty field would look like a real zero.
Assign a value to outputs.<variable> for each OUT row you want to write:
outputs.heater = 1; // a number
outputs.status = "OK"; // or text
outputs.power = { value: 575.2, time: inputs.amps.time }; // a value with its own time
null — writes nothing, and the field keeps its value. This is how a thermostat "holds" inside its dead band (example 2).{ value, time } — is stored with that time instead of the moment the script ran (example 7).return to stop early. Outputs assigned before the return are still written.Every run starts with empty outputs, and variables in the code do not survive from one run to the next. To remember something — a running total, a record, a smoothed value — keep it in a field, and map that field twice, with two different variable names:
| IO | JS variable | Field | Options |
|---|---|---|---|
| IN | liters | water/liters | trg |
| IN | total | water/total | — |
| OUT | newTotal | water/total | — |
The code reads the previous total from inputs.total and writes the new one to outputs.newTotal; the next run reads it back. The IN row of the remembered field must not have trg ticked — otherwise the script would start again from its own output. The mapping refuses that combination. See examples 9–11.
parseFloat() gives NaN — start from a sensible default, as the examples do. Values that arrive within a few milliseconds of each other may both read the same previous total; for sensors that report every second or slower this does not occur.
A script runs only when it is active and its mapping is complete (green icon).
Each output enters the platform like any other value, tagged with source SCRIPT: it is saved, shown on dashboards, sent via MQTT if pub is ticked, and can trigger rules, HTTP Requests and other scripts. When one script's output starts another script, the chain can be at most five scripts long; after that, no more scripts start. Values written by a rule never start a script. The full picture is in the Data Flow guide.
Scripts run in an isolated JavaScript sandbox. Available: the whole standard language — Math, JSON, Date, arrays, strings, try/catch, functions — plus console.log(). Not available: network requests, files, timers such as setTimeout, and require or other modules. To fetch data from the internet, use HTTP Requests and let a script process the stored response.
| Limit | Value | What happens |
|---|---|---|
| Run time | 50 ms per run | The run is stopped, nothing is written, and the script shows runtime error. |
| Memory | 32 MB per run | The same. |
| Script chain | 5 scripts | Further scripts in the chain do not start. |
| Timer interval | 1 second or more | Default 60 seconds. |
new Date().getHours() returns the UTC hour, not your local hour. For actions at a local time of day, use a Scheduler, which follows the dashboard's time zone.
In the code editor, the Input Simulation panel lists every IN variable with a box for a test value. Press Run Test: the code runs in your browser with those values, and Console Output shows your console.log() lines, the outputs the script would write, or the error it hit. A test writes nothing to your fields, so you can try values freely.
console.log() output is shown only in the test console. When the script runs live, it is not displayed anywhere — use the test panel to check your logic, and the Data Monitor to see what the live script actually writes.
isNaN check return before anything is written.Each example shows its IO mapping and the complete code. The field names are only examples — map your own fields and keep the JS variable names, or change both. trg and pub in the Options column are the checkboxes of the mapping row.
A board sends the raw 12-bit ADC value (0–4095) of a temperature transmitter with a −50…150 °C range. The script turns it into degrees.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | raw | tank/temp_raw | trg |
| OUT | celsius | tank/temperature | — |
// 0 … 4095 on the ADC = -50 … 150 °C on the transmitter
const raw = parseFloat(inputs.raw.value);
if (isNaN(raw)) return; // no value yet, or not a number
const celsius = raw * 200 / 4095 - 50;
outputs.celsius = Math.round(celsius * 10) / 10; // one decimal
Switches a heater around a setpoint that you change from the dashboard. Inside the dead band the script writes nothing, so the heater keeps its current state and does not chatter.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | temp | room/temperature | trg |
| IN | setpoint | room/setpoint | trg |
| OUT | heater | room/heater | pub |
const BAND = 0.5; // °C above and below the setpoint
const temp = parseFloat(inputs.temp.value);
const setpoint = parseFloat(inputs.setpoint.value);
if (isNaN(temp) || isNaN(setpoint)) return;
if (temp < setpoint - BAND) {
outputs.heater = 1; // too cold: heater on
} else if (temp > setpoint + BAND) {
outputs.heater = 0; // warm enough: heater off
}
// Inside the band: no output at all, so the heater stays as it is.
The pump may run only when it is requested, the emergency stop is released and the pressure is not too high. Any change on any of the three inputs re-evaluates the output at once.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | request | pump/request | trg |
| IN | estop | plant/estop | trg |
| IN | pressureHigh | plant/pressure_high | trg |
| OUT | pump | pump/run | pub |
const request = parseFloat(inputs.request.value) === 1;
const estopPressed = parseFloat(inputs.estop.value) === 1;
const pressureHigh = parseFloat(inputs.pressureHigh.value) === 1;
// Run only if everything is safe. A missing input counts as "not requested".
outputs.pump = (request && !estopPressed && !pressureHigh) ? 1 : 0;
Two sensors measure the same thing. The script writes the difference and raises an alarm when they drift apart — a sign that one of them needs attention.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | a | boiler/temp_a | trg |
| IN | b | boiler/temp_b | trg |
| OUT | diff | boiler/temp_diff | — |
| OUT | alarm | boiler/drift_alarm | — |
const LIMIT = 4.5; // allowed difference
const a = parseFloat(inputs.a.value);
const b = parseFloat(inputs.b.value);
if (isNaN(a) || isNaN(b)) return;
const diff = Math.abs(a - b);
outputs.diff = Math.round(diff * 10) / 10;
outputs.alarm = diff > LIMIT ? 1 : 0;
Runs on a timer, every 60 seconds. If the sensor has not sent a value for 5 minutes, the status field turns to 0 — a Rule on that field can then send you a notification.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | temp | garden/temperature | — |
| OUT | ok | garden/sensor_ok | — |
const MAX_AGE_MIN = 5;
const hasValue = !isNaN(parseFloat(inputs.temp.value));
const ageMin = (Date.now() - new Date(inputs.temp.time).getTime()) / 60000;
outputs.ok = (hasValue && ageMin < MAX_AGE_MIN) ? 1 : 0;
A gateway sends one JSON text with several readings. The script writes each reading to its own field. (For this job without code, see the JSON Splitter under Tools.)
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | payload | gateway/data | trg |
| OUT | temp | gateway/temperature | — |
| OUT | hum | gateway/humidity | — |
// Example message: {"temperature": 22.4, "humidity": 61}
let data;
try {
data = JSON.parse(inputs.payload.value);
} catch (e) {
console.log('Not valid JSON:', inputs.payload.value);
return;
}
if (typeof data.temperature === 'number') outputs.temp = data.temperature;
if (typeof data.humidity === 'number') outputs.hum = data.humidity;
Calculates power from voltage and current. The result is stored with the time of the current reading, not the time the script ran — useful when a board sends buffered readings with their own timestamps.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | volts | meter/voltage | — |
| IN | amps | meter/current | trg |
| OUT | watts | meter/power | — |
const volts = parseFloat(inputs.volts.value);
const amps = parseFloat(inputs.amps.value);
if (isNaN(volts) || isNaN(amps)) return;
outputs.watts = {
value: Math.round(volts * amps * 10) / 10,
time: inputs.amps.time // same timestamp as the current reading
};
Runs on a timer and writes the average of three room sensors. Sensors without a value are left out, so one dead sensor does not spoil the result.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | t1 | house/kitchen_temp | — |
| IN | t2 | house/living_temp | — |
| IN | t3 | house/bedroom_temp | — |
| OUT | average | house/average_temp | — |
const values = [inputs.t1, inputs.t2, inputs.t3]
.map(input => parseFloat(input.value))
.filter(v => !isNaN(v)); // skip sensors without a value
if (values.length === 0) return;
const sum = values.reduce((total, v) => total + v, 0);
outputs.average = Math.round(sum / values.length * 10) / 10;
These three keep their result in a field that is mapped both as IN and as OUT — see Remembering a value between runs.
A water meter sends the litres used since its previous message. The script adds them to a running total. The total field is mapped twice: as IN to read the current total, and as OUT to write the new one.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | liters | water/liters | trg |
| IN | total | water/total | — |
| OUT | newTotal | water/total | — |
const liters = parseFloat(inputs.liters.value);
if (isNaN(liters)) return;
let total = parseFloat(inputs.total.value);
if (isNaN(total)) total = 0; // first run: the total field is still empty
outputs.newTotal = Math.round((total + liters) * 100) / 100;
Keeps the lowest temperature ever measured. The script writes only when a new record is set. To start over, write a high value such as 999 to the record field from the dashboard.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | temp | garden/temperature | trg |
| IN | low | garden/temp_lowest | — |
| OUT | newLow | garden/temp_lowest | — |
const temp = parseFloat(inputs.temp.value);
if (isNaN(temp)) return;
const low = parseFloat(inputs.low.value);
if (isNaN(low) || temp < low) {
outputs.newLow = temp; // a new record (or the very first value)
}
A level sensor jumps up and down. Each new reading moves the smoothed value only part of the way towards it (an exponential moving average), so single spikes barely show.
| IO | JS variable | Field (example) | Options |
|---|---|---|---|
| IN | raw | tank/level_raw | trg |
| IN | smooth | tank/level | — |
| OUT | newSmooth | tank/level | — |
const K = 0.2; // 0…1: smaller = smoother but slower
const raw = parseFloat(inputs.raw.value);
if (isNaN(raw)) return;
const previous = parseFloat(inputs.smooth.value);
const next = isNaN(previous) ? raw : previous + K * (raw - previous);
outputs.newSmooth = Math.round(next * 100) / 100;
Virtuino Cloud — Scripts Guide