<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Home Assistant &#8211; The Self-Host Journal</title>
	<atom:link href="https://selfhostjournal.eu/category/home-assistant/feed/" rel="self" type="application/rss+xml" />
	<link>https://selfhostjournal.eu</link>
	<description>Real setups, real fixes</description>
	<lastBuildDate>Fri, 23 Oct 2026 09:10:42 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://selfhostjournal.eu/wp-content/uploads/2026/10/site-icon-terminal-150x150.png</url>
	<title>Home Assistant &#8211; The Self-Host Journal</title>
	<link>https://selfhostjournal.eu</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Monitoring my Docker host with Home Assistant: containers, disks, HTTP checks and alerts</title>
		<link>https://selfhostjournal.eu/docker-host-monitoring-home-assistant/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate></pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=150</guid>

					<description><![CDATA[Home Assistant watches the Docker host like the rest of the house: Portainer and SSH integrations, a container-down alert, HTTP checks that expect the right answer, disk space, backups and a server dashboard.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> A container that quietly stops, a disk that fills up, a service that answers with the wrong page: on a home server you usually find out when someone in the family says &#8220;it doesn&#8217;t work&#8221;.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Home Assistant watches the Docker host the same way it watches the rest of the house: one entity per container, host metrics, HTTP checks from the outside, backup status, and a handful of automations that send a Telegram message when something is wrong.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Home Assistant 2026.10, the Portainer integration, my own SSH Linux Management add-on (MQTT), about 25 containers on one Docker host, Telegram and the Companion app for notifications</p>
</div>



<p class="wp-block-paragraph">Home Assistant already knows how to tell me when a window is open or a battery is low. So instead of running a separate monitoring stack for my server, I let Home Assistant watch that too. Everything ends up in the same place: one dashboard, one notification channel, and automations I already know how to write. This article shows the two integrations I use to get the Docker host into Home Assistant, the alerts built on top of them, and what I learned along the way.</p>



<h2 class="wp-block-heading">1. Two integrations, two jobs</h2>



<p class="wp-block-paragraph">Home Assistant runs in a VM, the containers on the host next to it. To see inside the host, I use two integrations, because each one is good at something different:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th></th><th>Portainer integration</th><th>SSH Linux Management (add-on)</th></tr></thead><tbody><tr><td>Connects via</td><td>Portainer&#8217;s API with an access token</td><td>One SSH connection to the host</td></tr><tr><td>Per container</td><td>Status, CPU, memory, restart count, out-of-memory, image updates, restart/pause/recreate buttons</td><td>Running on/off as a switch, restart button</td></tr><tr><td>How fast</td><td>Polled at a regular interval</td><td>Pushed from Docker&#8217;s event stream, instantly</td></tr><tr><td>Host</td><td>–</td><td>CPU, RAM, disk use per mount, libvirt VMs</td></tr><tr><td>Extra</td><td>–</td><td>Reads the backup status file of my <a href="/homelab-backup-snapshots-dumps-offline-copy/">backup setup</a></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">For the alerts below I use the Portainer status sensors for containers and the SSH add-on for everything about the host. If you only want one, start with Portainer: it&#8217;s a built-in integration and gives you the most per container.</p>



<h2 class="wp-block-heading">2. The Portainer integration</h2>



<p class="wp-block-paragraph">If Portainer already runs on your Docker host, this is the quickest start:</p>



<ol class="wp-block-list">
<li>In Portainer, open your user profile → <strong>Access tokens</strong> → <strong>Add access token</strong>, and copy the token.</li>



<li>In Home Assistant: <strong>Settings → Devices &amp; services → Add integration → Portainer</strong>, enter Portainer&#8217;s URL and the token.</li>



<li>Home Assistant creates one device per container, with entities like <code>binary_sensor.&lt;container&gt;_status</code>, <code>sensor.&lt;container&gt;_memory_usage</code>, <code>sensor.&lt;container&gt;_restart_count</code> and <code>update.&lt;container&gt;_image_update_available</code>.</li>
</ol>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>The token can do what its user can do:</strong> with an admin&#8217;s token, Home Assistant can stop and recreate every container. Create a separate Portainer user for Home Assistant if your edition supports roles, and treat the token like a password: it lives in Home Assistant&#8217;s config, so your Home Assistant backups contain it too.</p>
</div>



<p class="wp-block-paragraph">One thing Portainer doesn&#8217;t do: when you delete a container, its device stays in Home Assistant as &#8220;unavailable&#8221;. Remove it by hand (<strong>Settings → Devices</strong>, the device, <strong>Delete</strong>), or your dashboards and automations slowly fill with dead entities.</p>



<h2 class="wp-block-heading">3. The SSH add-on: host metrics without root</h2>



<p class="wp-block-paragraph">For the host itself (disk, CPU, RAM, VMs) I wrote a small Home Assistant add-on, <a href="https://github.com/apps-linux/ha-ssh-mange-secure" target="_blank" rel="noopener">SSH Linux Management</a>. It opens one outbound SSH connection to the host and publishes what it finds through MQTT discovery, so the entities appear in Home Assistant without any YAML:</p>



<ul class="wp-block-list">
<li><strong>Docker:</strong> the remote Docker socket is forwarded through the SSH connection. No Docker TCP port is opened, and container changes arrive instantly through Docker&#8217;s event stream.</li>



<li><strong>Host metrics:</strong> CPU, memory and disk use per mount point (I watch <code>/</code> and the Nextcloud data disk).</li>



<li><strong>VMs:</strong> libvirt VMs as switches with a reboot button, through <code>virsh</code> over the same connection.</li>



<li><strong>The SSH account is not root:</strong> it&#8217;s a normal user in the <code>docker</code> (and <code>libvirt</code>) group, and the host key is pinned on the first connection.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Be honest about the <code>docker</code> group:</strong> anyone who can talk to the Docker socket can start a container that mounts the host&#8217;s root filesystem. Membership in that group is effectively root on that machine. &#8220;Not root&#8221; here means: no root password in Home Assistant, one dedicated key you can revoke, and nothing else that account can log in to. If you want real limits, put a Docker socket proxy in front that only allows the API calls you need.</p>
</div>



<p class="wp-block-paragraph">A lesson from running it: the first versions named each Docker entity after the container&#8217;s ID. Every <code>docker compose up</code> after an update creates a new container with a new ID, so Home Assistant got a new entity each time, and after a few weeks I had <code>switch.…_traefik</code>, <code>…_traefik_2</code> up to <code>…_traefik_5</code>. Version 0.11.0 keys the entities on the container <em>name</em> and removes discovery entries for containers that are gone. If you write your own MQTT discovery: choose the <code>unique_id</code> from something that survives a recreate.</p>



<h2 class="wp-block-heading">4. Alert: a container stopped</h2>



<p class="wp-block-paragraph">The most important automation is also the simplest: if any container&#8217;s status sensor is off for two minutes, send a message that names it. The two minutes keep normal restarts (updates, a recreate) from sending false alarms:</p>



<pre class="wp-block-code"><code>alias: System - Docker - Container Down Alert
description: A watched container is not running for 2 minutes → Telegram + push.
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id:
      - binary_sensor.traefik_status
      - binary_sensor.crowdsec_status
      - binary_sensor.authentik_server_status
      - binary_sensor.nextcloud_nextcloud_1_status
      - binary_sensor.vaultwarden_status
      - binary_sensor.zigbee2mqtt_status
      # … one line per container you care about
    to: "off"
    for:
      minutes: 2
actions:
  - action: telegram_bot.send_message
    data:
      title: "🔴 Docker container down"
      message: "{{ trigger.to_state.attributes.friendly_name }} has not been running for 2 minutes."
  - action: notify.mobile_app_your_phone
    data:
      title: "🔴 Docker container down"
      message: "{{ trigger.to_state.attributes.friendly_name }} has not been running for 2 minutes."</code></pre>



<ul class="wp-block-list">
<li><strong><code>mode: parallel</code>:</strong> if Docker itself restarts, several containers stop at once. Each one should get its own message, not just the first.</li>



<li><strong><code>to: "off"</code>, not &#8220;not on&#8221;:</strong> a sensor that turns <code>unavailable</code> (Portainer unreachable, container deleted) doesn&#8217;t fire. That&#8217;s on purpose: it prevents a flood of alerts when Portainer restarts, but it also means a deleted container is silently dropped from monitoring.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> the list of sensors is the weak spot. When you add, rename or remove a container, the list has to change with it, or the new container is never watched and the old one stays as a dead entry. I review it together with the backup job of every new container. A label on the status sensors and a label-based trigger would remove that step, at the cost of setting the label on each new sensor.</p>
</div>



<h2 class="wp-block-heading">5. Running isn&#8217;t the same as working: HTTP checks</h2>



<p class="wp-block-paragraph">A container can be &#8220;running&#8221; while the service inside answers with an error page, or while the reverse proxy in front of it sends traffic nowhere. So every ten minutes Home Assistant also calls the public addresses, the way a visitor would, and checks the HTTP status code. The calls are <code>shell_command</code> entries in <code>configuration.yaml</code>:</p>



<pre class="wp-block-code"><code>shell_command:
  curl_check_nextcloud: "curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://cloud.example.com/status.php"
  curl_check_traefik: "curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://traefik.example.com/dashboard/"</code></pre>



<p class="wp-block-paragraph">The automation runs each command, reads the status code from the response, and compares it with what that service should answer:</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: time_pattern
    minutes: "/10"
actions:
  - action: shell_command.curl_check_traefik
    response_variable: traefik
  - if:
      - condition: template
        value_template: "{{ traefik.stdout not in ['302', '401'] }}"
    then:
      - action: telegram_bot.send_message
        data:
          title: "🔴 Traefik dashboard check failed"
          message: "Expected a login (302/401), got {{ traefik.stdout }}."</code></pre>



<ul class="wp-block-list">
<li><strong>Check for the <em>right</em> answer, not just any answer.</strong> My Traefik dashboard and wiki sit behind single sign-on, so they must answer with a redirect to the login (302) or 401. A <code>200</code> would mean they&#8217;re suddenly open to everyone. The check catches a broken login, not just a broken service.</li>



<li><strong>Plan for maintenance.</strong> My nightly Nextcloud backup puts it into maintenance mode, which answers with 503. Between 02:55 and 04:30 the automation ignores exactly that code for Nextcloud, and only that one.</li>
</ul>



<h2 class="wp-block-heading">6. Disk space and the host</h2>



<p class="wp-block-paragraph">A full disk is the classic reason for a whole server to fail at once: databases stop writing, containers crash, logs can&#8217;t be written to explain why. The SSH add-on&#8217;s disk sensors make this a two-line trigger:</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: numeric_state
    entity_id:
      - sensor.ssh_host_myserver_host_disk_use
      - sensor.ssh_host_myserver_host_disk_use_data
    above: 85
actions:
  - action: telegram_bot.send_message
    data:
      message: "⚠️ {{ trigger.to_state.attributes.friendly_name }} is at {{ trigger.to_state.state }} % used."</code></pre>



<p class="wp-block-paragraph">85 % gives me days, not minutes, to react. The same sensors go on the dashboard as gauges, next to CPU and memory.</p>



<h2 class="wp-block-heading">7. Backups and updates</h2>



<ul class="wp-block-list">
<li><strong>Backups:</strong> a script on the host writes the state of every backup job into a JSON file every five minutes. The SSH add-on reads that file and creates a sensor per job plus one &#8220;backup problem&#8221; binary sensor. If that is on for ten minutes (a job failed, a snapshot is older than 26 hours, the backup disk isn&#8217;t mounted), I get a message with the reason. The details are in my <a href="/homelab-backup-snapshots-dumps-offline-copy/">backup article</a>.</li>



<li><strong>Updates:</strong> an automation runs my Docker update script over SSH three times a week at 04:00 (<code>docker compose pull</code> and <code>up -d</code> per stack). The container alert above is the safety net: if an update breaks a container, I know before breakfast. For the reverse proxy, a pinned major version keeps that risk small (see my <a href="/traefik-reverse-proxy-docker/">Traefik article</a>).</li>



<li><strong>Version changes:</strong> for Zigbee2MQTT I also store the last known version in an <code>input_text</code> helper and send a message when it changes, with a reminder to read the release notes. I added it after an update changed how some devices behaved and I only noticed days later.</li>
</ul>



<h2 class="wp-block-heading">8. The dashboard</h2>



<p class="wp-block-paragraph">A &#8220;Server&#8221; view on my main dashboard shows the host gauges (CPU, RAM, both disks), the backup status, and one tile per container. Each tile shows whether the container runs, and tapping its icon restarts it:</p>



<pre class="wp-block-code"><code>type: tile
entity: switch.ssh_host_myserver_docker_vaultwarden
name: Vaultwarden
icon: mdi:restart
icon_tap_action:
  action: perform-action
  perform_action: button.press
  target:
    entity_id: button.ssh_host_myserver_docker_vaultwarden_restart</code></pre>



<p class="wp-block-paragraph">It&#8217;s not meant for daily use. It&#8217;s the page I open when a message arrives, to see at a glance whether one container is down or the whole host has a problem, and to restart the obvious candidate from my phone.</p>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>One place for alerts.</strong> I read Telegram anyway. A separate monitoring system with its own app would be one more thing I ignore.</li>



<li><strong>Check from the outside, too.</strong> &#8220;Container running&#8221; and &#8220;service works for a visitor&#8221; are two different questions. A container can run perfectly while its login is broken or the proxy points elsewhere.</li>



<li><strong>Check the expected answer.</strong> A login page that suddenly answers 200 is a security problem, not a success.</li>



<li><strong>Give every alert a short delay.</strong> Two minutes for containers, ten for backups: real problems stay, restarts don&#8217;t wake you up.</li>



<li><strong>Clean up after removing a container.</strong> Delete its device, take it out of the alert list and the dashboard. Dead entities make a dashboard you stop trusting.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Heating automations in Home Assistant: per-room logic, TRV calibration and the issues we hit</title>
		<link>https://selfhostjournal.eu/home-assistant-heating-trv-calibration/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 19:18:57 +0000</pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=91</guid>

					<description><![CDATA[Twelve SONOFF TRVZB valves in nine rooms: Toggle, Target Sync and Calibration per room, a door and window pause, safety nets, and the problems that taught us each of them.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Radiator thermostats measure the temperature right next to the hot radiator, a shared blueprint stopped reacting, and an update silently broke the heating.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Three small automations per room (Toggle, Target Sync, Calibration), a real room sensor fed into every valve, and a few safety nets that tell me when something drifts.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Home Assistant, Zigbee2MQTT 2.14, 12 SONOFF TRVZB radiator valves, Zigbee temperature and motion sensors</p>
</div>



<p class="wp-block-paragraph">Our house has nine heated rooms with twelve smart radiator valves. The goal was simple: warm when someone is in the room, a few degrees less when not, and no heating with the window open. Getting there took a few rebuilds. This is the setup that works now, and the problems we hit on the way.</p>



<h2 class="wp-block-heading">The building blocks per room</h2>



<p class="wp-block-paragraph">Every room uses the same set of helpers. That makes the automations almost identical, and a new room is mostly copy and rename.</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Helper</th><th>Purpose</th></tr></thead><tbody><tr><td><code>input_boolean.heating_office_on</code></td><td>Master switch for the room (dashboard, Telegram, &#8220;all on/off&#8221; scripts)</td></tr><tr><td><code>input_number.temperature_comfort_office</code></td><td>Comfort temperature, for example 21 °C</td></tr><tr><td><code>input_number.temperature_eco_office</code></td><td>ECO temperature, for example 19 °C</td></tr><tr><td><code>schedule.schedule_heating_office</code></td><td>When the room should be heated at all</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">On top of that, each room has three automations:</p>



<ul class="wp-block-list">
<li><strong>Toggle</strong> follows the master switch and turns the valve and the Target Sync on or off.</li>



<li><strong>Target Sync</strong> decides between Comfort and ECO and writes the target to the valve.</li>



<li><strong>Calibration</strong> feeds the real room temperature into the valve.</li>
</ul>



<h2 class="wp-block-heading">Why I dropped the blueprint</h2>



<p class="wp-block-paragraph">At first all rooms ran on one shared heating blueprint. It was convenient until it wasn&#8217;t: at the end of August it simply stopped reacting to some inputs, because an internal guard condition failed. One bug in a shared blueprint means every room is affected at once, and the logic is hard to debug from the outside.</p>



<p class="wp-block-paragraph">I replaced it with three plain automations per room. That is more automations (33 for the whole house), but each one is short, does one thing, and shows up clearly in the trace when something goes wrong.</p>



<h2 class="wp-block-heading">1. Toggle</h2>



<p class="wp-block-paragraph">When the master switch goes on, the valve is set to <code>heat</code>, the Target Sync is enabled and run once, so the right temperature is applied immediately. When it goes off, the Target Sync is disabled first, then the valve is switched off.</p>



<pre class="wp-block-code"><code>alias: Office - Heating - Toggle
triggers:
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "on"
    id: turned_on
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "off"
    id: turned_off
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: turned_on
        sequence:
          - action: climate.set_hvac_mode
            target:
              entity_id: climate.office_heating_valve
            data:
              hvac_mode: heat
          - action: automation.turn_on
            target:
              entity_id: automation.office_heating_target_sync
          - action: automation.trigger
            target:
              entity_id: automation.office_heating_target_sync
      - conditions:
          - condition: trigger
            id: turned_off
        sequence:
          - action: automation.turn_off
            target:
              entity_id: automation.office_heating_target_sync
          - action: climate.set_hvac_mode
            target:
              entity_id: climate.office_heating_valve
            data:
              hvac_mode: "off"
mode: single</code></pre>



<h2 class="wp-block-heading">2. Target Sync</h2>



<p class="wp-block-paragraph">The Target Sync is where the rooms differ. In the office it follows a motion sensor: Comfort after two minutes of motion, ECO after ten minutes without. The delays stop the heating from jumping up and down when someone just walks through.</p>



<pre class="wp-block-code"><code>alias: Office - Heating - Target Sync
triggers:
  - trigger: state
    entity_id: binary_sensor.office_motion
    to: "on"
    for: { minutes: 2 }
  - trigger: state
    entity_id: binary_sensor.office_motion
    to: "off"
    for: { minutes: 10 }
  - trigger: state
    entity_id:
      - input_number.temperature_comfort_office
      - input_number.temperature_eco_office
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "on"
conditions:
  - condition: state
    entity_id: input_boolean.heating_office_on
    state: "on"
actions:
  - if:
      - condition: state
        entity_id: binary_sensor.office_motion
        state: "on"
    then:
      - action: climate.set_temperature
        target:
          entity_id: climate.office_heating_valve
        data:
          temperature: "{{ states('input_number.temperature_comfort_office') | float }}"
    else:
      - action: climate.set_temperature
        target:
          entity_id: climate.office_heating_valve
        data:
          temperature: "{{ states('input_number.temperature_eco_office') | float }}"
mode: restart</code></pre>



<p class="wp-block-paragraph">Other rooms use other signals with the same pattern:</p>



<ul class="wp-block-list">
<li><strong>Schedule only</strong> (kitchen, bedroom, bathroom): Comfort inside the schedule, ECO outside it.</li>



<li><strong>Schedule and presence</strong> (rooms that belong to one person): Comfort only if the schedule is on <em>and</em> that person is at home.</li>



<li><strong>A device as presence</strong>: in the gaming room the AV receiver is the best sign that someone is there. Receiver on means Comfort, receiver off means a lower setback.</li>
</ul>



<h2 class="wp-block-heading">3. Calibration: the part that matters most</h2>



<p class="wp-block-paragraph">A radiator thermostat measures the temperature where it sits: right next to the hot radiator. So it thinks the room is warm long before it is, and closes too early.</p>



<p class="wp-block-paragraph">The SONOFF TRVZB can use an external temperature instead. In Zigbee2MQTT it has two relevant entities:</p>



<ul class="wp-block-list">
<li>a <strong>select</strong> &#8220;Temperature sensor&#8221;: <code>local_temperature</code> (its own sensor) or <code>remote_temperature</code> (an external value)</li>



<li>a <strong>number</strong> &#8220;External temperature input&#8221; that receives the external value</li>
</ul>



<p class="wp-block-paragraph">The Calibration automation copies a real room sensor into that input whenever the reading has been stable for two minutes. It also checks the select, because after a reconnect or a battery change the valve sometimes falls back to its own sensor.</p>



<pre class="wp-block-code"><code>alias: Office - Climate - Calibration
triggers:
  - trigger: state
    entity_id: sensor.office_temperature
    for: { minutes: 2 }
  - trigger: state
    entity_id: select.office_heating_valve_temperature_sensor_select
    not_to:
      - remote_temperature
      - remote_source_offline
      - unavailable
      - unknown
conditions:
  - condition: numeric_state
    entity_id: sensor.office_temperature
    above: 0
actions:
  # switch the valve back to the external sensor if it reverted
  - if:
      - condition: not
        conditions:
          - condition: state
            entity_id: select.office_heating_valve_temperature_sensor_select
            state:
              - remote_temperature
              - remote_source_offline
    then:
      - action: select.select_option
        target:
          entity_id: select.office_heating_valve_temperature_sensor_select
        data:
          option: remote_temperature
        continue_on_error: true
  # feed the real room temperature into the valve
  - action: number.set_value
    target:
      entity_id: number.office_heating_valve_external_temperature_input
    data:
      value: "{{ states('sensor.office_temperature') | float }}"
mode: single</code></pre>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> leave <code>remote_source_offline</code> alone. It is the valve&#8217;s own fallback when the external value goes stale. If you force it back to <code>remote_temperature</code>, the valve heats with an old value.</p>
</div>



<p class="wp-block-paragraph">The condition <code>above: 0</code> keeps a sensor that reports 0 or nothing from cooling the house down. With the calibration in place, the room finally reaches the temperature it shows on the dashboard.</p>



<h2 class="wp-block-heading">4. No heating with the door open</h2>



<p class="wp-block-paragraph">The living room has a terrace door and two windows, and the heating kept running when they were open. The pause automation switches the three valves off after a door or window has been open for one minute, and switches them back on when <em>all</em> of them are closed again.</p>



<p class="wp-block-paragraph">The important detail: it does <strong>not</strong> touch the master switch. Instead it sets its own flag (<code>input_boolean.heating_living_room_paused</code>). Toggle and Target Sync leave the valves alone while the flag is on. Because the flag is a helper and not a wait in the automation, it also survives a Home Assistant restart. A start trigger restores the heating if everything was closed while Home Assistant was down.</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: state
    entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
    to: "on"
    for: { minutes: 1 }
    id: opened
  - trigger: state
    entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
    from: "on"
    to: "off"
    id: closed
  - trigger: homeassistant
    event: start
    id: ha_start
actions:
  - choose:
      - conditions:   # opened while heating is on -> pause
          - condition: trigger
            id: opened
          - condition: state
            entity_id: input_boolean.heating_living_room_on
            state: "on"
        sequence:
          - action: input_boolean.turn_on
            target: { entity_id: input_boolean.heating_living_room_paused }
          - action: climate.set_hvac_mode
            target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
            data: { hvac_mode: "off" }
      - conditions:   # all closed and paused by us -> resume
          - condition: trigger
            id: [closed, ha_start]
          - condition: state
            entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
            state: "off"
          - condition: state
            entity_id: input_boolean.heating_living_room_paused
            state: "on"
        sequence:
          - action: input_boolean.turn_off
            target: { entity_id: input_boolean.heating_living_room_paused }
          - if:
              - condition: state
                entity_id: input_boolean.heating_living_room_on
                state: "on"
            then:
              - action: climate.set_hvac_mode
                target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
                data: { hvac_mode: heat }
              - action: automation.trigger
                target: { entity_id: automation.living_room_heating_target_sync }
mode: queued</code></pre>



<h2 class="wp-block-heading">5. Safety nets</h2>



<p class="wp-block-paragraph">Automations fail quietly. These three make sure I hear about it:</p>



<ul class="wp-block-list">
<li><strong>Child lock enforcement:</strong> after a battery change or a re-pair, a valve&#8217;s child lock can be off, and then anyone can turn the knob. If any of the twelve locks is off for 10 seconds, or Home Assistant starts, all locks are switched on again.</li>



<li><strong>Target mismatch alert:</strong> 10 minutes after a valve&#8217;s target or a Comfort/ECO value changes, a template checks every valve. If a target matches neither its room&#8217;s ECO nor its Comfort value (tolerance 0.25 °C), I get a Telegram message. Rooms with heating off, valves that are off and the paused living room are skipped. This catches a Target Sync that didn&#8217;t reach the valve.</li>



<li><strong>Zigbee2MQTT version alert:</strong> a message whenever Zigbee2MQTT runs a new version. The reason is the next section.</li>
</ul>



<p class="wp-block-paragraph">The core of the mismatch check, simplified to one valve:</p>



<pre class="wp-block-code"><code>{% set target  = state_attr('climate.office_heating_valve', 'temperature') | float %}
{% set eco     = states('input_number.temperature_eco_office') | float %}
{% set comfort = states('input_number.temperature_comfort_office') | float %}
{{ (target - eco) | abs &gt; 0.25 and (target - comfort) | abs &gt; 0.25 }}</code></pre>



<h2 class="wp-block-heading">The issues we had</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Problem</th><th>Cause</th><th>Fix</th></tr></thead><tbody><tr><td>Rooms didn&#8217;t react to schedule or motion changes</td><td>The shared heating blueprint&#8217;s internal guard condition failed</td><td>Three own automations per room instead of the blueprint</td></tr><tr><td>Rooms stayed colder than the target</td><td>The valve measured next to the radiator</td><td>Calibration with a real room sensor</td></tr><tr><td>Valve went back to its own sensor</td><td>Reconnect or battery change resets the select</td><td>Calibration also triggers on the select and switches it back</td></tr><tr><td>Heating ran with the terrace door open</td><td>No pause logic</td><td>Door and window pause with its own flag</td></tr><tr><td>Calibration silently stopped working</td><td>An automatic update from Zigbee2MQTT 2.14.1 to 2.14.2 renamed the TRVZB sensor options</td><td>Automations switched to the new option names, plus a version change alert</td></tr><tr><td>Valves could be changed by hand</td><td>Child lock off after battery change or re-pair</td><td>Child lock enforcement</td></tr><tr><td>A valve sat at a wrong target unnoticed</td><td>A Target Sync that didn&#8217;t reach the valve</td><td>Target mismatch alert</td></tr></tbody></table></figure>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Watch out:</strong> if Zigbee2MQTT runs on the <code>latest</code> image and updates itself, a converter change can rename entities or options overnight. Nothing errors, the heating just quietly stops doing what you think it does. Either pin the version and update on purpose, or at least get notified when the version changes.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>Small, boring automations per room beat one clever shared blueprint. They are easier to read, test and fix.</li>



<li>A radiator thermostat without an external sensor heats to the wrong temperature. Feeding it a real room sensor is worth the extra automation.</li>



<li>Use a flag helper instead of a wait for anything that must survive a restart.</li>



<li>Every automation that can fail silently needs an alert that checks the result, not just the trigger.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Zigbee2MQTT in Docker: my configuration, and why sensors keep going offline</title>
		<link>https://selfhostjournal.eu/zigbee2mqtt-docker-offline-sensors/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 16:41:48 +0000</pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=37</guid>

					<description><![CDATA[A working Docker Compose and configuration.yaml for Zigbee2MQTT, and why battery sensors come back online as soon as you open the door.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Run Zigbee2MQTT in Docker for about 80 devices, and battery sensors that keep showing as offline.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> A small Docker Compose file, a clean <code>configuration.yaml</code>, and understanding why &#8220;offline&#8221; often doesn&#8217;t mean &#8220;gone&#8221;.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Zigbee2MQTT 2.14, TI CC2652 USB coordinator (Z-Stack), Home Assistant, Mosquitto</p>
</div>



<p class="wp-block-paragraph">My Zigbee network has grown to around 80 devices: radiator thermostats, temperature and motion sensors, door contacts, plugs and a lot of smart bulbs. Zigbee2MQTT connects all of them to Home Assistant through MQTT. Here is the setup I use, and the one problem that kept bugging me.</p>



<h2 class="wp-block-heading">1. Docker Compose</h2>



<p class="wp-block-paragraph">Zigbee2MQTT runs as a single container. The USB stick is passed in by its <code>by-id</code> path, so it still works when USB devices are enumerated in a different order after a reboot.</p>



<pre class="wp-block-code"><code>services:
  zigbee2mqtt:
    container_name: zigbee2mqtt
    image: ghcr.io/koenkk/zigbee2mqtt:latest
    restart: unless-stopped
    environment:
      TZ: Europe/Berlin
      ZIGBEE2MQTT_CONFIG_FRONTEND_ENABLED: "true"
      ZIGBEE2MQTT_CONFIG_FRONTEND_PORT: "8080"
      # the frontend password lives in .env, not in this file
      ZIGBEE2MQTT_CONFIG_FRONTEND_AUTH_TOKEN: ${Z2M_AUTH_TOKEN:?set Z2M_AUTH_TOKEN in .env}
    volumes:
      - ./:/app/data
      - /run/udev:/run/udev:ro
    devices:
      # find yours with: ls -l /dev/serial/by-id/
      - /dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0:/dev/ttyUSB0
    ports:
      # frontend only on the host itself
      - "127.0.0.1:8080:8080"</code></pre>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> don&#8217;t publish the frontend port to your whole network. Anyone who reaches it can pair or remove devices. I bind it to <code>127.0.0.1</code> and put it behind a reverse proxy with single sign-on.</p>
</div>



<h2 class="wp-block-heading">2. configuration.yaml</h2>



<p class="wp-block-paragraph">This is my configuration with all secrets removed. On a new installation, <code>GENERATE</code> lets Zigbee2MQTT create a random network key and PAN IDs on the first start. Never copy these values from someone else&#8217;s example.</p>



<pre class="wp-block-code"><code>version: 5
mqtt:
  base_topic: zigbee2mqtt
  server: mqtt://mqtt-broker:1883
  user: zigbee2mqtt
  password: '!secret mqtt_password'
serial:
  port: /dev/ttyUSB0
  adapter: zstack
  baudrate: 115200
  rtscts: false
advanced:
  channel: 15
  network_key: GENERATE
  pan_id: GENERATE
  ext_pan_id: GENERATE
  transmit_power: 20
  adapter_delay: 100
  last_seen: ISO_8601
  log_level: error
availability:
  enabled: true
homeassistant:
  enabled: true
frontend:
  enabled: true
  port: 8080</code></pre>



<p class="wp-block-paragraph">A few notes on the settings:</p>



<ul class="wp-block-list">
<li><code>channel</code>: pick it once and don&#8217;t change it later. Changing the channel means re-pairing many devices. Zigbee channels 15, 20 and 25 overlap least with the common Wi-Fi channels 1, 6 and 11.</li>



<li><code>last_seen: ISO_8601</code>: adds a timestamp to every device. You need this to understand the offline problem below.</li>



<li><code>availability</code>: marks devices as online or offline in Home Assistant. Useful, but it causes most of the confusion described next.</li>



<li><code>password: '!secret mqtt_password'</code> reads the value from a separate <code>secret.yaml</code>, so the configuration can be shared or backed up without the password.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Watch out:</strong> back up the whole data folder, especially <code>configuration.yaml</code>, <code>database.db</code> and <code>coordinator_backup.json</code>. Without the network key and the coordinator backup, a new stick means re-pairing every device.</p>
</div>



<h2 class="wp-block-heading">3. Sensors that keep leaving the network</h2>



<p class="wp-block-paragraph">This is the part that kept bugging me. Every few days some battery sensors, mostly door and window contacts, show up as offline in Zigbee2MQTT and Home Assistant. So far the fix has always been the same: <strong>open and close the door once</strong>, and the sensor is back online immediately. No re-pairing, no battery change.</p>



<p class="wp-block-paragraph">That a simple open and close brings them back is actually a good clue. There are two likely explanations, and they can both happen in the same network.</p>



<h3 class="wp-block-heading">Reason 1: the sensor never left, it was just quiet</h3>



<p class="wp-block-paragraph">Battery devices sleep most of the time and only send something when their state changes or at a long check-in interval. Zigbee2MQTT cannot ask them &#8220;are you still there?&#8221;. With availability turned on, it simply waits: if a battery device has sent nothing for <strong>25 hours</strong> (the default), it is marked offline. A door that nobody opened for a day looks exactly like a dead sensor. Opening it sends a message, and the sensor is &#8220;back&#8221;.</p>



<p class="wp-block-paragraph">You can recognise this case by the <em>last seen</em> time: if the sensor went offline almost exactly 25 hours after it was last seen, it is the timeout and not a real problem. The fix is a longer timeout for these devices:</p>



<pre class="wp-block-code"><code>availability:
  enabled: true
  active:
    timeout: 10       # minutes, mains-powered devices (they can be pinged)
  passive:
    timeout: 1500     # minutes, battery devices (default = 25 hours)

# or only for single devices, in the devices section:
devices:
  '0x00158d0001234567':
    friendly_name: Front door
    availability:
      timeout: 4320   # 3 days</code></pre>



<h3 class="wp-block-heading">Reason 2: the sensor lost its parent router</h3>



<p class="wp-block-paragraph">Battery devices don&#8217;t talk to the coordinator directly. They attach to a nearby <em>router</em>, which is any mains-powered device such as a plug or a smart bulb. If that router disappears, the sensor is orphaned. It only notices when it tries to send something, and then looks for a new parent. That is why opening the door &#8220;repairs&#8221; it: the open event forces the sensor to rejoin.</p>



<p class="wp-block-paragraph">Typical reasons a router disappears or drops its children:</p>



<ul class="wp-block-list">
<li><strong>Smart bulbs switched off at the wall.</strong> Every bulb is a router. Switch it off and every sensor attached to it is orphaned. This is the most common cause.</li>



<li><strong>Weak routers.</strong> Many cheap bulbs and plugs can only keep a few children, or drop them after a firmware glitch.</li>



<li><strong>Aqara/Xiaomi sensors.</strong> They don&#8217;t follow the Zigbee standard completely and often only stay connected to certain routers. With other brands as parents they drop off regularly.</li>
</ul>



<h3 class="wp-block-heading">What I&#8217;m doing about it</h3>



<ol class="wp-block-list">
<li>Check the <em>last seen</em> time of each sensor that goes offline, to separate the timeout from real drop-outs.</li>



<li>Raise the availability timeout for door and window contacts. They don&#8217;t need to report every day.</li>



<li>Keep bulbs that act as routers powered all the time and switch them through Zigbee instead of the wall switch.</li>



<li>Add a dedicated, always-on router (a USB router stick or a good smart plug) near the sensors that drop most often.</li>



<li>Look at the network map in the Zigbee2MQTT frontend to see which router each problem sensor hangs on.</li>
</ol>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> don&#8217;t re-pair a sensor just because it shows as offline. Trigger it first (open the door, press its button, breathe on a temperature sensor). If it comes back, it was never really gone.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>&#8220;Offline&#8221; in Zigbee2MQTT often just means &#8220;hasn&#8217;t said anything for 25 hours&#8221;.</li>



<li>The routers decide how stable the network is, not the coordinator. Bulbs on wall switches are bad routers.</li>



<li>Back up the network key and coordinator backup. They are the difference between swapping a stick in minutes and re-pairing 80 devices.</li>
</ul>



<p class="has-text-color has-small-font-size wp-block-paragraph" style="color:var(--wp--preset--color--accent-4)">I&#8217;ll update this article once I know which of the two reasons applies to my sensors.</p>

]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
