Two Controllers, One Relay: A Lease Pattern for Home Assistant and Shelly

A relay can only have one controller. When Home Assistant and a script inside the device both write the same relay, they fight. Each one reverses the other, and the device keeps the last write.

This article describes a lease pattern. Home Assistant declares that it wants control. The device holds that declaration for a fixed time. While the lease is valid, the device makes no relay writes. When the lease goes stale, the device takes over and applies a fallback policy. Exactly one owner exists at all times, and the device decides.

Safety note. Mains wiring and high-current loads require an electrician and independent protection. The example below controls a water heater. Read the safety section before you copy the fallback policy from this article.

The trap: the device cannot tell Home Assistant from a person

Home Assistant drives a Shelly relay over a WebSocket connection. When the switch changes, the device reports the source as WS_in. A person who clicks the same relay in the device web interface produces the same value. The device cannot separate the two.

Many device scripts treat WS_in as “a person did this” and record a manual override. In that design, every Home Assistant command becomes a manual override. A typical failure looks like this:

  • Home Assistant turns the relay on.
  • The script records a manual override in the ON state.
  • Home Assistant turns the relay off for a safety reason.
  • The script re-asserts ON, or records a new override, within one control cycle.

Both controllers now believe they are correct. The relay follows whichever one wrote last, and the logs fill with repeated switching.

Why “keep the other automation disabled” is not a control

The usual defence is a written rule: leave the other automation off. That is a procedure, not a control. A procedure depends on memory, on every future reader, and on nobody pressing a toggle while troubleshooting something else. It will fail eventually, and it fails silently.

A control is something the system enforces without help. The lease is that control. Home Assistant cannot write the relay by accident, because the device knows whether Home Assistant is the current owner.

The four states

The device resolves every control cycle into exactly one state. Later rows lose to earlier rows.

State Condition Relay behaviour
Thermal cut Device temperature at or above the limit Relay forced off. Overrides everything.
Home Assistant control Lease is fresh and the automation is enabled The device makes no writes. Home Assistant decides.
Manual override A person acted while no owner was present Human state is held until the window closes.
Fallback No fresh lease, or the automation is disabled Your chosen policy. This article uses fail-on.

Step 1: add the lease to the device script

Add three small functions. The device does not need to trust Home Assistant. It only needs to know when Home Assistant last spoke.

var LEASE_TTL_MS = 5 * 60 * 1000;   // Home Assistant counts as absent after 5 minutes
var OVERHEAT_C   = 80;              // device temperature limit, not water temperature

var haLeaseTs      = 0;
var haWantsControl = false;
var overheatLatch  = false;

// Home Assistant calls this over RPC with Script.Eval.
function setHaLease(wantsControl) {
  haLeaseTs      = Date.now();
  haWantsControl = !!wantsControl;
  // Persist so a device reboot does not instantly expire a valid lease.
  Shelly.call("KVS.Set", { key: "ha_lease_ts", value: String(haLeaseTs) });
  return "ok";
}

function leaseFresh() {
  return haLeaseTs > 0 && (Date.now() - haLeaseTs) < LEASE_TTL_MS;
}

function inHaControl() {
  return leaseFresh() && haWantsControl;
}

Persist the timestamp. Without persistence, a device reboot throws away a valid lease and the device takes over for up to one heartbeat interval. That is a short, unwanted interruption, not a failure, but it is easy to avoid.

Step 2: ignore relay writes while Home Assistant owns the relay

This single guard fixes the conflict. When Home Assistant is the owner, a WS_in write is Home Assistant talking, so the script must not record it as human intent.

Shelly.addStatusHandler(function (status) {
  if (!status || status.component !== "switch:0") return;
  if (!status.delta || typeof status.delta.output !== "boolean") return;
  if (status.delta.source !== "WS_in") return;

  // Home Assistant uses the same transport as a person using the web UI.
  // While it owns the relay, do not read these writes as human intent.
  if (inHaControl()) return;

  applyManualOverride(status.delta.output);
});

The control loop then applies the state table. Note that thermal protection runs before the ownership check, so it stays active even when Home Assistant owns the relay.

function control() {
  var sw = Shelly.getComponentStatus("switch", 0);
  if (!sw) { setRelay(false, "no switch status"); return; }

  var temp = sw.temperature ? sw.temperature.tC : null;

  // 1. Thermal protection always wins, in every state.
  if (temp !== null && temp >= OVERHEAT_C) {
    overheatLatch = true;
    setRelay(false, "thermal cut at " + temp + " C");
    return;
  }
  if (overheatLatch) {
    // Clear only after the device cools. Adjust or replace this rule for your load.
    if (temp !== null && temp < OVERHEAT_C - 10) { overheatLatch = false; }
    else { setRelay(false, "waiting for cooldown"); return; }
  }

  // 2. Home Assistant owns the relay. Make no writes at all.
  if (inHaControl()) return;

  // 3. Fallback policy. Choose this with the safety section in mind.
  setRelay(true, "fallback: no owner");
}

Step 3: push the lease from Home Assistant

Home Assistant calls Script.Eval on the device RPC endpoint. A rest_command is enough. The payload renders the automation state as true or false, which is valid JavaScript for the setHaLease call.

