diff --git a/.templates/esphome/88-tty-iotstack-esphome.rules b/.templates/esphome/88-tty-iotstack-esphome.rules
new file mode 100644
index 00000000..2b8f30d3
--- /dev/null
+++ b/.templates/esphome/88-tty-iotstack-esphome.rules
@@ -0,0 +1,47 @@
+# Assumptions:
+#
+# 1. The ESPhome container is running with the container-name "esphome".
+#
+# 2. The service definition for the ESPhome container includes:
+#
+# device_cgroup_rules:
+# - 'c 188:* rw'
+#
+# This clause permits the container to access any device with a major
+# number 188, which captures most USB-to-serial adapters that are
+# found on ESP32 dev boards or equivalent off-board adapters such as
+# those made by Future Technology Devices International (FTDI) and
+# Silicon Laboratories Incorporated. The major number 188 also shows
+# up in the UDEV rules below.
+#
+# 3. The ESP device to be managed is mounted and/or unmounted WHILE the
+# container is running. In other words, all bets are off if the host
+# system reboots or the container starts while the USB device is
+# connected. You will likely need to unplug/replug the device to
+# get the container back in sync.
+#
+# The rules do NOT check if the container is running and do NOT check
+# for errors. All that will happen is errors in the system log.
+#
+# Removing ESPhome from your stack does NOT remove this rules file. It
+# does not matter whether you accomplish removal by editing your compose
+# file or via the IOTstack menu, this rule will be left in place and it
+# will generate an error every time it fires in response to insertion
+# or removal of a matching USB device.
+#
+# It is perfectly safe to remove this rules file yourself:
+#
+# sudo rm /etc/udev/rules.d/88-tty-iotstack-esphome.rules
+#
+# That's all you have to do. UDEV is dynamic and, despite what you read
+# on the web, does NOT have to be restarted or reloaded.
+
+# Upon insertion of a matching USB device, mount the same device inside the container
+ACTION=="add", \
+ SUBSYSTEM=="tty", ENV{MAJOR}=="188", \
+ RUN+="/usr/bin/docker exec esphome mknod %E{DEVNAME} c %M %m"
+
+# Upon removal of a matching USB device, remove the same device inside the container
+ACTION=="remove", \
+ SUBSYSTEM=="tty", ENV{MAJOR}=="188", \
+ RUN+="/usr/bin/docker exec esphome rm -f %E{DEVNAME}"
diff --git a/.templates/esphome/build.py b/.templates/esphome/build.py
new file mode 100755
index 00000000..641cd87b
--- /dev/null
+++ b/.templates/esphome/build.py
@@ -0,0 +1,175 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+
+issues = {} # Returned issues dict
+buildHooks = {} # Options, and others hooks
+haltOnErrors = True
+
+import os
+import sys
+
+global templatesDirectory
+global currentServiceName # Name of the current service
+global generateRandomString
+
+from deps.consts import templatesDirectory
+from deps.common_functions import generateRandomString
+
+
+# Main wrapper function. Required to make local vars work correctly
+def main():
+
+ global toRun # Switch for which function to run when executed
+ global buildHooks # Where to place the options menu result
+ global issues # Returned issues dict
+ global haltOnErrors # Turn on to allow erroring
+
+ # runtime vars
+ portConflicts = []
+
+ # This lets the menu know whether to put " >> Options " or not
+ # This function is REQUIRED.
+ def checkForOptionsHook():
+ try:
+ buildHooks["options"] = callable(runOptionsMenu)
+ except:
+ buildHooks["options"] = False
+ return buildHooks
+ return buildHooks
+
+ # This function is REQUIRED.
+ def checkForPreBuildHook():
+ try:
+ buildHooks["preBuildHook"] = callable(preBuild)
+ except:
+ buildHooks["preBuildHook"] = False
+ return buildHooks
+ return buildHooks
+
+ # This function is REQUIRED.
+ def checkForPostBuildHook():
+ try:
+ buildHooks["postBuildHook"] = callable(postBuild)
+ except:
+ buildHooks["postBuildHook"] = False
+ return buildHooks
+ return buildHooks
+
+ # This function is REQUIRED.
+ def checkForRunChecksHook():
+ try:
+ buildHooks["runChecksHook"] = callable(runChecks)
+ except:
+ buildHooks["runChecksHook"] = False
+ return buildHooks
+ return buildHooks
+
+ # This service will not check anything unless this is set
+ # This function is optional, and will run each time the menu is rendered
+ def runChecks():
+ checkForIssues()
+ return []
+
+ # This function is optional, and will run after the docker-compose.yml file is written to disk.
+ def postBuild():
+ return True
+
+ # This function is optional, and will run just before the build docker-compose.yml code.
+ def preBuild():
+ return True
+
+ # #####################################
+ # Supporting functions below
+ # #####################################
+
+ def doCustomSetup() :
+
+ import os
+ import re
+ import subprocess
+ from os.path import exists
+
+ def copyUdevRulesFile(templates,rules) :
+
+ # the expected location of the rules file in the template is the absolute path ...
+ SOURCE_PATH = templates + '/' + currentServiceName + '/' + rules
+
+ # the rules file should be installed at the following absolute path...
+ TARGET_PATH = '/etc/udev/rules.d' + '/' + rules
+
+ # does the target already exist?
+ if not exists(TARGET_PATH) :
+
+ # no! does the source path exist?
+ if exists(SOURCE_PATH) :
+
+ # yes! we should copy the source to the target
+ subprocess.call(['sudo', 'cp', SOURCE_PATH, TARGET_PATH])
+
+ # sudo cp sets root ownership but not necessarily correct mode
+ subprocess.call(['sudo', 'chmod', '644', TARGET_PATH])
+
+ def setEnvironment (path, key, value) :
+
+ # assume the variable should be written
+ shouldWrite = True
+
+ # does the target file already exist?
+ if exists(path) :
+
+ # yes! open the file so we can search it
+ env_file = open(path, 'r+')
+
+ # prepare to read by lines
+ env_data = env_file.readlines()
+
+ # we are searching for...
+ expression = '^' + key + '='
+
+ # search by line
+ for line in env_data:
+ if re.search(expression, line) :
+ shouldWrite = False
+ break
+
+ else :
+
+ # no! create the file
+ env_file = open(path, 'w')
+
+ # should the variable be written?
+ if shouldWrite :
+ print(key + '=' + value, file=env_file)
+
+ # done with the environment file
+ env_file.close()
+
+ copyUdevRulesFile(
+ os.path.realpath(templatesDirectory),
+ '88-tty-iotstack-' + currentServiceName + '.rules'
+ )
+
+ # the environment file is located at ...
+ DOT_ENV_PATH = os.path.realpath('.') + '/.env'
+
+ # check/set environment variables
+ setEnvironment(DOT_ENV_PATH,'ESPHOME_USERNAME',currentServiceName)
+ setEnvironment(DOT_ENV_PATH,'ESPHOME_PASSWORD',generateRandomString())
+
+
+ def checkForIssues():
+ doCustomSetup() # done here because is called least-frequently
+ return True
+
+ if haltOnErrors:
+ eval(toRun)()
+ else:
+ try:
+ eval(toRun)()
+ except:
+ pass
+
+if currentServiceName == 'esphome':
+ main()
+else:
+ print("Error. '{}' Tried to run 'plex' config".format(currentServiceName))
diff --git a/.templates/esphome/service.yml b/.templates/esphome/service.yml
new file mode 100644
index 00000000..330be329
--- /dev/null
+++ b/.templates/esphome/service.yml
@@ -0,0 +1,15 @@
+esphome:
+ container_name: esphome
+ image: esphome/esphome
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Etc/UTC}
+ - USERNAME=${ESPHOME_USERNAME:-esphome}
+ - PASSWORD=${ESPHOME_PASSWORD:?eg echo ESPHOME_PASSWORD=ChangeMe >>~/IOTstack/.env}
+ network_mode: host
+ x-ports:
+ - "6052:6052"
+ volumes:
+ - ./volumes/esphome/config:/config
+ device_cgroup_rules:
+ - 'c 188:* rw'
diff --git a/docs/Containers/ESPHome.md b/docs/Containers/ESPHome.md
new file mode 100644
index 00000000..6b6e592b
--- /dev/null
+++ b/docs/Containers/ESPHome.md
@@ -0,0 +1,295 @@
+# ESPHome
+
+*ESPHome is a system to control your microcontrollers by simple yet powerful configuration files and control them remotely through Home Automation systems.*
+
+## Resources
+
+- [ESPHome documentation](https://esphome.io)
+- [DockerHub](https://hub.docker.com/r/esphome/esphome)
+- [GitHub](https://github.com/esphome/feature-requests)
+
+## IOTstack service definition {#serviceDefinition}
+
+``` yaml linenums="1"
+esphome:
+ container_name: esphome
+ image: esphome/esphome
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Etc/UTC}
+ - USERNAME=${ESPHOME_USERNAME:-esphome}
+ - PASSWORD=${ESPHOME_PASSWORD:?eg echo ESPHOME_PASSWORD=ChangeMe >>~/IOTstack/.env}
+ network_mode: host
+ x-ports:
+ - "6052:6052"
+ volumes:
+ - ./volumes/esphome/config:/config
+ device_cgroup_rules:
+ - 'c 188:* rw'
+```
+
+Notes:
+
+1. The container runs in "host" mode, meaning it binds to the host port 6052.
+2. The `x-` prefix on the `x-ports` clause has the same effect as commenting-out lines 10 and 11. It serves the twin purposes of documenting the fact that the ESPHome container uses port 6052 and minimising the risk of port number collisions.
+
+## Container installation
+
+### via the IOTstack menu
+
+If you select ESPHome in the IOTstack menu, as well as adding the [service definition](#serviceDefinition) to your compose file, the menu:
+
+1. Copies a rules file into `/etc/udev/rules.d`.
+2. Checks `~/IOTstack/.env` for the presence of the `ESPHOME_USERNAME` and initialises it to the value `esphome` if it is not found.
+3. Checks `~/IOTstack/.env` for the presence of the `ESPHOME_PASSWORD` and initialises it to a random value if it is not found.
+
+### manual installation {#manualInstall}
+
+If you prefer to avoid the menu, you can install ESPHome like this:
+
+1. Be in the correct directory:
+
+ ``` console
+ $ cd ~/IOTstack
+ ```
+
+2. If you are on the "master" branch, add the service definition like this:
+
+ ``` console
+ $ sed -e "s/^/ /" ./.templates/esphome/service.yml >>docker-compose.yml
+ ```
+
+ Alternatively, if you are on the "old-menu" branch, do this:
+
+ ``` console
+ $ cat ./.templates/esphome/service.yml >>docker-compose.yml
+ ```
+
+3. Replace `«username»` and `«password»` in the following commands with values of your choice and then run the commands:
+
+ ``` console
+ $ echo "ESPHOME_USERNAME=«username»” >>.env
+ $ echo "ESPHOME_PASSWORD=«password»" >>.env
+ ```
+
+ This initialises the required environment variables. Although the username defaults to `esphome`, there is no default for the password. If you forget to set a password, `docker-compose` will remind you when you try to start the container:
+
+ ```
+ error while interpolating services.esphome.environment.[]: \
+ required variable ESPHOME_PASSWORD is missing a value: \
+ eg echo ESPHOME_PASSWORD=ChangeMe >>~/IOTstack/.env
+ ```
+
+ The values of the username and password variables are applied each time you start the container. In other words, if you decide to change these credentials, all you need to do is edit the `.env` file and “up” the container.
+
+4. Copy the UDEV rules file into place and ensure it has the correct permissions:
+
+ ``` console
+ $ sudo cp ./.templates/esphome/88-tty-iotstack-esphome.rules /etc/udev/rules.d/
+ $ sudo chmod 644 /etc/udev/rules.d/88-tty-iotstack-esphome.rules
+ ```
+
+## A quick tour
+
+ESPHome provides a number of methods for provisioning an ESP device. These instructions focus on the situation where the device is connected to your Raspberry Pi via a USB cable.
+
+### start the container
+
+To start the container:
+
+``` console
+$ cd ~/IOTstack
+$ docker-compose up -d esphome
+```
+
+Tip:
+
+* You can always retrieve your ESPHome login credentials from the `.env` file. For example:
+
+ ``` console
+ $ grep “^ESPHOME_” .env
+ ESPHOME_USERNAME=esphome
+ ESPHOME_PASSWORD=8AxXG5ZVsO4UGTMt
+ ```
+
+### connect your ESP device
+
+Connect your ESP device to one of your Raspberry Pi’s USB ports. You need to connect the device *while* the ESPHome container is running so that the [UDEV rules file](#udevRules) can propagate the device (typically `/dev/ttyUSBn`) into the container.
+
+So long as the container is running, you can freely connect and disconnect ESP devices to your Raspberry Pi’s USB ports, and the container will keep “in sync”.
+
+### in your browser
+
+Launch your browser. For maximum flexibility, ESPHome recommends browsers that support *WebSerial*, like Google Chrome or Microsoft Edge.
+
+1. Connect to your Raspberry Pi on port 6052 (reference point 🄰 in the following screen shot):
+
+ ![main login screen](./images/esphome-010-login.png)
+
+ You can use your Raspberry Pi’s:
+
+ * multicast domain name (eg `raspberrypi.local`);
+ * IP address (eg 192.168.1.100); or
+ * domain name (if you run your own Domain Name System server).
+
+2. Enter your ESPHome credentials at 🄱 and click Login.
+
+3. Click either of the + New Device buttons 🄲:
+
+ ![add new device](./images/esphome-020-new-device.png)
+
+ Read the dialog and then click Continue 🄳:
+
+ ![new device dialog](./images/esphome-030-new-device-continue.png)
+
+4. Give the configuration a name at 🄴:
+
+ ![create configuration dialog](./images/esphome-040-create-config.png)
+
+ In the fields at 🄵, enter the Network Name (SSID) and password (PSK) of the WiFi network that you want your ESP devices to connect to when they power up.
+
+ > The WiFi fields are only displayed the very first time you set up a device. Thereafter, ESPHome assumes all your devices will use the same WiFi network.
+
+ Click “Next” 🄶.
+
+5. Select the appropriate SoC (System on a Chip) type for your device. Here, I am using a generic ESP32 at 🄷:
+
+ ![select device type dialog](./images/esphome-050-device-type.png)
+
+ Clicking on the appropriate line proceeds to the next step.
+
+6. You can either make a note of the encryption key or, as is explained in the dialog, defer that until you actually need it for Home Assistant. Click “Install” 🄸.
+
+ ![device encryption key](./images/esphome-060-encryption-key.png)
+
+7. The primary reason for running ESPHome as a container in IOTstack is so you can program ESP devices attached to your Raspberry Pi. You need to tell ESPHome what you are doing by selecting “Plug into the computer running ESPHome Dashboard” 🄹:
+
+ ![choose device connection method](./images/esphome-070-install-method.png)
+
+8. If all has gone well, your device will appear in the list. Select it 🄺:
+
+ ![pick server USB port](./images/esphome-080-server-port.png)
+
+ If, instead, you see the window below, it likely means you did not connect your ESP device *while* the ESPHome container was running:
+
+ ![no device detected alert](./images/esphome-085-no-server-port.png)
+
+ Try disconnecting and reconnecting your ESP device, and waiting for the panel 🄺 to refresh. If that does not cure the problem then it likely means the [UDEV rules](#udevRules) are not matching on your particular device for some reason. You may need to consider [privileged mode](#privileged).
+
+9. The container will begin the process of compiling the firmware and uploading it to your device. The first time you do this takes significantly longer than second-or-subsequent builds, mainly because the container downloads almost 2GB of data.
+
+ ![build process log window](./images/esphome-090-build-sequence.png)
+
+ The time to compile depends on the speed of your Raspberry Pi hardware (ie a Raspberry Pi 5 will be significantly faster than a model 4, than a model 3). Be patient!
+
+ When the progress log 🄻 implies the process has been completed, you can click Stop 🄼 to dismiss the window.
+
+10. Assuming normal completion, your ESP device should show as “Online” 🄽. You can edit or explore the configuration using the “Edit” and “⋮” buttons.
+
+ ![job done, device online](./images/esphome-100-device-online.png)
+
+## Getting a clean slate
+
+If ESPHome misbehaves or your early experiments leave a lot of clutter behind, and you decide it would be best to start over with a clean installation, run the commands below:
+
+``` console
+$ cd ~/IOTstack
+$ docker-compose down esphome
+$ sudo rm -rf ./volumes/esphome
+$ docker-compose up -d esphome
+```
+
+Notes:
+
+1. Always be careful with `sudo rm`. Double-check the command **before** you press enter.
+2. The `sudo rm` may seem to take longer than you expect. Do not be concerned. ESPHome downloads a lot of data which it stores at the hidden path:
+
+ ```
+ /IOTstack/volumes/esphome/config/.esphome
+ ```
+
+ A base install has more than 13,000 files and over 3,000 directories. Even on a solid state disk, deleting that meny directory entries takes time!
+
+## Device mapping
+
+### UDEV rules file {#udevRules}
+
+The [service definition](#serviceDefinition) contains the following lines:
+
+``` yaml linenums="14"
+ device_cgroup_rules:
+ - 'c 188:* rw'
+```
+
+Those lines assume the presence of a rules file at:
+
+```
+/etc/udev/rules.d/88-tty-iotstack-esphome.rules
+```
+
+That file is copied into place automatically if you use the IOTstack menu to select ESPHome. It should also have been copied if you [installed ESPHome manually](#manualInstall).
+
+What the rules file does is to wait for you to connect any USB device which maps to a major device number of 188. That includes most (hopefully all) USB-to-serial adapters that are found on ESP dev boards, or equivalent standalone adapters such as those made by Future Technology Devices International (FTDI) and Silicon Laboratories Incorporated where you typically connect jumper wires to the GPIO pins which implement the ESP's primary serial interface.
+
+Whenever you **connect** such a device to your Raspberry Pi, the rules file instructs the ESPHome container to add a matching node. Similarly, when you **remove** such a device, the rules file instructs the ESPHome container to delete the matching node. The **container** gains the ability to access the USB device (the ESP) via the `device_cgroup_rules` clause.
+
+You can check whether a USB device is known to the container by running:
+
+``` console
+$ docker exec esphome ls /dev
+```
+
+The mechanism is not 100% robust. In particular, it will lose synchronisation if the system is rebooted, or the container is started when a USB device is already mounted. Worst case should be the need to unplug then re-plug the device, after which the container should catch up.
+
+#### Removing the rules file {#udevRulesRemoval}
+
+The UDEV rules "fire" irrespective of whether or not the ESPHome container is actually running. All that happens if the container is not running is an error message in the system log. However, if you decide to remove the ESPHome container, you should remove the rules file by hand:
+
+``` console
+$ sudo rm /etc/udev/rules.d/88-tty-iotstack-esphome.rules
+```
+
+### Privileged mode {#privileged}
+
+The [UDEV rules approach](#udevRules) uses the principle of least privilege but it relies upon an assumption about how ESP devices represent themselves when connected to a Raspberry Pi.
+
+If you encounter difficulties, you can consider trying this instead:
+
+1. Follow the instructions to [remove the UDEV rules file](#udevRulesRemoval).
+2. Edit the [service definition](#serviceDefinition) so that it looks like this:
+
+ ``` yaml linenums="14"
+ x-device_cgroup_rules:
+ - 'c 188:* rw'
+ privileged: true
+ ```
+
+ The `x-` prefix has the effect of commenting-out lines 14 and 15, making it easy to restore them later.
+
+3. Start the container:
+
+ ``` console
+ $ cd ~/IOTstack
+ $ docker-compose up -d esphome
+ ```
+
+The `privileged` flag gives the container unrestricted access to **all** of `/dev`. The container runs as root so this is the same as granting any process running inside the ESPHome container full and unrestricted access to **all** corners of your hardware platform, including your mass storage devices (SD, HD, SSD). You should use privileged mode *sparingly* and in full knowledge that it is entirely at your own risk!
+
+## Routine maintenance
+
+You can keep ESPHome up-to-date with routine “pull” commands:
+
+``` console
+$ cd ~/IOTstack
+$ docker-compose pull
+$ docker-compose up -d
+$ docker system prune -f
+```
+
+If a `pull` downloads a more-recent image for ESPHome, the subsequent `up` will (logically) disconnect any connected ESP device from the container.
+
+The same will happen if you “down” and “up” the ESPHome container, or reboot the Raspberry Pi, while an ESP device is physically connected to the Raspberry Pi.
+
+> In every case, the device will still be known to the Raspberry Pi, just not the ESPHome container. In a logical sense, the container is “out of sync” with the host system.
+
+If this happens, disconnect and reconnect the device. The [UDEV rule](#udevRules) will “fire” and propagate the device back into the running container.
diff --git a/docs/Containers/images/esphome-010-login.png b/docs/Containers/images/esphome-010-login.png
new file mode 100644
index 00000000..940ba41d
Binary files /dev/null and b/docs/Containers/images/esphome-010-login.png differ
diff --git a/docs/Containers/images/esphome-020-new-device.png b/docs/Containers/images/esphome-020-new-device.png
new file mode 100644
index 00000000..add6ed46
Binary files /dev/null and b/docs/Containers/images/esphome-020-new-device.png differ
diff --git a/docs/Containers/images/esphome-030-new-device-continue.png b/docs/Containers/images/esphome-030-new-device-continue.png
new file mode 100644
index 00000000..8f3a7d9f
Binary files /dev/null and b/docs/Containers/images/esphome-030-new-device-continue.png differ
diff --git a/docs/Containers/images/esphome-040-create-config.png b/docs/Containers/images/esphome-040-create-config.png
new file mode 100644
index 00000000..eebf9a24
Binary files /dev/null and b/docs/Containers/images/esphome-040-create-config.png differ
diff --git a/docs/Containers/images/esphome-050-device-type.png b/docs/Containers/images/esphome-050-device-type.png
new file mode 100644
index 00000000..e216f182
Binary files /dev/null and b/docs/Containers/images/esphome-050-device-type.png differ
diff --git a/docs/Containers/images/esphome-060-encryption-key.png b/docs/Containers/images/esphome-060-encryption-key.png
new file mode 100644
index 00000000..ced10351
Binary files /dev/null and b/docs/Containers/images/esphome-060-encryption-key.png differ
diff --git a/docs/Containers/images/esphome-070-install-method.png b/docs/Containers/images/esphome-070-install-method.png
new file mode 100644
index 00000000..9b04cece
Binary files /dev/null and b/docs/Containers/images/esphome-070-install-method.png differ
diff --git a/docs/Containers/images/esphome-080-server-port.png b/docs/Containers/images/esphome-080-server-port.png
new file mode 100644
index 00000000..9b8bb7a4
Binary files /dev/null and b/docs/Containers/images/esphome-080-server-port.png differ
diff --git a/docs/Containers/images/esphome-085-no-server-port.png b/docs/Containers/images/esphome-085-no-server-port.png
new file mode 100644
index 00000000..48752498
Binary files /dev/null and b/docs/Containers/images/esphome-085-no-server-port.png differ
diff --git a/docs/Containers/images/esphome-090-build-sequence.png b/docs/Containers/images/esphome-090-build-sequence.png
new file mode 100644
index 00000000..dbe0f780
Binary files /dev/null and b/docs/Containers/images/esphome-090-build-sequence.png differ
diff --git a/docs/Containers/images/esphome-100-device-online.png b/docs/Containers/images/esphome-100-device-online.png
new file mode 100644
index 00000000..6ae2938e
Binary files /dev/null and b/docs/Containers/images/esphome-100-device-online.png differ