This text is AI generated!
SolarWBWB2 — Tasmota Berry Controller
A Tasmota Berry script (autoexec.be) that drives a bi-stable three-way valve
to switch a domestic hot-water circuit between an electric and a gas
waterboiler, based on a DS18B20 temperature reading. The same module also
drives two status LEDs and an OLED display.
This document explains how the code evolved from a flat procedural script into the current class-based version with manual/automatic mode, Home Assistant MQTT auto-discovery, and persistent user-configurable thresholds. The full V6 source is included at the end for reference.
1. Hardware & Tasmota setup
| Resource | Purpose |
|---|---|
| DS18B20 | Temperature sensor on the hot-water buffer |
| Relay 1 | Pulse coil A of the bi-stable valve (→ electric position) |
| Relay 2 | Pulse coil B of the bi-stable valve (→ gas position) |
| Relay 3 | LED “Electric” |
| Relay 4 | LED “Gas” |
| Relay 5 | Display on/off |
| Relay 6 | Virtual relay used as a valve-position indicator |
The valve coils need a 25 s pulse, so both relays are configured with
PulseTime 125 and made mutually exclusive with InterLock 1,2.
Default switching thresholds (now user-configurable, see §2 V6):
- T ≥ 40 °C → switch to the electric boiler
- T < 33 °C → switch to the gas boiler
- In between → keep the current position (hysteresis)
2. Evolution of the code
V1 — Flat procedural script
The original autoexec.be configured Tasmota at the top of the file and
defined a single t2v() (temperature-to-valve) function that read the
temperature, decided which boiler to use, and pulsed the right relay plus
the LEDs and the virtual indicator. A DS18B20 rule and a 30-minute cron
job both called t2v().
This worked, but everything lived in module scope: state (t, oldvalue)
was global, and the two switching branches inside t2v() were inline,
making them impossible to trigger manually.
V2 — Class refactor + Tasmota commands
Three changes were applied:
- Wrap everything in a class (
SolarWBWB2). State (t,oldvalue) and constants (thresholds, init sentinel) became class members, so the global namespace stays clean and the controller is easy to reason about. - Split
t2v()into two functions:switch_to_electric()andswitch_to_gas(). Each one pulses the correct valve coil, sets the virtual relay 6 indicator, and updates the two LEDs.t2v()now just picks which one to call based on the temperature. - Expose both functions as Tasmota commands with
tasmota.add_cmd, so they are reachable from the web console and from MQTT (cmnd/<topic>/ValveElec,cmnd/<topic>/ValveGas). Each handler replies with a JSON payload viatasmota.resp_cmnd, which is what MQTT clients expect.
V3 — Emergency switch-back uses the new function
A small but important correction: the “fast emergency switch-back to gas”
inside update_temp() was still calling the old inline logic. It was
changed to call self.switch_to_gas() so the LEDs and the virtual
indicator are kept consistent with the actual valve position. Comments
were tightened up at the same time.
V4 — Manual / Automatic mode
A runtime mode lets a human override the temperature-based logic. Design choices:
- Implicit manual switch — calling
ValveElecorValveGasputs the controller intomanualmode automatically. No separate “enable manual” step. - Explicit return to auto — a new
ValveAutocommand goes back to automatic and immediately re-runst2v()so the valve reflects the current temperature right away (instead of waiting up to 30 minutes for the next cron tick). - Mode is reported —
ValveMode(no payload) returns the current mode as JSON, so MQTT clients can poll it. - Safety in manual mode — while in manual, the cron evaluation, the boot-time evaluation, and the <33 °C emergency switch-back are all suspended. The valve stays exactly where the operator put it.
- No persistence — a reboot always starts in
auto. This keeps the script simple and matches the principle that a power cycle should return the system to a known-safe automatic state.
V5 — Home Assistant MQTT auto-discovery
V4 already exposed the controller over MQTT, but Home Assistant had no way
to find the custom Berry commands on its own — Tasmota’s native discovery
only knows about its built-in features (relays, sensors, etc.), not about
arbitrary add_cmd handlers. V5 closes that gap by having the Berry script
publish its own retained MQTT discovery payloads under the standard
homeassistant/ prefix.
Design choices:
- Hard-coded topic —
tasmota_BA39AC(Tasmota default for this MAC). - Default discovery prefix —
homeassistant/. - Mode UI = sensor + 3 buttons —
ValveElec,ValveGas,ValveAutobuttons, plus a read-only sensor for the current mode. - Valve position is left to Tasmota — Tasmota’s own native discovery already exposes Relay 6 (“Elec / Gas”) in HA, which is enough.
- One HA device — all entities share the same
device.identifiers, so HA groups them under one device card. - Availability — every entity uses
tele/<topic>/LWTasavailability_topic. - Mode state topic —
stat/<topic>/VALVE_MODE, retained. - Recovery on broker reconnect — a rule on
Mqtt#Connectedre-publishes both discovery and current state. - One-time Tasmota tweaks —
SetOption19 0(modern HA discovery, do not interfere),SetOption59 1(state telemetry on every power change),SetOption4 0(responses oncmnd/, notstat/).
V6 — Configurable, persistent thresholds (current)
V1–V5 hard-coded the upper/lower thresholds (40 / 33 °C) as static class members. V6 turns them into runtime-configurable, persistent values that can be changed from the Tasmota console, over MQTT, or directly from Home Assistant.
Design choices:
- Integers only. Switching at half-degree resolution adds no value here, and an integer command surface is simpler to validate and display. Payloads with a decimal point are rejected.
- Persistence via Berry’s
persistmodule. Two keys —valve_upper,valve_lower— are saved withpersist.save()after every successful change. On boot,init()loads them back if present; otherwise it falls back to the defaultsDEFAULT_UPPER = 40andDEFAULT_LOWER = 33. Static constantsDEFAULT_*keep those defaults visible at the top of the class. - Per-device instance state. The thresholds moved from
static var UPPER_THRESHOLDto instance varsself.upper/self.lower, since they are now mutable per device. - Validation.
- Must parse as an integer.
- Must lie in the absolute range
[THR_MIN, THR_MAX] = [0, 100]°C. - Cross-validation: setting
ValveUpperThresholdto a value<= self.loweris rejected (resp_cmnd_failed), and vice versa forValveLowerThreshold. This prevents an inverted range that would breakt2v(). - If the persisted pair is somehow inverted at boot,
init()self-heals by reverting to the defaults.
- Immediate effect. After a successful change, if the controller is
in AUTO mode,
t2v()is re-evaluated immediately so the valve position can react to the new threshold without waiting for the next cron tick. - MQTT state topics. Two retained topics mirror the current values:
stat/tasmota_BA39AC/VALVE_UPPERstat/tasmota_BA39AC/VALVE_LOWERPublished on every successful change, on boot, and on everyMqtt#Connected.
- HA auto-discovery as
numberentities. Twohomeassistant/number/config payloads are published (retained) alongside the existing V5 discovery messages. Both usemode: "box",step: 1,unit: °C, andentity_category: "config", so HA renders them as configuration inputs on the same device card. Each entity binds:command_topic→cmnd/tasmota_BA39AC/ValveUpperThreshold(or…/ValveLowerThreshold)state_topic→ the matchingstat/.../VALVE_UPPER|LOWERtopic When the user moves the slider in HA, HA publishes the new integer to the command topic; the Berry handler validates, persists, and echoes back via the state topic — closing the round-trip.
Resulting command surface (V6)
| Command | Payload | Effect | Side-effects |
|---|---|---|---|
ValveElec |
— | Pulse to electric | mode → manual, publish mode |
ValveGas |
— | Pulse to gas | mode → manual, publish mode |
ValveAuto |
— | Return to automatic, re-evaluate t2v() immediately |
mode → auto, publish mode |
ValveMode |
— | Report current mode as JSON | none |
ValveUpperThreshold |
int 0..100 | Set upper threshold (must be > lower) |
persist, publish state, re-eval if AUTO |
ValveLowerThreshold |
int 0..100 | Set lower threshold (must be < upper) |
persist, publish state, re-eval if AUTO |
ValveThresholds |
— | Report both thresholds as JSON | none |
Resulting HA entities (V6)
| HA entity | Component | Topic / command |
|---|---|---|
button.solarwbwb2_valve_electric |
button | cmnd/tasmota_BA39AC/ValveElec |
button.solarwbwb2_valve_gas |
button | cmnd/tasmota_BA39AC/ValveGas |
button.solarwbwb2_valve_auto |
button | cmnd/tasmota_BA39AC/ValveAuto |
sensor.solarwbwb2_valve_mode |
sensor | stat/tasmota_BA39AC/VALVE_MODE (auto/manual) |
number.solarwbwb2_valve_upper_threshold |
number | cmd ValveUpperThreshold, state …/VALVE_UPPER (°C int) |
number.solarwbwb2_valve_lower_threshold |
number | cmd ValveLowerThreshold, state …/VALVE_LOWER (°C int) |
| (Tasmota native) Relay 6 | switch | Valve position indicator (“Elec / Gas”) |
Removing the discovery from HA
If you ever want HA to forget these entities, publish empty retained payloads to the discovery topics:
mosquitto_pub -t homeassistant/button/solarwbwb2_ba39ac/valve_elec/config -r -n
mosquitto_pub -t homeassistant/button/solarwbwb2_ba39ac/valve_gas/config -r -n
mosquitto_pub -t homeassistant/button/solarwbwb2_ba39ac/valve_auto/config -r -n
mosquitto_pub -t homeassistant/sensor/solarwbwb2_ba39ac/valve_mode/config -r -n
mosquitto_pub -t homeassistant/number/solarwbwb2_ba39ac/valve_upper/config -r -n
mosquitto_pub -t homeassistant/number/solarwbwb2_ba39ac/valve_lower/config -r -n
3. Full source — autoexec.be V6
#----------------------------------------
# autoexec.be — SolarWBWB2 - V6
# - Initialises Tasmota configuration
# - Updates the display with the measured temperature
# - Controls the bi-stable three-way valve between
# electric and gas waterboilers based on temperature
# - V4: Manual / Automatic mode (ValveElec / ValveGas /
# ValveAuto / ValveMode)
# - V5: Home Assistant MQTT auto-discovery
# - button.solarwbwb2_valve_elec
# - button.solarwbwb2_valve_gas
# - button.solarwbwb2_valve_auto
# - sensor.solarwbwb2_valve_mode
# - V6 (this file): Configurable, persistent thresholds
# Two new Tasmota commands (web console + MQTT):
# ValveUpperThreshold <int 0..100> (>= -> electric)
# ValveLowerThreshold <int 0..100> (< -> gas)
# ValveThresholds (report both)
# Stored in Tasmota's `persist` module, so they
# survive reboots. Auto-discovered in HA as two
# `number` entities, grouped under the same device.
# Cross-validation enforces upper > lower.
# After a successful change in AUTO mode, t2v() is
# re-evaluated immediately.
#
# MQTT topic is hard-coded as "tasmota_BA39AC"
# (Tasmota default for this device's MAC).
#----------------------------------------#
import persist
#----------------------------------------
# One-time Tasmota configuration
#----------------------------------------#
tasmota.cmd('Teleperiod 30') # send sensors to MQTT every 30s
tasmota.cmd('SetOption65 1') # Disable Fast Power Cycle Device Recovery
tasmota.cmd('SetOption55 1') # Enable mDNS service
tasmota.cmd('SetOption19 0') # Use modern (native) HA discovery, not legacy
tasmota.cmd('SetOption59 1') # Send tele/%topic%/STATE on every power change
tasmota.cmd('SetOption4 0') # Return MQTT response on cmnd/ topic, not stat/
tasmota.cmd('Backlog0 Timezone 99; TimeStd 0,0,10,1,3,60; TimeDst 0,0,3,1,2,120') # Brussels
tasmota.cmd('WebButton1 Valve-Elec')
tasmota.cmd('WebButton2 Valve-Gas')
tasmota.cmd('WebButton3 LED-Elec')
tasmota.cmd('WebButton4 LED-Gas')
tasmota.cmd('WebButton5 Display')
tasmota.cmd('PulseTime1 125') # 25s pulse on relay1 (valve coil)
tasmota.cmd('PulseTime2 125') # 25s pulse on relay2 (valve coil)
tasmota.cmd('InterLock 1,2')
tasmota.cmd('InterLock 1')
tasmota.global.devices_present = 6
tasmota.cmd('WebButton6 Elec / Gas') # virtual relay6 = valve position
#----------------------------------------
# Main controller class
#----------------------------------------#
class SolarWBWB2
static var DEFAULT_UPPER = 40 # >= 40 C -> electric
static var DEFAULT_LOWER = 33 # < 33 C -> gas
static var THR_MIN = 0 # sane absolute bounds
static var THR_MAX = 100
static var INIT_SENTINEL = 5000 # "no value yet" marker
static var MODE_AUTO = "auto"
static var MODE_MANUAL = "manual"
# MQTT / HA discovery constants
static var TOPIC = "tasmota_BA39AC"
static var HA_PREFIX = "homeassistant"
static var DEV_ID = "solarwbwb2_ba39ac"
static var DEV_NAME = "SolarWBWB2"
var t # last measured temperature (DS18B20)
var oldvalue # previous measurement, for change detection / boot
var mode # "auto" | "manual"
var upper # configurable upper threshold (int, persisted)
var lower # configurable lower threshold (int, persisted)
def init()
self.t = nil
self.oldvalue = self.INIT_SENTINEL
self.mode = self.MODE_AUTO
# Load persisted thresholds, fall back to defaults.
self.upper = persist.has("valve_upper") ? int(persist.valve_upper) : self.DEFAULT_UPPER
self.lower = persist.has("valve_lower") ? int(persist.valve_lower) : self.DEFAULT_LOWER
if self.upper <= self.lower
self.upper = self.DEFAULT_UPPER
self.lower = self.DEFAULT_LOWER
end
tasmota.add_rule("DS18B20#Temperature",
/ value -> self.update_temp(value))
tasmota.add_cron("0 */30 * * * *",
/ -> self.t2v(), "every_30m")
tasmota.add_rule("Mqtt#Connected",
/ -> self.on_mqtt_connected())
tasmota.add_cmd("ValveElec",
/ cmd, idx, payload -> self.cmd_valve_elec(cmd, idx, payload))
tasmota.add_cmd("ValveGas",
/ cmd, idx, payload -> self.cmd_valve_gas(cmd, idx, payload))
tasmota.add_cmd("ValveAuto",
/ cmd, idx, payload -> self.cmd_valve_auto(cmd, idx, payload))
tasmota.add_cmd("ValveMode",
/ cmd, idx, payload -> self.cmd_valve_mode(cmd, idx, payload))
tasmota.add_cmd("ValveUpperThreshold",
/ cmd, idx, payload -> self.cmd_valve_upper(cmd, idx, payload))
tasmota.add_cmd("ValveLowerThreshold",
/ cmd, idx, payload -> self.cmd_valve_lower(cmd, idx, payload))
tasmota.add_cmd("ValveThresholds",
/ cmd, idx, payload -> self.cmd_valve_thresholds(cmd, idx, payload))
tasmota.set_timer(5000, / -> self.publish_discovery())
tasmota.set_timer(6000, / -> self.publish_mode_state())
tasmota.set_timer(6500, / -> self.publish_threshold_states())
end
def switch_to_electric()
tasmota.cmd('power1 1')
print('Switching to the electric waterboiler')
tasmota.cmd('power6 1')
tasmota.cmd('power3 1')
tasmota.cmd('power4 0')
end
def switch_to_gas()
tasmota.cmd('power2 1')
print('Switching to the gas waterboiler')
tasmota.cmd('power6 0')
tasmota.cmd('power3 0')
tasmota.cmd('power4 1')
end
def t2v()
if self.mode != self.MODE_AUTO return end
if self.t == nil return end
if self.t >= self.upper
self.switch_to_electric()
elif self.t < self.lower
self.switch_to_gas()
end
end
def update_temp(value)
if value != self.oldvalue
tasmota.cmd("DisplayText _T=" .. value .. " C")
end
self.t = value
if self.oldvalue == self.INIT_SENTINEL && self.mode == self.MODE_AUTO
self.t2v()
end
self.oldvalue = value
if self.mode == self.MODE_AUTO
if (self.t < self.lower) && tasmota.get_power(2)
self.switch_to_gas()
end
end
end
# ---- Mode commands -------------------------------------------------
def cmd_valve_elec(cmd, idx, payload)
self.mode = self.MODE_MANUAL
self.switch_to_electric()
self.publish_mode_state()
tasmota.resp_cmnd('{"ValveElec":"Done","Mode":"manual"}')
end
def cmd_valve_gas(cmd, idx, payload)
self.mode = self.MODE_MANUAL
self.switch_to_gas()
self.publish_mode_state()
tasmota.resp_cmnd('{"ValveGas":"Done","Mode":"manual"}')
end
def cmd_valve_auto(cmd, idx, payload)
self.mode = self.MODE_AUTO
print('Valve control returned to AUTO mode')
self.t2v()
self.publish_mode_state()
tasmota.resp_cmnd('{"ValveAuto":"Done","Mode":"auto"}')
end
def cmd_valve_mode(cmd, idx, payload)
tasmota.resp_cmnd('{"ValveMode":"' .. self.mode .. '"}')
end
# ---- Threshold commands (V6) --------------------------------------
def parse_int(payload)
if type(payload) == 'int' return payload end
if type(payload) == 'string'
var s = string.tr(payload, " \t", "")
if size(s) == 0 return nil end
if string.find(s, ".") >= 0 return nil end
try
return int(s)
except ..
return nil
end
end
return nil
end
def cmd_valve_upper(cmd, idx, payload)
var v = self.parse_int(payload)
if v == nil
tasmota.resp_cmnd_error()
return
end
if v < self.THR_MIN || v > self.THR_MAX
tasmota.resp_cmnd_error()
return
end
if v <= self.lower
tasmota.resp_cmnd_failed()
return
end
self.upper = v
persist.valve_upper = v
persist.save()
self.publish_upper_state()
if self.mode == self.MODE_AUTO self.t2v() end
tasmota.resp_cmnd('{"ValveUpperThreshold":' .. v .. '}')
end
def cmd_valve_lower(cmd, idx, payload)
var v = self.parse_int(payload)
if v == nil
tasmota.resp_cmnd_error()
return
end
if v < self.THR_MIN || v > self.THR_MAX
tasmota.resp_cmnd_error()
return
end
if v >= self.upper
tasmota.resp_cmnd_failed()
return
end
self.lower = v
persist.valve_lower = v
persist.save()
self.publish_lower_state()
if self.mode == self.MODE_AUTO self.t2v() end
tasmota.resp_cmnd('{"ValveLowerThreshold":' .. v .. '}')
end
def cmd_valve_thresholds(cmd, idx, payload)
tasmota.resp_cmnd('{"ValveUpperThreshold":' .. self.upper ..
',"ValveLowerThreshold":' .. self.lower .. '}')
end
# ---- HA discovery --------------------------------------------------
def device_block()
return '"dev":{"ids":["' .. self.DEV_ID .. '"],' ..
'"name":"' .. self.DEV_NAME .. '",' ..
'"mf":"Tasmota","mdl":"SolarWBWB2 Berry"}'
end
def publish_button(object_id, name, command)
var topic = self.HA_PREFIX .. "/button/" .. self.DEV_ID .. "/" .. object_id .. "/config"
var payload = '{' ..
'"name":"' .. name .. '",' ..
'"uniq_id":"' .. self.DEV_ID .. '_' .. object_id .. '",' ..
'"cmd_t":"cmnd/' .. self.TOPIC .. '/' .. command .. '",' ..
'"avty_t":"tele/' .. self.TOPIC .. '/LWT",' ..
'"pl_avail":"Online","pl_not_avail":"Offline",' ..
self.device_block() ..
'}'
tasmota.publish(topic, payload, true)
end
def publish_mode_sensor()
var topic = self.HA_PREFIX .. "/sensor/" .. self.DEV_ID .. "/valve_mode/config"
var payload = '{' ..
'"name":"Valve Mode",' ..
'"uniq_id":"' .. self.DEV_ID .. '_valve_mode",' ..
'"stat_t":"stat/' .. self.TOPIC .. '/VALVE_MODE",' ..
'"avty_t":"tele/' .. self.TOPIC .. '/LWT",' ..
'"pl_avail":"Online","pl_not_avail":"Offline",' ..
'"icon":"mdi:auto-mode",' ..
self.device_block() ..
'}'
tasmota.publish(topic, payload, true)
end
def publish_number(object_id, name, command, stat_topic, icon)
var topic = self.HA_PREFIX .. "/number/" .. self.DEV_ID .. "/" .. object_id .. "/config"
var payload = '{' ..
'"name":"' .. name .. '",' ..
'"uniq_id":"' .. self.DEV_ID .. '_' .. object_id .. '",' ..
'"cmd_t":"cmnd/' .. self.TOPIC .. '/' .. command .. '",' ..
'"stat_t":"' .. stat_topic .. '",' ..
'"min":' .. self.THR_MIN .. ',"max":' .. self.THR_MAX .. ',"step":1,' ..
'"mode":"box",' ..
'"unit_of_meas":"°C",' ..
'"entity_category":"config",' ..
'"icon":"' .. icon .. '",' ..
'"avty_t":"tele/' .. self.TOPIC .. '/LWT",' ..
'"pl_avail":"Online","pl_not_avail":"Offline",' ..
self.device_block() ..
'}'
tasmota.publish(topic, payload, true)
end
def publish_discovery()
self.publish_button("valve_elec", "Valve Electric", "ValveElec")
self.publish_button("valve_gas", "Valve Gas", "ValveGas")
self.publish_button("valve_auto", "Valve Auto", "ValveAuto")
self.publish_mode_sensor()
self.publish_number("valve_upper", "Valve Upper Threshold",
"ValveUpperThreshold",
"stat/" .. self.TOPIC .. "/VALVE_UPPER",
"mdi:thermometer-chevron-up")
self.publish_number("valve_lower", "Valve Lower Threshold",
"ValveLowerThreshold",
"stat/" .. self.TOPIC .. "/VALVE_LOWER",
"mdi:thermometer-chevron-down")
print('HA discovery published for ' .. self.DEV_ID)
end
# ---- Retained state publishers ------------------------------------
def publish_mode_state()
tasmota.publish("stat/" .. self.TOPIC .. "/VALVE_MODE", self.mode, true)
end
def publish_upper_state()
tasmota.publish("stat/" .. self.TOPIC .. "/VALVE_UPPER", str(self.upper), true)
end
def publish_lower_state()
tasmota.publish("stat/" .. self.TOPIC .. "/VALVE_LOWER", str(self.lower), true)
end
def publish_threshold_states()
self.publish_upper_state()
self.publish_lower_state()
end
def on_mqtt_connected()
self.publish_discovery()
self.publish_mode_state()
self.publish_threshold_states()
end
end
solar_wbwb2 = SolarWBWB2()
4. Operating cheatsheet
From the Tasmota web console:
ValveElec -> valve to electric, mode becomes manual
ValveGas -> valve to gas, mode becomes manual
ValveAuto -> back to automatic, re-evaluates immediately
ValveMode -> {"ValveMode":"auto"} or {"ValveMode":"manual"}
ValveUpperThreshold 42 -> set upper threshold to 42 °C (must be > lower)
ValveLowerThreshold 30 -> set lower threshold to 30 °C (must be < upper)
ValveThresholds -> {"ValveUpperThreshold":42,"ValveLowerThreshold":30}
Over MQTT (replace <topic> with the device’s Tasmota topic, here
tasmota_BA39AC):
cmnd/<topic>/ValveElec
cmnd/<topic>/ValveGas
cmnd/<topic>/ValveAuto
cmnd/<topic>/ValveMode
cmnd/<topic>/ValveUpperThreshold 42
cmnd/<topic>/ValveLowerThreshold 30
cmnd/<topic>/ValveThresholds
Each command publishes a JSON reply on stat/<topic>/RESULT.
Threshold changes additionally publish their new value (retained) on
stat/<topic>/VALVE_UPPER and stat/<topic>/VALVE_LOWER, which is what
Home Assistant reads back into the corresponding number entity.
5. Possible next steps
- Status telemetry — publish
{mode, valve_position, temperature, thresholds}totele/<topic>/SOLARWBWB2every Teleperiod. - Manual-mode timeout — auto-revert to
autoafter N minutes so a forgotten manual override doesn’t stay forever, exposed as anumberentity in HA. - Hysteresis as a single setting — derive
lowerfromupperminus a configurable hysteresis band, instead of two independent thresholds.