rest_command:
  relay_lease:
    url: "http://RELAY_IP/rpc"
    method: POST
    content_type: "application/json"
    payload: >-
      {"id":1,"method":"Script.Eval","params":{"id":1,"code":"setHaLease({{ is_state('automation.water_heater_window','on') | lower }})"}}

Send the lease on a fixed interval. Use one lease period comfortably shorter than the device timeout. One minute against five minutes tolerates four missed heartbeats.

- id: relay_lease_heartbeat
  alias: Relay lease heartbeat
  mode: queued
  max: 3
  triggers:
    - trigger: homeassistant
      event: start
    - trigger: time_pattern
      minutes: "/1"
    - trigger: state
      entity_id: automation.water_heater_window
  actions:
    - action: rest_command.relay_lease

Use mode: queued, not mode: single. The minute tick and a state change can arrive in the same second, and single drops one of them with a warning. A dropped lease push is harmless here, but the warnings are noise that hides real problems later.

Step 4: make the window automation restart-safe

This is where the expensive bugs live. Two of them are common, and both energise a load at the wrong time.

  • No window condition on the start trigger. A homeassistant: start trigger fires on every restart. Without a condition, a restart at 23:00 turns the load on.
  • The stop action is unreachable after a restart. If the off action sits after a long repeat block inside the same action sequence, a restart in the middle aborts the sequence. The load then stays on with no further trigger.

Give every trigger an id, handle the stop path first, and refuse to run outside the window. The lease call goes first, so ownership is declared before the first relay write.

- id: water_heater_window
  alias: Water heater window
  initial_state: false
  mode: single
  triggers:
    - trigger: time
      at: "08:45:00"
      id: start
    - trigger: homeassistant
      event: start
      id: resume
    - trigger: state
      entity_id: automation.water_heater_window
      to: "on"
      id: resume
    - trigger: time
      at: "14:00:00"
      id: stop
  actions:
    - action: rest_command.relay_lease
    - if:
        - condition: trigger
          id: stop
      then:
        - action: switch.turn_off
          target:
            entity_id: switch.water_heater_relay
        - stop: "Window closed."
    - if:
        - condition: template
          value_template: "{{ now() < today_at('08:45') or now() >= today_at('14:00') }}"
      then:
        - action: switch.turn_off
          target:
            entity_id: switch.water_heater_relay
        - stop: "Outside the window."
    - action: switch.turn_on
      target:
        entity_id: switch.water_heater_relay

The state trigger on the automation’s own entity looks unusual, but it is useful. It means that switching the automation on inside the window starts the run immediately, instead of waiting for the next day.

Step 5: choose the fallback policy with care

The lease decides who owns the relay. It does not decide what to do when nobody owns it. That is a separate engineering and safety decision.

Fail-off is the safe default for most loads. Lights, pumps and appliances stay off when the controller is absent. Nobody is surprised.

Fail-on suits a small set of loads where the cost of no service is higher than the cost of unsupervised running. A storage water cylinder with a working mechanical thermostat is the usual example. It is also the example that needs the most care, because fail-on removes your ability to stop it remotely.

If you use fail-on, keep these rules:

  • Keep the mechanical thermostat. It is the primary control, not the software.
  • Keep independent thermal and overcurrent protection. Software is a convenience, not a safeguard.
  • Do not treat a device temperature reading as a load reading. It measures the device, not the water and not the element.
  • Do not use fail-on for a load that is unsafe to run unattended.

A contactor changes the picture. If the relay drives a contactor coil and the load sits on the contactor’s main contacts, the device no longer carries load current. Its power sensor then reports coil current, which is often one or two watts. Every power-based sensor and every calculation built on it becomes wrong, and a stuck thermostat produces no temperature signature at the device at all.

Test the handover before you trust it

Run all four steps. A pattern that works in three of them is not working.

Step Expected result
Write the relay from Home Assistant while it is not the owner Captured as a manual override and held.
Enable the window automation Device reports Home Assistant control, override clears, Home Assistant drives the relay.
Write the relay from Home Assistant while it is the owner No override is recorded and the device does not re-assert the relay.
Disable the window automation Device returns to the fallback policy within one heartbeat.

Step 3 is the one that proves the fix. Before the lease, that write was the failure. After the lease, the device stays out of the way.

Trade-offs

  • The lease adds one HTTP request per minute. On a local network this is negligible, but it is not zero.
  • Fail-on is not an energy-saving policy. It keeps the load available, not efficient.
  • The design needs a device clock. Without valid time, lease comparison is meaningless, so treat an unknown clock as no lease.
  • The device script is now safety-relevant code. Version it, back it up before you change it, and keep a tested path to roll back.
  • Ownership is visible only by reading device state. Expose the current state as an event or sensor so you can see why the relay did something, rather than guess.

Summary

  • One relay, one owner. Enforce it in the system, not in a document.
  • Home Assistant and a person both reach the device as WS_in. Separate them with an explicit lease, not with the transport.
  • Let the device decide ownership and keep thermal protection above every other state.
  • Fix the two window-automation restart bugs: add a condition to the start trigger, and give the stop action its own trigger.
  • Fail-on is a safety decision, not a reliability trick. Use it only where unsupervised running is acceptable, and keep independent protection.

The pattern is not specific to Shelly or to water heaters. Any device that runs its own logic and also accepts commands from a home automation system has the same ambiguity. A lease and a timeout resolve it in about thirty lines of device code.

Leave a Reply

Your email address will not be published. Required fields are marked *