Reliable heater control without vendor lock-in. This open-source project automates Vevor diesel heaters over Bluetooth Low Energy with ESPHome, so you can integrate predictable heating behavior into Home Assistant setups. Check it out on GitHub.
Lab & hardware
Vevor diesel heater BLE automation
Taming an oversized diesel heater: from on/off to a steady burn
± 14 min read
The problem
My campervan Bassie has an 8 kW Vevor diesel heater. In a small, uninsulated van even its lowest setting overshoots. The heater's built-in thermostat makes it worse:
- It reads its own sensor, in whole-degree steps.
- It only accepts setpoints between 8 and 36 °C, so there is no frost-guard mode.
- It does not compensate well when the room stays below target for a long time.
The result is cycling: heat, overshoot, shut down, cool down, ignite again. Every ignition runs the glow plug and drives the fuel pump at its highest rate. My own note from the first build: ignitions are expensive, so find a sweet spot and stay there. That's also more peaceful. That sentence became the design goal.
Architecture
The system has three layers, each an ESPHome package on one ESP32-S3.
- BLE driver
(open source).
It speaks the heater's Bluetooth protocol and exposes a small interface:
heater_turn_on,heater_turn_off,heater_set_power(percentage)andheater_set_temperature(°C). Power is mapped to the heater's ten levels withround(percentage / 10). It also publishes burner state, room and burner temperature, battery voltage, altitude, fan level and error code. - Thermostat layer. A standard ESPHome
thermostatclimate entity decides whether to heat. It reads an external SCD4x sensor (0.1 °C resolution) and falls back to the heater's own sensor if that one is unavailable. It heats below target − 0.2 °C and goes idle above target + 1.5 °C, with 60 s minimum run and idle times. - Control algorithms. Every 5 seconds a control loop decides how much to heat. A select entity, set from Home Assistant, switches between the heater's own temperature mode and six algorithm versions, so I can compare them in the field.
All three layers run on the ESP32 itself. Home Assistant is only used to set the target, pick the algorithm and read the data. If it goes down, the chip keeps controlling the heater on its own.
The loop has a few fixed guard rails:
- No control during the first 60 s after boot, so the sensors can settle. A sensor that briefly reports an inaccurate low value would otherwise send the heater to full throttle.
- A 5 s settle time after the target changes.
- Thermostat OFF or IDLE means the heater is turned off, whatever the algorithm says.
- The output is clamped to 10–100 %. During the first five minutes after boot it is held at 10 %, while the sensor warms up, for the same reason: no full throttle on a reading that is still too low.
Each algorithm publishes its own proposed power as a diagnostic sensor, and a text sensor shows which stage actually changed the output. That made it possible to see in Home Assistant graphs what every layer was doing.
Owning the thermostat also unlocks a real frost guard. The factory controls stop at 8 °C; with the thermostat layer the van is kept just above freezing while I'm away. Setting that frost-guard target when I leave, and switching the heater off when the engine starts, are Home Assistant automations, not logic on the chip.
v1: reaching the setpoint
My first attempt was the textbook one. I read up on PID controllers and tried a PID thermostat. It worked, but tuning it was hard.
A PID controller is great at holding a sensor value steady in the long run, and a well-tuned one does not have to overshoot much. In my setup it did: it overshot and undershot a few times before it settled. For a heater that is exactly what you don't want. It should reach the target quickly and stay there. In theory a PID can do that, but the right gains depend on how fast the van loses heat, and that changes all the time with the outside temperature, wind and how often the door opens. Gains that worked one night were wrong the next.
So I took a step back and asked a different question: if I sat next to the heater and set it by hand, what would I do? Turn it up when it's far too cold, ease off when it gets close, back off further when it overshoots, and stay put once it feels right. Those rules became the algorithms below.
The v1 algorithms build on each other. Each one takes the output of the previous one and adjusts it. The input is the error e = target − room temperature, in °C.
v1.1 Linear. A plain proportional controller: 10 % power per degree of error.
P = 10 · e
float err = id(climate_error).state; // target - room, in °C
float proposed_power = lroundf(err * 10); // 10 % per degree v1.2 Boost. When the room is more than 2 °C below target, go straight to 100 %. A cold van warms up fast and the controller only takes over close to the target. The threshold started at 3 °C and was lowered to 2 °C in March.
A real cold start, on 19 October 2026: the van had cooled to 13.8 °C when I set it to 20 °C at 21:20. Boost sends level 10 straight away. During ignition the fan already blows but there is no flame yet, so the reading first dips to 13.5 °C. Once the flame catches, the room climbs almost a degree a minute. At 18.2 °C the error drops below 2 °C and boost lets go.
v1.3 Curve. A power curve reshapes the linear output. With p = P / 100 and curve factor f (default 0.5), the output is pf above 30 % and a straight line below it. The linear segment avoids an infinitely steep slope near zero.
p′ = pf for p ≥ 0.3, p′ = p · 0.3f / 0.3 below
v1.4 Reduce error. A pure P-controller settles below the target when the van loses more heat than the proposed power delivers. v1.4 adds a slowly built supplement, evaluated once a minute. In control terms it is an integral term with hand-tuned anti-windup:
| Situation | Change per minute |
|---|---|
| Heating longer than 10 min and e > 0.3 °C | +2 % (max 90 %) |
| At or above target (e ≤ 0) | −5 % |
| Well above target (e < −0.8 °C) | −10 % |
| Thermostat goes from heating to idle | −20 % |
| Idle for 30 min | reset to 0 % |
The thermostat only goes from heating to idle when the room is well above target. Ideally that never happens, because every idle means another on/off toggle. Sometimes it cannot be avoided, for instance when the difference between inside and outside is small and even the lowest level heats too much. The −20 % step makes sure the next run starts lower.
The supplement is what brings the room the last degrees and keeps it on target. On a cold evening, 21 October 2026, the van had cooled to 10.7 °C on frost guard when I set it to 20 °C at 21:22. Boost let go at 18.1 °C, a quarter of an hour later, and v1.4 took over at level 4 to 5. Then the room stalled at 18.7 °C for a few minutes: the roof fan had stepped up for CO₂ and was blowing in fresh, cold air. Linear alone would have settled for about 13 %; the supplement kept climbing by 2 % a minute, to 38 %, until the room reached 20 °C at 21:53. From there it drained by 5 % a minute and the heater stepped down to level 1. The room dipped to 19.6 °C, the supplement built up again and the heater went back up to level 3 and 4. It tacks around the target, without switching off.
v2: stabilising
v1 reaches the setpoint. v2 is about staying there without cycling.
Zones. Around the target the temperature is classified into zones: below target, just above target, within 0.5 °C of the idle boundary, and above it. Every move from below to above the target, or back, counts as a crossing. Two crossings with both sides seen means the system is oscillating.
v2.1 Stabilise. While oscillating, v2.1 keeps a rolling average of the power over a 15-minute window and clamps the output to a band around it. The band starts 100 % wide and narrows to a minimum of 15 % over that window:
B(t) = 100 − (100 − Bmin) · min(1, t / W), P ∈ [P̄ − B/2, P̄ + B/2]
The effect is that the heater converges on the power level the van actually needs: the sweet spot from my first note, found automatically. The band is released when the burner stops or the target changes.
The same cold evening shows v2.1 at work. After two crossings the band starts to narrow, and at 22:18 it takes over: the output stays at 10 %, level 1, for the rest of the evening, apart from five minutes at level 2. The room moves between 19.6 and 20.7 °C, and much of that movement is the roof fan: when it steps up to clear CO₂, the fresh air pulls the room down. In two hours the heater ignited once.
v2.2 Soft start. Two final clamps, both to 10 %:
- After the thermostat went idle because the room overshot (above target + 1.5 °C), the next 30 minutes of heating are limited to 10 %.
- Within 0.5 °C of that idle boundary, the output is held at 10 % so the heater does not tip over it and shut down.
float upper_boundary = target + heat_overrun; // where the thermostat goes idle
bool almost_latched = room > (upper_boundary - 0.5f);
if (is_heating && almost_latched) {
input_power = 10.0f; // slow down before the boundary instead of tipping over it
} Together they turn "overshoot, shut down, re-ignite at full power" into "slow down before the boundary, restart gently". v2.2 is the version that runs in the van today.
Estimating diesel use
The heater does not report fuel use, but it does report its fan level, and each level maps to a fixed pump pulse rate. I measured those rates myself: I recorded the ticking of the dosing pump at every level and counted the spikes in the audio. With 0.022 ml per pulse:
Q [L/h] = fpulse [Hz] · 0.022 ml · 3.6
That gives about 0.16 L/h at level 1 (122 pulses per minute) to 0.36 L/h at level 10 (276 pulses per minute). Ignition is counted at level 10. An integration sensor turns the flow into total litres, and a utility meter in Home Assistant tracks what is left in the tank.
Ventilation
The same ESP32 also drives the Maxxfan roof fan from CO₂: off below 800 ppm, rising to full speed at 1200 ppm, averaged over time to keep it calm. It runs in intake mode: fresh air comes in from the roof and leaves through a cracked hatch in the engine cover, so the van sits at slight overpressure. The heater itself hangs under the van and its exhaust blows out at the edge, but with the wind from the wrong side the fumes drift back under the floor, right where that hatch is. See the Maxxfan project for the hardware and the control logic, and my article on ventilating a camper (in Dutch) on CZYK. Off-grid for how it keeps the air fresh.
Code
The BLE driver is open source: esphome-vevor-diesel-heater-ble. The control algorithms are closed source for now, though I may open-source them later. Until then, the formulas and fragments above show how they work.
Looking for the practical side? On CZYK. Off-grid I wrote, in Dutch, a guide to staying warm in a camper and the build log of fitting this heater under the van.
Curious how to implement this yourself? Feel free to send me a message or reach out on LinkedIn.