Why this is worth doing
Every electric underfloor heating installation has a temperature sensor buried in the screed. When the old thermostat is replaced with a Shelly, that sensor usually gets abandoned and a new probe is fitted wherever one will reach.
It does not have to be. A Shelly Plus Add-on can read the existing NTC sensor, whatever brand it is, and a script converts its resistance into a temperature the app can show and act on.
That matters because the buried sensor is in the right place. It measures the floor, not the air near it, and getting a new probe to the same depth means opening the screed.
This example uses a Devireg NTC 15 kΩ. Any NTC works — the different sensor section covers what to change, and it is not optional reading.
What you need
- A Shelly 1PM Gen3 or another Gen3/Gen4 device with the same form factor
- A Shelly Plus Add-on
- An NTC sensor — the one already in the floor, or any other
Update the device to the latest firmware before starting.
The sensor’s own numbers matter. A resistance printed on an NTC — 15 kΩ — is its resistance at 25 °C, not a rating. For the Devireg used here:
| Temperature | Resistance |
|---|---|
| 0 °C | 42 000 Ω |
| 25 °C | 15 000 Ω |
| 50 °C | 6 000 Ω |
Add the sensor
Wire the NTC to the Plus Add-on’s analog input, then tell the device it is there. In Shelly Smart Control:
- Open the device and tap the Add-on symbol
- Add peripheral
- Select Analog
- Add peripheral, and allow the reboot if it asks
The same is available in the web interface. After the reboot the device has a voltmeter component — usually id 100 — and that is what the script reads.
Install the script
Create a script on the device and paste this in. See how to add a script, and enable run on startup.
// Brief description:
// - Periodically reads voltage from a thermistor via a voltmeter component
// - Converts voltage to temperature using Steinhart-Hart equation
// - Writes calculated temperature to a virtual component
// - Retrieves desired temperature (either static or from virtual component)
// - Applies hysteresis and optional inversion to control a relay based on temperature
// SH Coefficient Calculator
// https://rusefi.com/Steinhart-Hart.html
//
// Thermistor wiki page
// https://en.wikipedia.org/wiki/Thermistor
/**************** START CHANGE HERE ****************/
let CONFIG = {
scanInterval: 10, // seconds, the script runs every 10 seconds to fetch the voltage
voltmeterID: 100, // ID of the voltmeter - when we install the plugin, the device defines this number
// Desired temperature settings
useVirtualDesiredTemp: true, // Set to true to use a virtual component for the desired temperature
desiredTemp: 25, // Desired temperature in Celsius (used if useVirtualDesiredTemp is false)
hysteresis: 1, // Hysteresis band in degrees Celsius
invertRelay: false, // Set to true to invert the relay action
/**
* Applies math to the voltage and returns the result. This function is called every time the voltage is measured
* @param {Number} voltage The currently measured voltage
* @returns The temperature based on the voltage
*/
calcTemp: function (voltage) {
const constVoltage = 10;
const R1 = 10000;
const A = 0.0010377385695278978;
const B = 0.00021633455581572119;
const C = 2.654857502585547e-7;
const R2 = R1 * (voltage / (constVoltage - voltage));
const logR2 = Math.log(R2);
let T = 1.0 / (A + (B + C * logR2 * logR2) * logR2);
T = T - 273.15; // Celsius
console.log(
"Current Temperature: " +
T.toFixed(2) +
" °C | Voltage: " +
voltage +
"V | Resistance: " +
R2.toFixed(2) +
"Ω"
);
return T;
},
/**
* This function is called every time a temperature is read
* @param {Number} temperature The currently calculated temperature
* @param {Number} desiredTemp The desired temperature
*/
onTempReading: function (temperature, desiredTemp) {
// Fetch the current relay status
let switchStatus = Shelly.getComponentStatus("switch:0");
if (switchStatus === null || typeof switchStatus.output !== "boolean") {
console.log("Cannot read relay status");
return;
}
let relayIsOn = switchStatus.output; // true or false
// Calculate thresholds
let onThreshold = desiredTemp - CONFIG.hysteresis / 2;
let offThreshold = desiredTemp + CONFIG.hysteresis / 2;
let turnRelayOn = false;
let turnRelayOff = false;
if (!CONFIG.invertRelay) {
// Normal operation
if (!relayIsOn && temperature < onThreshold) {
turnRelayOn = true;
} else if (relayIsOn && temperature > offThreshold) {
turnRelayOff = true;
}
} else {
// Inverted operation
if (!relayIsOn && temperature > offThreshold) {
turnRelayOn = true;
} else if (relayIsOn && temperature < onThreshold) {
turnRelayOff = true;
}
}
if (turnRelayOn) {
// Turn relay on
Shelly.call("Switch.Set", { id: 0, on: true });
console.log("Turning relay on");
} else if (turnRelayOff) {
// Turn relay off
Shelly.call("Switch.Set", { id: 0, on: false });
console.log("Turning relay off");
} else {
// No relay action needed
console.log("No relay action needed");
}
},
};
/**************** STOP CHANGE HERE ****************/
function fetchVoltage() {
// Fetch the voltmeter component
const voltmeter = Shelly.getComponentStatus(
"voltmeter:" + JSON.stringify(CONFIG.voltmeterID)
);
// Exit if component does not exist
if (typeof voltmeter === "undefined" || voltmeter === null) {
console.log("Cannot find voltmeter component");
return;
}
const voltage = voltmeter["voltage"];
// Exit if voltage cannot be read
if (typeof voltage !== "number") {
console.log("Cannot read the voltage or it is NaN");
return;
}
// Calculate the temperature based on the voltage
const temp = CONFIG.calcTemp(voltage);
// Write temperature to virtual component with id 200
Shelly.call("Number.Set", { id: 200, value: Number(temp.toFixed(2)) });
print("Temperature " + temp.toFixed(2) + " °C written to Virtual component (Id:200)");
// Exit if temperature was not calculated correctly
if (typeof temp !== "number") {
console.log("Something went wrong calculating the temperature");
return;
}
// Fetch desired temperature if necessary
if (CONFIG.useVirtualDesiredTemp) {
Shelly.call(
"Number.GetStatus",
{ id: 201 },
function (result, error_code, error_message) {
if (error_code === 0 && result && typeof result.value === "number") {
let desiredTemp = result.value;
console.log("Desired temperature fetched from virtual component: " + desiredTemp + " °C");
CONFIG.onTempReading(temp, desiredTemp);
} else {
console.log("Error fetching desired temperature: " + error_message);
// Handle error, maybe use default desired temperature
CONFIG.onTempReading(temp, CONFIG.desiredTemp);
}
}
);
} else {
// Use desired temperature from CONFIG
CONFIG.onTempReading(temp, CONFIG.desiredTemp);
}
}
// Initialize the script
function init() {
// Start the timer
Timer.set(CONFIG.scanInterval * 1000, true, fetchVoltage);
// Fetch voltage at startup
fetchVoltage();
}
init();Everything you need to change is in the CONFIG block at the top:
| Setting | Default | What it does |
|---|---|---|
scanInterval |
10 | Seconds between readings |
voltmeterID |
100 | The voltmeter component the Add-on created |
useVirtualDesiredTemp |
true | Take the setpoint from a virtual component. False uses desiredTemp instead |
desiredTemp |
25 | Setpoint in °C, used only when the above is false |
hysteresis |
1 | Degrees between switching off and on again |
invertRelay |
false | True for cooling: the relay runs above the setpoint rather than below |
Below CONFIG sits calcTemp, which converts resistance to temperature using the Steinhart–Hart equation. Its three coefficients A, B and C describe one specific thermistor — the ones here are calculated for the Devireg 15 kΩ above.
constVoltage and R1 describe the Add-on’s voltage divider, not the sensor. Leave them alone.
Virtual components
To see and set the temperature from the app, the device needs two Number virtual components. Open the device, tap the Virtual components icon, and create them under Components.
Create them in this order — the id comes from the order of creation, and the script expects exactly these two:
- Current temperature, id 200 — where the script writes the reading
- Desired temperature, id 201 — where you set the target
Give the second a slider range that matches the room. For a floor, 15 to 30 °C is a useful span; above 27 °C is worth thinking twice about on wood.
After about ten seconds the temperature appears, and the slider controls the heating.
Group them. Under the Group tab, create a group with both components and switch on Extract virtual group as device. It then behaves like a device on the home screen, and a scene can trigger on its value. A free account can extract one group.
A different sensor
Everything above works unchanged for a Devireg 15 kΩ. For any other NTC, three numbers in the script have to be recalculated — and this is the part to get right.
Find three resistance values from the sensor’s datasheet: at its minimum temperature, at 25 °C, and at its maximum. For a typical NTC 10 kΩ:
| Point | Temperature | Resistance |
|---|---|---|
| Minimum | −40 °C | 323 839 Ω |
| Nominal | 25 °C | 10 000 Ω |
| Maximum | 90 °C | 1 034 Ω |
Feed them to a Steinhart–Hart calculator to get the coefficients:
Open the tool Steinhart–Hart coefficient calculator Enter three temperature and resistance pairs to get A, B and C. Note that it takes temperatures in Fahrenheit. Opens on rusefi.com — opens in a new windowPaste the three values into calcTemp, replacing A, B and C.
Then check it against something you can measure. Leave the sensor in the room, read a thermometer, and compare. If the script says 21 °C and the room is 21 °C, the coefficients are right. If it says 31, they are not — and that is a five-second test that catches the whole class of error.
Where this script comes from
The voltage-to-temperature conversion is based on Shelly’s own ntc-conversion example, licensed under Apache 2.0. The relay control and the coefficients are by Ronni Nielsen.
What has been changed:
- The relay control is new. The original leaves its switching commented out, as a place for you to write your own — this version implements it, with an on and off threshold either side of the setpoint.
- Hysteresis and inversion are added, so the same script drives heating or cooling.
- Virtual components are read and written, so the temperature and the setpoint live in the app rather than in the code.
- The coefficients are recalculated for a Devireg 15 kΩ. The original’s are for a 10 kΩ thermistor.
- The coefficients are verified. Solving Steinhart–Hart for the Devireg’s three points reproduces the values in the script exactly — the reading is correct at 0, 25 and 50 °C.
Tips & best practices
- Measure the old sensor before trusting it. A multimeter across the leads at a known room temperature tells you both that it is alive and roughly which curve it is on.
- Widen the hysteresis on a floor. One degree suits air; a screed responds slowly enough that a narrow band just cycles the relay.
- Cap the floor temperature in your automation. 27 °C is the usual limit for wood, and the script will happily go past it.
- Write the coefficients down. If the script is ever reinstalled, three numbers stand between the device and a correct reading, and they cannot be recovered from the sensor.
- The setpoint survives a reboot because it lives in the virtual component rather than the script. That is the main reason to prefer it over
desiredTemp.