Host controls and sensors
The example config below showcases a Debian system which runs an ESPHome binary to control a couple of tasks and monitor some states using
host platform. Exposes buttons in Home Assistant to reboot or shut down, switch to turn off and on the screen and sensors to monitor
CPU temperatures, load average, free memory, disk space, number of available update packages.
It’s recommended to set the system up with a static IP address, because ESPHome acts as server and Home Assistant connects to it as client. Since there’s no MDNS advertisment published by the host, you need to add it manually to Home Assistant by the IP address.
The shell commands are executed with the same privileges as the ESPHome binary. It’s out of scope of this document to show how to provision a Linux system in order to properly operate complying to this requirement. You need to set it up so the binary runs automatically after boot.
WARNING
This function provides full, unsandboxed access to the host operating system. Commands execute with the same privileges as the ESPHome process - if running as root, commands have root access. There is no input validation, command filtering, or security sandboxing. Only use this on systems you fully control and trust, and never expose the API to untrusted networks. Malicious or accidental misuse could result in data loss, system compromise, or other serious consequences. You should NOT run any commands using this facility that contain data accepted from any outside input (webserver, HA text > sensors etc.) unless that data has been well sanitised, since this risks a command injection attack.
Basic setup
Section titled “Basic setup”This is all you need to set up a basic ESPHome binary. Do not set a manual MAC address, it’s going to use the MAC of the host it’s running on.
host:
api: encryption: key: !secret encryption_key reboot_timeout: 0s
logger: level: DEBUGDisabling reboot_timeout is recommended because from ESPHome perspective this just means quitting the executable and not rebooting the whole
system. On a final deployment, setting level: INFO would suffice to reduce output chatter.
Read some data at start
Section titled “Read some data at start”Using name_add_mac_suffix will append the last 3 bytes of the mac address of the device to the name, this will allow using the same binary
on multiple machines just by copying it over.
esphome: name: host-kiosk-pc name_add_mac_suffix: true on_boot: - lambda: |- auto result = esphome::host::execute_host_command("ip -o -4 addr show | awk '$2!=\"lo\"{print $4}' | cut -d/ -f1"); id(host_ip_address).publish_state(result.stdout_output); - lambda: |- auto result = esphome::host::execute_host_command("cat /sys/class/dmi/id/product_name"); id(host_model).publish_state(result.stdout_output); - lambda: |- auto result = esphome::host::execute_host_command("hostname"); id(host_name).publish_state(result.stdout_output); - lambda: |- auto result = esphome::host::execute_host_command("nproc"); id(host_nproc).publish_state(result.stdout_output);These lamdas run on_boot because their result doesn’t change anymore after the system boots up, there’s no need to run them more than once.
Text sensors
Section titled “Text sensors”The template sensors get updated from the lambdas specified elsewhere in the configuration, thus they are very simple:
text_sensor: - platform: template id: host_ip_address icon: mdi:ip-network name: "IP Addresses" - platform: template id: host_model icon: mdi:raspberry-pi name: "Model" - platform: template id: host_name icon: mdi:console-network name: "Hostname" - platform: template id: host_nproc icon: mdi:cpu-64-bit name: "Cores"Sensors
Section titled “Sensors”Sensors updating at runtime can have their polling command set in their own lambdas, which run in every update_interval:
sensor: - platform: template icon: mdi:radiator name: "CPU Temperature" state_class: measurement unit_of_measurement: "°C" accuracy_decimals: 0 lambda: |- auto result = esphome::host::execute_host_command("cat /sys/class/thermal/thermal_zone0/temp"); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value() / 1000; - platform: template name: "Load average" update_interval: 30s state_class: measurement accuracy_decimals: 2 icon: mdi:cpu-64-bit lambda: |- auto result = esphome::host::execute_host_command("awk '{print $1}' /proc/loadavg"); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value(); - platform: template name: "Free memory" state_class: measurement unit_of_measurement: "%" accuracy_decimals: 0 icon: mdi:memory lambda: |- esphome::host::ShellCommandOptions opts; opts.environment = { {"LC_NUMERIC", "C"}, }; auto result = esphome::host::execute_host_command("free | awk '/^Mem:/ {print ($7/$2)*100}'", opts); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value(); - platform: template name: "Free disk space on \u005C" update_interval: 5min state_class: measurement unit_of_measurement: "%" accuracy_decimals: 0 icon: mdi:chart-donut lambda: |- auto result = esphome::host::execute_host_command("df -P / | awk 'NR==2{u=substr($5,1,length($5)-1); print 100-u}'"); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value(); - platform: template name: "Package updates available" update_interval: 1days state_class: measurement accuracy_decimals: 0 icon: mdi:package-down lambda: |- auto result = esphome::host::execute_host_command("apt list --upgradable 2>/dev/null | tail -n +2 | wc -l"); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value(); - platform: uptime name: "Uptime" type: seconds - platform: template id: eno1_rx name: "NIC eno1 download" state_class: measurement device_class: data_rate accuracy_decimals: 0 icon: mdi:download-network-outline unit_of_measurement: b/s - platform: template id: eno1_tx name: "NIC eno1 upload" state_class: measurement device_class: data_rate accuracy_decimals: 0 icon: mdi:upload-network-outline unit_of_measurement: b/sNote the usage of LC_NUMERIC=C environment variable in the Free memory sensor. This is to ensure that the shell produces numeric output
with proper locales, here specifically we care about decimal separator to be a ., not , as it is in many other languages.
Controls
Section titled “Controls”The buttons run the commands directly:
button: - platform: template name: "Reboot" icon: mdi:restart on_press: - lambda: |- auto result = esphome::host::execute_host_command("/sbin/reboot"); - platform: template name: "Shutdown" icon: mdi:power-cycle disabled_by_default: true on_press: - lambda: |- auto result = esphome::host::execute_host_command("/sbin/shutdown -h now");
switch: - platform: template name: "Display" id: host_display_switch icon: mdi:monitor-shimmer optimistic: true restore_mode: DISABLED # or ALWAYS_ON turn_on_action: - lambda: |- esphome::host::ShellCommandOptions opts; opts.environment = { {"DISPLAY", ":0.0"}, }; auto result = esphome::host::execute_host_command("xset dpms force on", opts); turn_off_action: - lambda: |- esphome::host::ShellCommandOptions opts; opts.environment = { {"DISPLAY", ":0.0"}, }; auto result = esphome::host::execute_host_command("xset dpms force off", opts);
interval: - interval: 5s then: - switch.template.publish: id: host_display_switch state: !lambda |- esphome::host::ShellCommandOptions opts; opts.environment = { {"DISPLAY", ":0.0"}, }; auto result = esphome::host::execute_host_command("xset -q | awk '/Monitor is/ {print $NF; exit}'", opts); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_on_off(stdout_str.c_str(), "On", "Off"); if (parsed == esphome::PARSE_ON) { return true; } if (parsed == esphome::PARSE_OFF) { return false; } ESP_LOGW("host.shell", "Unable to parse monitor state from output: %s", stdout_str.c_str()); return {}; - interval: 5min then: - lambda: |- auto rx1_result = esphome::host::execute_host_command("cat /sys/class/net/eno1/statistics/rx_bytes"); auto rx1_str = rx1_result.stdout_output; rx1_str.erase(std::remove_if(rx1_str.begin(), rx1_str.end(), ::isspace), rx1_str.end()); id(eno1_rx1) = parse_number<uint64_t>(rx1_str).value_or(0); - lambda: |- auto tx1_result = esphome::host::execute_host_command("cat /sys/class/net/eno1/statistics/tx_bytes"); auto tx1_str = tx1_result.stdout_output; tx1_str.erase(std::remove_if(tx1_str.begin(), tx1_str.end(), ::isspace), tx1_str.end()); id(eno1_tx1) = parse_number<uint64_t>(tx1_str).value_or(0); - delay: 1s - lambda: |- auto rx2_result = esphome::host::execute_host_command("cat /sys/class/net/eno1/statistics/rx_bytes"); auto rx2_str = rx2_result.stdout_output; rx2_str.erase(std::remove_if(rx2_str.begin(), rx2_str.end(), ::isspace), rx2_str.end()); id(eno1_rx2) = parse_number<uint64_t>(rx2_str).value_or(0); - lambda: |- auto tx2_result = esphome::host::execute_host_command("cat /sys/class/net/eno1/statistics/tx_bytes"); auto tx2_str = tx2_result.stdout_output; tx2_str.erase(std::remove_if(tx2_str.begin(), tx2_str.end(), ::isspace), tx2_str.end()); id(eno1_tx2) = parse_number<uint64_t>(tx2_str).value_or(0); - lambda: |- id(eno1_rx).publish_state((float)(id(eno1_rx2) - id(eno1_rx1))); id(eno1_tx).publish_state((float)(id(eno1_tx2) - id(eno1_tx1)));
globals: - id: eno1_rx1 type: uint64_t - id: eno1_tx1 type: uint64_t - id: eno1_rx2 type: uint64_t - id: eno1_tx2 type: uint64_tThe template switch, in order to update its state has to rely on a less frequent timing than its built-in lambda which runs every loop cycle.
For this, we use the interval component to set a timing which doesn’t overload the system.
For network traffic measurement, every 5 minutes we look two times at the linux traffic statistic counters with one second delay, substract
them and publish them to the template sensors. We use globals to define variables available between the different lambdas.
Deploy as systemd unit
Section titled “Deploy as systemd unit”To deploy it as a systemd unit which ensures proper startup at boot, and restarts the binary in case of failure we can assume something like:
- User:
kiosk(with appropriate privileges to run the commands defined in the yaml) - Binary location:
/usr/local/bin/esphome_host - Preferences location:
/var/lib/esphome_host(in the yaml, setpreferences_path: /var/lib/esphome_host)
Place the binary and create directories
Section titled “Place the binary and create directories”Compile the binary using esphome run and watch the log for the message “Running program from path”. Usse that path in the following command:
# Recommended location for the binarysudo install -m 0755 <compilation_output_path> /usr/local/bin/esphome_host
# Create the dedicated working directory to save the preferences filesudo install -d -o kiosk -g kiosk -m 0750 /var/lib/esphome_hostCreate the systemd unit
Section titled “Create the systemd unit”sudo nano /etc/systemd/system/esphome_host.servicewith the following content:
[Unit]Description=ESPHome Host ServiceAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=kioskGroup=kioskWorkingDirectory=/var/lib/esphome_hostExecStart=/usr/local/bin/esphome_hostRestart=on-failureRestartSec=5s
[Install]WantedBy=default.targetEnable and start
Section titled “Enable and start”sudo systemctl daemon-reloadsudo systemctl enable --now esphome_host.servicesudo systemctl status esphome_host.serviceTo see the log, use:
journalctl -u esphome_host.serviceMonitor unit uptime
Section titled “Monitor unit uptime”Here’s a tip to monitor separately the uptime of the esphome_host executable:
sensor: - platform: template name: "Uptime app unit" state_class: total_increasing unit_of_measurement: s device_class: duration accuracy_decimals: 0 icon: mdi:timer-play-outline lambda: |- auto result = esphome::host::execute_host_command("UNIT=esphome_host.service; ae=$(systemctl show -p ActiveEnterTimestampMonotonic --value $UNIT); awk -v ae=${ae:-0} '{print (ae>0)?int($1-ae/1000000):0}' /proc/uptime"); auto stdout_str = result.stdout_output; stdout_str.erase(std::remove_if(stdout_str.begin(), stdout_str.end(), ::isspace), stdout_str.end()); auto parsed = parse_number<float>(stdout_str); if (!parsed.has_value()) { return NAN; } return parsed.value();