Lab & hardware

Vevor diesel heater BLE automation

On this page
  1. The problem
  2. Architecture
  3. v1: reaching the setpoint
  4. v2: stabilising
  5. Estimating diesel use
  6. Ventilation
  7. Code

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.

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.

Schematic: the factory thermostat cycling around an 18 °C target 12 °C15 °C18 °C21 °C24 °C target 18 °C burner on 2 hours → factory thermostat
Schematic: the factory thermostat at an 18 °C comfort target. The room swings by 6 to 8 degrees, overshooting and then dropping well below the target, with a fresh ignition every cycle.
Schematic: the heater fixed at its lowest level with a 5 °C target, still cycling 3 °C5 °C7 °C9 °C target 5 °C burner on 90 minutes → fixed at level 1
Schematic: even at its lowest level (1) and a 5 °C target the heater overshoots by about 2 °C, shuts down and starts again every 15 minutes.

Architecture

The system has three layers, each an ESPHome package on one ESP32-S3.

  1. 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) and heater_set_temperature(°C). Power is mapped to the heater's ten levels with round(percentage / 10). It also publishes burner state, room and burner temperature, battery voltage, altitude, fan level and error code.
  2. Thermostat layer. A standard ESPHome thermostat climate 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.
  3. 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.

Schematic: frost guard at a 1.5 °C target, the heater burning at level 1 0 °C1 °C2 °C3 °C4 °C target 1.5 °C burner on, level 1 3 hours → room, thermostat layer
Schematic: frost guard at a 1.5 °C target, below what the factory controls allow. The thermostat layer heats below target − 0.2 °C and idles above target + 1.5 °C.

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.

Schematic: my PID setup overshooting on its way to 18 °C, compared with rules that brake just before the target 10 °C14 °C18 °C22 °C target 18 °C 1 hour → PID, my setup rules by hand
Schematic: warming up from 10 °C. The PID as it behaved in my setup overshoots by about 3 °C and swings before it settles. The hand-written rules go to full power straight away and brake just before the target.

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.

Measured cold start: boost sends level 10, the room climbs from 13.5 to 18.2 °C in about five minutes after ignition, then boost lets go boostcurve 13 °C15 °C17 °C19 °C21 °C 0 %50 %100 % 21:1921:2121:2321:2521:2721:2921:31 level 1032 room temperature proposed power ignition: fan on, no flame yet 18.2 °C: error below 2 °C dashed: v1.1 linear alone target 20 °C
Measured: room temperature (terracotta) against the target (dotted), with the proposed power below. The shaded band is where boost sets the output.

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

Power curve of algorithm v1.3 for curve factors 0.5, 1.0 and 1.5 0%25%50%75%100% 0%25%50%75%100% power in (from v1.2) → power out f = 0.5 (default) f = 1.0 (linear) f = 1.5 (softer)
The v1.3 power curve pf, computed from the formula. A factor below 1 lifts the middle of the range; above 1 it softens it. In the firmware the part below 30 % is a straight line, as in the formula above.

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:

SituationChange 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 minreset 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.

Measured: after boost the reduce-error supplement keeps the heater at level 4 to 5 until the room reaches 20 °C, then the heater tacks between level 1 and 4 around the target reduce error 17 °C18 °C19 °C20 °C21 °C 0 %50 %100 % fan 21:4021:5022:0022:1022:20 10level 4level 5432level 2level 3321 room temperature proposed power dashed: v1.1 linear alone roof fan target 20 °C
Measured: room temperature (terracotta) against the target (dotted), the proposed power in the middle and the roof fan at the bottom. The shaded band is where v1.4 sets the output.

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.

Schematic: v2.1 narrows a power band around the rolling average until the room settles on the target oscillation detected band narrows over W 16 °C18 °C20 °C 0%25%50%75%100% 0 min15 min30 min45 min60 min target 18 °C room temperature heater power band around average v1 only
Schematic: once two crossings show the room is oscillating, v2.1 narrows a band around the rolling average power. The output (solid) is clamped inside it while plain v1 (dashed) would keep swinging, and the room settles on the target.

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.

Measured: v2.1 narrows the power band until the heater burns at level 1 for most of an hour without switching off, with the room between 19.6 and 20.7 °C reduce errorstabilisestabilise 19 °C20 °C21 °C 0 %50 %100 % fan 22:0022:1022:2022:3022:4022:5023:0023:10 level 23level 12level 1 room temperature proposed power light band: what v2.1 allows roof fan target 20 °C
Measured: the light band is the range v2.1 allows around the average power; the output (sage) settles at level 1. The roof fan at the bottom explains most of the remaining swing.

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.

Diesel use per hour during one cold night, 0.51 litres in total 0.000.020.040.060.08 litres per hour total 0.51 L 0.05 23:00 0.04 00:00 0.06 01:00 0.06 02:00 0.06 03:00 0.07 04:00 0.06 05:00 0.05 06:00 0.06 07:00
One cold night: 0.51 L of diesel in total, about €1.22 at €2.40 a litre, at 0.04 to 0.07 L per hour.

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.