Download a screen's firmware and put it on from your own computer (0.2.68)
GitHub issue #8: Home Assistant on a server or virtual machine is out of reach of the screen. New screen and Firmware & USB get a Download choice: ESP Screens builds the profile's firmware as always and offers ESPHome's factory image, which the owner flashes with ESPHome Web in Chrome or Edge. - Firmware: a download job compiles without uploading; only the image of the profile's latest successful build in this process is served, a new job withdraws it, and a build without a factory image fails. - USB on the Home Assistant machine stays first in both lists, also before a board is plugged in; Download and Later stay chosen once picked. - The not-yet-in-Home-Assistant card knows a downloaded screen. - The device name pattern is valid again under Chrome's v flag. - Docs: README, DOCS, EASY_SETUP, DOCKER, TROUBLESHOOTING. App only; firmware stays 0.2.58. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,8 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
|
||||
|
||||
**You don't need to install the separate ESPHome Device Builder app.**
|
||||
ESP Screen Manager already includes the ESPHome CLI and can build firmware itself,
|
||||
install it via USB, and later update it wirelessly over OTA.
|
||||
install it via USB or give you the file to put on the screen from your own computer,
|
||||
and later update it wirelessly over OTA.
|
||||
|
||||
**You do need to pair the flashed screen via the ESPHome integration in HA.**
|
||||
That pairing lives under **Settings → Devices & services**, not in the
|
||||
@@ -142,6 +143,10 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:
|
||||
with unique API and OTA keys goes into the ESPHome folder; the build
|
||||
and flash run in the same window (a first build takes a few minutes on a
|
||||
Raspberry Pi). Each screen gets its own profile.
|
||||
Is Home Assistant on a server or in a virtual machine, out of reach of the screen?
|
||||
Choose **Download** under **Install via**: ESP Screens builds the firmware, and you put it
|
||||
on the screen from your own computer with [ESPHome Web](https://web.esphome.io) in Chrome
|
||||
or Edge. After that, updates go over Wi-Fi as usual.
|
||||
4. **CYD:** go through the calibration on the screen. **Guition:** uses GT911
|
||||
without resistive calibration. Then pair the discovered ESPHome device in
|
||||
**Settings → Devices & services** using the API key the window
|
||||
|
||||
@@ -75,6 +75,9 @@ under **Settings → Devices & services** in Home Assistant, and then choose the
|
||||
- **USB (Linux only):** connect the screen, uncomment `devices:` in `compose.yaml` with its
|
||||
port (`/dev/ttyUSB0` or `/dev/ttyACM0`), and run `docker compose up -d` again. Installs
|
||||
over Wi-Fi (OTA) and the nightly updates need nothing extra.
|
||||
- **Without USB passthrough (app 0.2.68+):** in **New screen**, choose **Download · flash from
|
||||
your own computer**. The container builds the firmware, and you put it on the screen with
|
||||
[ESPHome Web](https://web.esphome.io) in Chrome or Edge on the computer the screen is plugged into.
|
||||
- **Backups:** Home Assistant backups don't include this container. Keep a copy of
|
||||
`data/screens.json` and `data/updates.json` (layouts and update settings) and of the
|
||||
ESPHome folder. `data/build` and `data/platformio` are caches.
|
||||
|
||||
+15
-4
@@ -64,10 +64,21 @@ again for an existing screen generates new keys and is not the
|
||||
update route.
|
||||
|
||||
No USB port in the list? A cable plugged into your laptop isn't visible to the
|
||||
Raspberry Pi. In that case, choose **Later · save profile only**: the profile then
|
||||
sits in the same folder ESPHome Device Builder uses, so you can open it there
|
||||
and flash from your browser (**Install → Plug into this computer**), or
|
||||
install it to the HA machine later via **Settings → Firmware & USB**.
|
||||
machine running Home Assistant, and a server or virtual machine may have no USB port
|
||||
within reach at all. Then put the firmware on the screen from your own computer:
|
||||
|
||||
1. Under **Install via**, choose **Download · flash from your own computer** and click
|
||||
**Build & download**. ESP Screens builds the firmware the same way; when it's ready,
|
||||
the window offers the file, for example `kitchen.factory.bin`.
|
||||
2. Plug the screen into your own computer with a USB data cable.
|
||||
3. Open [ESPHome Web](https://web.esphome.io/?dashboard_install) in Chrome or Edge on that
|
||||
computer (other browsers can't reach USB), click **Connect** and choose the screen's port.
|
||||
4. Click **Install** and select the downloaded file. The screen restarts and joins your Wi-Fi.
|
||||
|
||||
The file holds your Wi-Fi password and the screen's keys: keep it to yourself. Pairing works
|
||||
as in chapter 3, and every later update goes over Wi-Fi, so the cable is only needed once.
|
||||
For an existing profile, the same file is under **Settings → Firmware & USB**: choose the
|
||||
profile and **Download · flash from your own computer**, then **Build & download**.
|
||||
|
||||
**CYD:** calibration appears on first boot. Calmly tap the visible crosshair
|
||||
three times, hold each tap briefly, and follow each next crosshair in turn.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Test results app 0.2.68 / firmware 0.2.58 (2026-09-17)
|
||||
|
||||
GitHub issue #8 asked for a firmware download, because Home Assistant runs on a Proxmox server and plugging the
|
||||
screen into it is awkward. ESP Screens already builds a profile's firmware with its own ESPHome CLI and flashes it over
|
||||
USB; this release adds a third choice: build, download the file, and put it on the screen from your own computer with
|
||||
ESPHome Web. Max's calls: the three routes are "build and flash on the Home Assistant machine", "build and flash in the
|
||||
browser" (later) and "build and download" (this release); USB on the Home Assistant machine must always stay visible,
|
||||
also before a board is plugged in, "or people think it isn't possible at all". App only. Built on 0.2.67 (`6809d3b`).
|
||||
|
||||
## ESPHome, read first
|
||||
|
||||
- ESPHome 2026.6.2 `components/esp32/__init__.py` `get_download_types`: the "Factory format (Previously Modern)" is
|
||||
`firmware.factory.bin` next to `firmware.bin`, "For use with ESPHome Web and other tools"; the dashboard's download
|
||||
handler serves it from `firmware_bin_path.parent`. `core/config.py`: `ESPHOME_BUILD_PATH` becomes
|
||||
`<build path>/<node>`, so the app's `data/build/<profile>` holds `<node>/.pioenvs/<node>/firmware.factory.bin`
|
||||
(PlatformIO) or `<node>/build/firmware.factory.bin` (`espidf/toolchain.py`, native ESP-IDF). Both are searched; the
|
||||
newest wins and links are skipped.
|
||||
- ESPHome's own install dialog (`esphome_dashboard` source maps, `install-choose-dialog.ts`) offers Wirelessly, Plug
|
||||
into this computer, Plug into the computer running ESPHome Device Builder and Manual download. Browser flashing needs
|
||||
`"serial" in navigator` and `window.isSecureContext` (HTTPS); otherwise it falls back to the download plus
|
||||
`https://web.esphome.io/?dashboard_install`. ESP Screens links to that same address.
|
||||
- ESPHome Web's words (`esphome/device-builder-frontend` `src/translations/en.json`): "Connect your device below, then
|
||||
click Install to flash your downloaded project", Connect, Install, "Select the factory binary you want to install on
|
||||
your device", and holding BOOT when it fails to initialise. The steps in the window and the docs use those words.
|
||||
|
||||
## Automated
|
||||
|
||||
- Python (`.venv-portal`): 386 tests OK (377 on 0.2.67).
|
||||
- New `tests/test_firmware_download.py` (9), with a stand-in ESPHome CLI that writes the factory image where ESPHome
|
||||
2026.6.2 does: the Download target is checked (CLI, busy slot, unknown targets) before anything is written; the
|
||||
download job compiles without uploading and offers `<profile>.factory.bin`; a build without a factory image fails
|
||||
instead of offering nothing; the native ESP-IDF layout is found; a new job withdraws the old file until it succeeds,
|
||||
so a failed rebuild offers nothing; Build only offers the file and Check does not; only a file built by this process
|
||||
is served (not one left from before a restart, not another name, not a link); the newest image wins after a node
|
||||
rename; and the HTTP route: CSRF on the create, 400 while building, `application/octet-stream`,
|
||||
`attachment; filename="kitchen.factory.bin"`, `no-store`, identical bytes, and the *not yet in Home Assistant* card
|
||||
turning to "downloaded".
|
||||
- `node --check screen_manager/app/static/app.js` clean.
|
||||
|
||||
## Real ESPHome builds (2026.6.2, Mac)
|
||||
|
||||
Through `Firmware.install(target='download')` itself, with the app's environment (`ESPHOME_BUILD_PATH` per profile,
|
||||
`PLATFORMIO_CORE_DIR` under data), a profile made by `installation_yaml` and the remote packages from `main`:
|
||||
|
||||
- **CYD** (`download-check-cyd`): success after 132 s, `Succeeded: download`, the image found at
|
||||
`data/build/download-check-cyd/download-check-cyd/.pioenvs/download-check-cyd/firmware.factory.bin`, 1,602,272 bytes.
|
||||
Layout checked: padding to 0x1000, bootloader magic 0xE9 at 0x1000, partition table at 0x8000 (otadata, phy_init,
|
||||
app0 at 0x10000, app1 at 0x1D0000, nvs at 0x390000), app at 0x10000: `esptool image-info` checksum valid, validation
|
||||
hash valid, project `download-check-cyd`, ESP-IDF 5.5.4; the image contains firmware version 0.2.58. The file ends
|
||||
far below NVS, so flashing it keeps a CYD's touch calibration, as a USB install does.
|
||||
- **Guition** (`download-check-guition`): success after 126 s, 1,841,760 bytes at the same place. Bootloader magic
|
||||
0xE9 at 0x0 (ESP32-S3), partition table at 0x8000 (app0 at 0x10000 and app1 at 0x7D0000, 0x7C0000 each; nvs at
|
||||
0xF90000), app at 0x10000 with a valid checksum and validation hash, project `download-check-guition`, firmware
|
||||
0.2.58 inside.
|
||||
|
||||
## Editor, clicked through
|
||||
|
||||
Demo home with the real app code (fake Home Assistant, stand-in CLI with ESPHome's log lines, USB ports from a file),
|
||||
desktop 1280×900 and phone 375×812:
|
||||
|
||||
- New screen without a board: **Install via** lists `USB · no board found on the Home Assistant machine yet` first and
|
||||
selects it, **Install** waits (disabled) and the hint says where to plug in or to choose Download.
|
||||
- A board plugged in: the list shows its port within a poll and selects it; **Install** is enabled. A second board:
|
||||
"More than one board connected". Unplugged while chosen: back to the waiting USB entry.
|
||||
- **Download** or **Later** chosen: kept while boards come and go. Button: **Build & download** / **Save profile**.
|
||||
- **Build & download**: `POST firmware/profiles` with `target: download`, "Building firmware…" with the spinner;
|
||||
closed and reopened during the build, the window shows the running build again and ends on the download. Done:
|
||||
"Ready to download.", **Download garage-screen.factory.bin** (`api/firmware/profiles/garage-screen.yaml/download`,
|
||||
fetched from the page: 200, `application/octet-stream`, attachment name, `no-store`, 1,504,097 bytes; not clicked,
|
||||
so nothing landed in Downloads), the ESPHome Web link to `?dashboard_install` with `target="_blank"`, the CYD
|
||||
calibration note, the API key and the pairing steps.
|
||||
- A failing build: "That didn't work." / "Build failed" with the error line; **Retry** posts `action: download` without
|
||||
a target. **Later**: "Profile saved." with the new text that points to Firmware & USB and Download.
|
||||
- The card under My screens: "Firmware downloaded, not yet in Home Assistant…".
|
||||
- Firmware & USB: Wi-Fi / OTA, the USB entry (waiting or the ports, refreshed while open), Download; with Download the
|
||||
host field hides and the button says **Build & download**; a profile never built shows no link; after the build,
|
||||
**Download kitchen-screen.factory.bin** appears; back to Wi-Fi / OTA: **Build & install** with the host field.
|
||||
- Phone width: the page does not scroll sideways; the download box wraps.
|
||||
- Found on the way: the device name field's `pattern="[a-z][a-z0-9-]{0,29}"` is invalid under the `v` flag that current
|
||||
Chrome uses for `pattern`, so Chrome skipped the check and logged an error. Now `[a-z][a-z0-9\-]{0,29}`: "Bad Name",
|
||||
"9lives" and 31 characters are flagged while typing, "living-room" and "ok-2" pass.
|
||||
|
||||
## Home Assistant ingress (read only)
|
||||
|
||||
The Yellow runs the store add-on 0.2.66. Its existing zip download (`api/claude-skill.zip`, the same kind of GET with
|
||||
`Content-Disposition`) fetched through `/api/hassio_ingress/…` with an ingress session: 200, `application/zip`,
|
||||
`attachment; filename="esp-screens.zip"`, `no-store`, `Content-Length` kept and a valid zip. The firmware file takes
|
||||
the same path (below Home Assistant's 4 MB limit for a buffered response).
|
||||
|
||||
## Not tested / for the owner
|
||||
|
||||
- Nothing was flashed. A real flash with ESPHome Web in Chrome, with a file downloaded from the add-on in Home
|
||||
Assistant, is still to do: New screen (or Firmware & USB → Download) → Build & download → Download → web.esphome.io
|
||||
→ Connect → Install → select the file. A board keeps its identity when the file comes from its own profile.
|
||||
- The first build on the Yellow takes minutes, as with USB.
|
||||
@@ -6,6 +6,7 @@
|
||||
| Permission denied on Linux | Access to the serial device group (often `dialout`); log in again after a group change. |
|
||||
| Port busy | Stop ESPHome logs, the IDE monitor, and other serial readers. One reader at a time. |
|
||||
| Upload keeps connecting | Check the correct board/port. Hold BOOT while connecting and release afterward if the board requires it. |
|
||||
| ESPHome Web shows no port or can't connect (Download) | Chrome or Edge on a computer; Safari, Firefox and phones can't reach USB. A data cable, and the driver for the board's USB chip if the computer asks for one (CH340 on most CYDs). Hold BOOT while choosing the port if it keeps failing to connect. |
|
||||
| Build fails | Python 3.11–3.14 and ESPHome 2026.6.2; the full unpacked folder, local fonts/components present, internet, and disk space. Save the first real error from the build log. |
|
||||
| White/black or wrongly rotated image | Check the ILI9341 + XPT2046 variant. Don't blindly copy the pins, rotation, or SPI settings from a different CYD model. |
|
||||
| No measurement screen | The upload must be complete, not just the build. Flash with `-s CALIBRATION_ON_BOOT true`. Look for `USB calibration ready` in the USB logs; a normal boot shows no crosshairs. |
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## 0.2.68 (firmware 0.2.58)
|
||||
|
||||
Put a new screen on from your own computer.
|
||||
|
||||
- **Download the firmware.** **New screen** has a new choice under **Install via**: **Download · flash from your own computer**, for when the machine running Home Assistant is out of reach of the screen, such as a server, a virtual machine or a Docker host. ESP Screens builds the firmware as always and then offers the file, such as `kitchen.factory.bin`, with three steps: plug the screen into your computer, open ESPHome Web in Chrome or Edge and click Connect, then Install with that file. Pairing works as before, and after the first install every update goes over Wi-Fi.
|
||||
- **USB on the Home Assistant machine stays first.** The list always starts with USB, also before a board is plugged in, and picks the board as soon as it shows up. **Download** and **Later** stay chosen once you pick them.
|
||||
- **Firmware & USB** has the same **Download** choice for an existing profile: **Build & download** gives you that profile's file. Its USB entry is there before a board is plugged in too, and the list follows a board you plug in while the window is open.
|
||||
- **Only a fresh file.** A download is always the result of the latest successful build of that profile: while a build runs or after one fails, there is nothing to download. The file holds your Wi-Fi password and the screen's keys, and the window says so next to the button.
|
||||
- **The card under My screens knows.** A screen whose firmware was downloaded but isn't in Home Assistant yet says so, and what comes next.
|
||||
- **The device name is checked while you type.** Current Chrome ignored the check behind **customize**, so a name with a capital or a space was only refused after clicking Install.
|
||||
- No firmware change: firmware stays 0.2.58. Includes everything from 0.2.67.
|
||||
|
||||
## 0.2.67 (firmware 0.2.58)
|
||||
|
||||
Tiles take what they can do, their words and their icons from Home Assistant, and a tap can run any action Home Assistant offers.
|
||||
|
||||
@@ -7,9 +7,12 @@ Home Assistant machine, choose the board, name and USB port, then click **Instal
|
||||
The profile with unique keys goes into the ESPHome folder of the HA configuration,
|
||||
any missing Wi-Fi goes into `secrets.yaml` (existing secrets are left untouched), and the
|
||||
build and flash run in the same window; it then shows the API key
|
||||
for pairing. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Settings → Firmware & USB** is
|
||||
for existing profiles: check, build or reinstall via USB or
|
||||
the IP address (OTA). Existing ESPHome profiles in the HA config folder are
|
||||
for pairing. Is the Home Assistant machine out of reach of the screen, such as a server or a
|
||||
virtual machine? Choose **Download** under **Install via**: ESP Screens builds the firmware and
|
||||
you put it on the screen from your own computer with [ESPHome Web](https://web.esphome.io) in
|
||||
Chrome or Edge. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Settings → Firmware & USB** is
|
||||
for existing profiles: check, build or reinstall via USB, the IP address (OTA) or
|
||||
Download. Existing ESPHome profiles in the HA config folder are
|
||||
found automatically.
|
||||
|
||||
**Settings** (top right) also has the nightly firmware updates, the **Alerts** cheatsheet
|
||||
|
||||
@@ -43,6 +43,10 @@ class Firmware:
|
||||
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
|
||||
'external_components', 'captive_portal'})
|
||||
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
|
||||
# ESPHome's "Factory format" (bootloader, partition table and app from address 0), the file ESPHome Web
|
||||
# flashes on a board. A build writes it next to firmware.bin: under .pioenvs/<node>/ with PlatformIO,
|
||||
# under build/ with ESPHome's native ESP-IDF toolchain.
|
||||
FACTORY_IMAGES = ('*/.pioenvs/*/firmware.factory.bin', '*/build/firmware.factory.bin')
|
||||
|
||||
def __init__(self, root, data):
|
||||
self.root, self.data = Path(root).resolve(), Path(data)
|
||||
@@ -51,6 +55,10 @@ class Firmware:
|
||||
self.logs = deque(maxlen=300)
|
||||
self.process = None
|
||||
self.installed = set() # profiles this process flashed successfully; the page nudges pairing for them
|
||||
# Factory image per profile from its last successful build in this process; only these are downloaded,
|
||||
# so a download is never older than the profile's latest build. Downloaded ones get their own pairing nudge.
|
||||
self.images = {}
|
||||
self.downloaded = set()
|
||||
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
|
||||
|
||||
def profiles(self):
|
||||
@@ -114,7 +122,8 @@ class Firmware:
|
||||
|
||||
def status(self):
|
||||
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
|
||||
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs), 'wifi': self.wifi_status()}
|
||||
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs), 'wifi': self.wifi_status(),
|
||||
'downloads': sorted(self.images)}
|
||||
|
||||
def store_wifi(self, data):
|
||||
"""Put wifi_ssid/wifi_password in secrets.yaml when they are missing. Existing values and
|
||||
@@ -261,23 +270,48 @@ class Firmware:
|
||||
return self.override(profile.name)
|
||||
|
||||
def install(self, data):
|
||||
"""Profile plus, when a USB port is chosen, the build and flash in one go.
|
||||
"""Profile plus, when a USB port is chosen, the build and flash in one go. With the target
|
||||
'download' it builds only, and the owner flashes the image from their own computer.
|
||||
|
||||
The port and the job slot are checked before anything is written, so a refused
|
||||
The target and the job slot are checked before anything is written, so a refused
|
||||
install leaves no half-made profile behind."""
|
||||
target = data.get('target') or ''
|
||||
if target:
|
||||
if not isinstance(target, str) or target not in self.ports():
|
||||
if target != 'download' and (not isinstance(target, str) or target not in self.ports()):
|
||||
raise ValueError('Choose the connected USB port from the list.')
|
||||
if self.task and not self.task.done():
|
||||
raise ValueError('A build or installation is already running. Wait for it to finish.')
|
||||
if not shutil.which('esphome'):
|
||||
raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
result = self.create(data)
|
||||
if target:
|
||||
if target == 'download':
|
||||
result['job'] = self.start({'file': result['file'], 'action': 'download'})
|
||||
elif target:
|
||||
result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target})
|
||||
return result
|
||||
|
||||
def factory_image(self, profile):
|
||||
"""The factory image in this profile's own build folder, or None. The newest wins: a renamed node
|
||||
leaves its old folder behind."""
|
||||
build = self.data / 'build' / profile.stem
|
||||
found = [path for pattern in self.FACTORY_IMAGES for path in build.glob(pattern)
|
||||
if path.is_file() and not path.is_symlink()]
|
||||
return max(found, key=lambda path: path.stat().st_mtime_ns) if found else None
|
||||
|
||||
def image(self, name):
|
||||
"""(path, file name) of the image a profile's last build made, for the page's download.
|
||||
|
||||
Only an image built by this process counts, and a new job for the profile withdraws it first, so the
|
||||
owner never gets firmware from before a change or a failed build."""
|
||||
profile = self.profile(name)
|
||||
if self.job and self.job.get('file') == profile.name and self.job.get('state') == 'running':
|
||||
raise ValueError('The firmware is still being built. Download it when the build has finished.')
|
||||
path = self.images.get(profile.name)
|
||||
if not path or not path.is_file():
|
||||
raise ValueError('Build the firmware first; the download appears when the build has finished.')
|
||||
self.downloaded.add(profile.name)
|
||||
return path, profile.stem + '.factory.bin'
|
||||
|
||||
def redact(self, text):
|
||||
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
|
||||
for value in self._secret_values:
|
||||
@@ -288,7 +322,7 @@ class Firmware:
|
||||
if self.task and not self.task.done(): raise ValueError('A build or installation is already running.')
|
||||
profile = self.profile(data.get('file'))
|
||||
action = data.get('action')
|
||||
if action not in ('validate','build','install'): raise ValueError('Unknown firmware action.')
|
||||
if action not in ('validate','build','install','download'): raise ValueError('Unknown firmware action.')
|
||||
if not shutil.which('esphome'): raise ValueError('The ESPHome CLI is missing from this installation.')
|
||||
target = data.get('target','')
|
||||
if action == 'install':
|
||||
@@ -316,6 +350,7 @@ class Firmware:
|
||||
override_file = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
|
||||
if override_file.exists() and not override_file.is_symlink():
|
||||
collect(yaml.compose(override_file.read_text()))
|
||||
self.images.pop(profile.name, None) # until this job succeeds, the old image may no longer match the profile
|
||||
self.logs.clear()
|
||||
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
|
||||
self.task=asyncio.create_task(self.run(profile,action,target))
|
||||
@@ -336,8 +371,12 @@ class Firmware:
|
||||
self.logs.append(self.redact(line.decode(errors='replace').rstrip()))
|
||||
code=await self.process.wait()
|
||||
if code: raise RuntimeError('ESPHome '+stage+' failed; see the log.')
|
||||
image = self.factory_image(profile) if action != 'validate' else None
|
||||
if action == 'download' and not image:
|
||||
raise RuntimeError('ESPHome built no factory image (firmware.factory.bin) to download; see the log.')
|
||||
self.job['state']='success'; self.logs.append('Succeeded: '+action)
|
||||
if action=='install': self.installed.add(profile.name)
|
||||
if image: self.images[profile.name] = image
|
||||
except asyncio.CancelledError:
|
||||
self.job['state']='interrupted'
|
||||
raise
|
||||
|
||||
@@ -998,8 +998,9 @@ class Manager:
|
||||
nodes = {s.get('node') for s in screens}
|
||||
devices = {s.get('device') for s in screens}
|
||||
installed = getattr(self.firmware, 'installed', set())
|
||||
downloaded = getattr(self.firmware, 'downloaded', set())
|
||||
return [{'file': file, 'node': meta['node'], 'friendly': meta.get('friendly') or meta['node'] or file,
|
||||
'installed': file in installed, 'api_key': meta.get('api_key')}
|
||||
'installed': file in installed, 'downloaded': file in downloaded, 'api_key': meta.get('api_key')}
|
||||
for file, meta in profiles.items()
|
||||
if meta.get('screen') and meta.get('node') not in nodes and (meta.get('friendly') or None) not in devices]
|
||||
|
||||
@@ -1639,8 +1640,14 @@ def create_app(manager, development=False):
|
||||
raise ValueError('Invalid override data.')
|
||||
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
|
||||
async def firmware_create(request):
|
||||
# Profile, missing wifi secrets and (with a USB port) the build and flash in one request.
|
||||
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request.
|
||||
return web.json_response(manager.firmware.install(await request.json()))
|
||||
async def firmware_download(request):
|
||||
"""New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on
|
||||
the owner's own computer. Like the profile it came from, it holds the Wi-Fi password and the screen's keys."""
|
||||
path, name = manager.firmware.image(request.match_info['file'])
|
||||
return web.FileResponse(path, headers={'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': f'attachment; filename="{name}"'})
|
||||
async def shutdown(app):
|
||||
for task in (manager.updates.task, manager.firmware.task):
|
||||
if task and not task.done():
|
||||
@@ -1655,6 +1662,7 @@ def create_app(manager, development=False):
|
||||
app.router.add_post('/api/firmware/jobs', firmware_start)
|
||||
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
|
||||
app.router.add_put('/api/firmware/profiles/{file}/override', firmware_override_save)
|
||||
app.router.add_get('/api/firmware/profiles/{file}/download', firmware_download)
|
||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
|
||||
@@ -505,7 +505,9 @@ function renderPending() {
|
||||
node("strong", p.friendly),
|
||||
node("small", p.installed
|
||||
? "Installed, but not yet in Home Assistant. That happens outside ESP Screens: add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
|
||||
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Settings → Firmware & USB → ${p.file}.`),
|
||||
: p.downloaded
|
||||
? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
|
||||
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Settings → Firmware & USB → ${p.file}.`),
|
||||
);
|
||||
const actions = node("div", undefined, "pending-actions");
|
||||
const go = node("button", "Open Devices & services", "mini");
|
||||
@@ -2371,8 +2373,15 @@ for (const [value, label] of [
|
||||
// ----- New screen: profile, Wi-Fi, and the first flash in one window -----
|
||||
const installer = {
|
||||
poll: null, view: "setup", file: null, friendly: "", board: "cyd", target: "",
|
||||
apiKey: null, nodeEdited: false, ports: null, jobState: null,
|
||||
apiKey: null, nodeEdited: false, ports: null, jobState: null, picked: false, action: null,
|
||||
};
|
||||
// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own
|
||||
// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does).
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
function firmwareFile(file) {
|
||||
return { href: `api/firmware/profiles/${encodeURIComponent(file)}/download`, name: file.replace(/\.yaml$/, "") + ".factory.bin" };
|
||||
}
|
||||
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
|
||||
function slug(text) {
|
||||
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
@@ -2394,23 +2403,29 @@ function renderTargets(ports) {
|
||||
if (installer.ports === key) return;
|
||||
installer.ports = key;
|
||||
const current = select.value;
|
||||
const later = node("option", "Later · save profile only");
|
||||
later.value = "";
|
||||
select.replaceChildren(...ports.map((p) => { const o = node("option", portLabel(p)); o.value = p; return o; }), later);
|
||||
select.value = ports.includes(current) ? current : ports[0] || "";
|
||||
const option = (value, text) => { const o = node("option", text); o.value = value; return o; };
|
||||
// USB on the Home Assistant machine is always listed first, also before a board is plugged in, so nobody
|
||||
// concludes it isn't possible; "usb" stands for that port until one shows up.
|
||||
const usb = ports.length ? ports.map((p) => option(p, portLabel(p))) : [option("usb", "USB · no board found on the Home Assistant machine yet")];
|
||||
select.replaceChildren(...usb, option("download", DOWNLOAD_TARGET), option("", "Later · save profile only"));
|
||||
// Download or Later stays once the owner chose it; a USB choice follows the board.
|
||||
const kept = current === "download" || current === "" ? installer.picked : ports.includes(current);
|
||||
select.value = kept ? current : ports[0] || "usb";
|
||||
renderTargetHint();
|
||||
}
|
||||
function renderTargetHint() {
|
||||
const select = $("#install-target");
|
||||
const ports = select.options.length - 1;
|
||||
$("#target-hint").textContent = !ports
|
||||
? "No USB port found. Connect the screen with a data cable to the Home Assistant machine; the list refreshes on its own."
|
||||
: !select.value
|
||||
? "The profile goes into the ESPHome folder. You can install later via Settings → Firmware & USB, or from ESPHome Device Builder."
|
||||
: ports > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.";
|
||||
$("#install-go").textContent = select.value ? "Install" : "Save profile";
|
||||
const ports = [...select.options].filter((o) => o.value.startsWith("/")).length;
|
||||
$("#target-hint").textContent = select.value === "usb"
|
||||
? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer."
|
||||
: select.value === "download"
|
||||
? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi."
|
||||
: !select.value
|
||||
? "The profile goes into the ESPHome folder. You can install later via Settings → Firmware & USB, or from ESPHome Device Builder."
|
||||
: ports > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.";
|
||||
$("#install-go").textContent = select.value === "download" ? "Build & download" : select.value ? "Install" : "Save profile";
|
||||
}
|
||||
function renderWifi(wifi) {
|
||||
const fields = $("#wifi-fields");
|
||||
@@ -2453,7 +2468,9 @@ async function installerRefresh() {
|
||||
: !data.available && flashing
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
: wifiNote;
|
||||
$("#install-go").disabled = data.wifi?.state === "invalid" || (flashing && (busy || !data.available));
|
||||
// "usb" without a port: the button waits for the board, the hint under the list says where to plug it in.
|
||||
$("#install-go").disabled = data.wifi?.state === "invalid" || $("#install-target").value === "usb" ||
|
||||
(flashing && (busy || !data.available));
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
renderProgress(job, data.logs);
|
||||
}
|
||||
@@ -2475,26 +2492,34 @@ function outcome(ok) {
|
||||
function renderProgress(job, logs) {
|
||||
const running = job.state === "running";
|
||||
const ok = job.state === "success";
|
||||
const download = job.action === "download";
|
||||
installer.action = job.action;
|
||||
if (running) {
|
||||
$("#progress-spin").hidden = false;
|
||||
$("#progress-mark").hidden = true;
|
||||
} else outcome(ok);
|
||||
$("#install-title").textContent = running ? "One moment…" : ok ? "Done." : "That didn't work.";
|
||||
$("#install-title").textContent = running ? "One moment…" : ok ? (download ? "Ready to download." : "Done.") : "That didn't work.";
|
||||
$("#progress-title").textContent = running
|
||||
? job.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
: ok ? `Firmware is on ${installer.friendly}` : "Install failed";
|
||||
: ok
|
||||
? download ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download ? "Build failed" : "Install failed";
|
||||
$("#progress-detail").textContent = running
|
||||
? job.stage === "upload"
|
||||
? "Don't disconnect the USB cable yet."
|
||||
: "A first build takes a few minutes on a Raspberry Pi. You can close this window: the installation keeps running and you'll find it again under New screen."
|
||||
: `A first build takes a few minutes on a Raspberry Pi. You can close this window: the ${download ? "build" : "installation"} keeps running and you'll find it again under New screen.`
|
||||
: ok
|
||||
? `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
? download
|
||||
? "Put it on the screen from your own computer, then pair the screen with Home Assistant."
|
||||
: `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
: logs.filter((l) => /error/i.test(l)).pop() || logs.filter((l) => /failed/i.test(l)).pop() || "See the log below.";
|
||||
const pre = $("#install-log");
|
||||
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
|
||||
pre.textContent = logs.join("\n");
|
||||
if (stick) pre.scrollTop = pre.scrollHeight;
|
||||
if (!running && !ok) $("#install-log-wrap").open = true;
|
||||
$("#install-download").hidden = !(ok && download);
|
||||
if (ok && download) renderDownload();
|
||||
$("#install-result").hidden = !ok;
|
||||
if (ok) renderSteps();
|
||||
$("#install-retry").hidden = running || ok;
|
||||
@@ -2509,7 +2534,8 @@ function showSaved() {
|
||||
$("#install-title").textContent = "Profile saved.";
|
||||
$("#progress-title").textContent = `${installer.file} is in the ESPHome folder`;
|
||||
$("#progress-detail").textContent =
|
||||
"You can install once the screen is connected to the Home Assistant machine: Settings → Firmware & USB → this profile → USB port → Build & install. Or open the profile in ESPHome Device Builder (same folder) and flash from your browser. Save the API key for pairing:";
|
||||
"Install it later from Settings → Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:";
|
||||
$("#install-download").hidden = true;
|
||||
$("#install-result").hidden = false;
|
||||
renderSteps();
|
||||
$("#install-log-wrap").hidden = true;
|
||||
@@ -2517,6 +2543,30 @@ function showSaved() {
|
||||
$("#install-close").textContent = "Done";
|
||||
$("#install-close").classList.remove("quiet");
|
||||
}
|
||||
// ESPHome Web's words: Connect, then Install and "Select the factory binary you want to install on your device".
|
||||
function renderDownload() {
|
||||
const file = firmwareFile(installer.file);
|
||||
const link = $("#download-firmware");
|
||||
link.href = file.href;
|
||||
link.download = file.name;
|
||||
link.textContent = `Download ${file.name}`;
|
||||
const web = node("a", "ESPHome Web");
|
||||
web.href = ESPHOME_WEB;
|
||||
web.target = "_blank";
|
||||
web.rel = "noopener";
|
||||
const steps = [
|
||||
[["Plug the screen into your computer"], " with a USB data cable."],
|
||||
[["Open ", web], " in Chrome or Edge, click Connect and choose the screen's USB port."],
|
||||
[["Click Install"], ` and select ${file.name}. The screen restarts and joins your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""}`],
|
||||
];
|
||||
$("#download-steps").replaceChildren(...steps.map(([bold, text]) => {
|
||||
const li = node("li");
|
||||
const b = node("b");
|
||||
b.append(...bold);
|
||||
li.append(b, text);
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
function renderSteps() {
|
||||
$("#api-key").textContent = installer.apiKey || "";
|
||||
const steps = [
|
||||
@@ -2538,12 +2588,13 @@ function renderSteps() {
|
||||
}));
|
||||
}
|
||||
function resetInstaller() {
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "" });
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "", picked: false, action: null });
|
||||
const form = $("#install-form");
|
||||
form.reset();
|
||||
$("#node-label").hidden = true;
|
||||
$("#install-setup").hidden = false;
|
||||
$("#install-progress").hidden = true;
|
||||
$("#install-download").hidden = true;
|
||||
$("#install-result").hidden = true;
|
||||
$("#install-log-wrap").open = false;
|
||||
$("#install-log").textContent = "";
|
||||
@@ -2569,7 +2620,7 @@ $("#installer").addEventListener("close", () => {
|
||||
// A finished job is shown once; the next open starts a fresh form.
|
||||
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
|
||||
});
|
||||
$("#install-target").onchange = () => { renderTargetHint(); installerRefresh(); };
|
||||
$("#install-target").onchange = () => { installer.picked = true; renderTargetHint(); installerRefresh(); };
|
||||
$("#install-form").elements.friendly_name.oninput = syncNode;
|
||||
$("#install-form").elements.name.oninput = () => { installer.nodeEdited = true; syncNode(); };
|
||||
$("#edit-node").onclick = () => {
|
||||
@@ -2599,12 +2650,17 @@ $("#install-form").onsubmit = async (e) => {
|
||||
};
|
||||
$("#install-retry").onclick = async () => {
|
||||
try {
|
||||
const { ports } = await (await api("firmware")).json();
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0];
|
||||
const download = installer.action === "download";
|
||||
if (!download) {
|
||||
const { ports } = await (await api("firmware")).json();
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0];
|
||||
}
|
||||
const job = await (await api("firmware/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ file: installer.file, action: "install", target: installer.target }),
|
||||
body: JSON.stringify(download
|
||||
? { file: installer.file, action: "download" }
|
||||
: { file: installer.file, action: "install", target: installer.target }),
|
||||
})).json();
|
||||
showProgress(job, []);
|
||||
} catch (err) {
|
||||
@@ -2668,10 +2724,11 @@ document.addEventListener("visibilitychange", async () => {
|
||||
});
|
||||
|
||||
// Shared firmware workspace; always select a concrete profile and upload target.
|
||||
let firmwarePoll;
|
||||
let firmwarePoll, firmwareData;
|
||||
async function firmwareRefresh(initial = false) {
|
||||
try {
|
||||
const data = await (await api("firmware")).json();
|
||||
firmwareData = data;
|
||||
if (initial) {
|
||||
$("#firmware-file").replaceChildren(
|
||||
...data.profiles.map((p) => {
|
||||
@@ -2680,30 +2737,57 @@ async function firmwareRefresh(initial = false) {
|
||||
return o;
|
||||
}),
|
||||
);
|
||||
$("#firmware-port").replaceChildren(
|
||||
...[["ota", "Wi-Fi / OTA"], ...data.ports.map((p) => [p, p])].map(
|
||||
([value, text]) => {
|
||||
const o = node("option", text);
|
||||
o.value = value;
|
||||
return o;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
renderFirmwarePorts(data.ports);
|
||||
const running = data.job?.state === "running";
|
||||
for (const id of ["validate", "build", "install"])
|
||||
// Build & install also depends on the target: renderFirmwareTarget below.
|
||||
for (const id of ["validate", "build"])
|
||||
$("#firmware-" + id).disabled =
|
||||
running || !data.available || !data.profiles.length;
|
||||
$("#firmware-status").textContent = !data.available
|
||||
? "The ESPHome CLI is missing. Update the app to 0.2.0."
|
||||
: data.job
|
||||
? `${data.job.file} · ${data.job.action} · ${data.job.state}`
|
||||
: "Choose the intended profile and a USB port or IP address.";
|
||||
: "Choose the intended profile, then a USB port, an IP address, or Download.";
|
||||
$("#firmware-log").textContent = data.logs.join("\n");
|
||||
renderFirmwareTarget();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
// Wi-Fi / OTA, then USB on the Home Assistant machine (listed before a board is plugged in too, as in New screen),
|
||||
// then Download. Rebuilt only when the ports change, so a choice survives the refresh.
|
||||
function renderFirmwarePorts(ports) {
|
||||
const select = $("#firmware-port");
|
||||
const key = ports.join("\n");
|
||||
if (select.dataset.ports === key) return;
|
||||
select.dataset.ports = key;
|
||||
const current = select.value;
|
||||
const option = (value, text) => { const o = node("option", text); o.value = value; return o; };
|
||||
const usb = ports.length ? ports.map((p) => option(p, p)) : [option("usb", "USB · no board found on the Home Assistant machine yet")];
|
||||
select.replaceChildren(option("ota", "Wi-Fi / OTA"), ...usb, option("download", DOWNLOAD_TARGET));
|
||||
const usbChosen = current === "usb" || current.startsWith("/");
|
||||
select.value = ports.includes(current) || current === "ota" || current === "download" ? current : usbChosen ? ports[0] || "usb" : "ota";
|
||||
}
|
||||
// The chosen target decides the host field and the main button; with Download, the file of the profile's last
|
||||
// build appears as soon as there is one.
|
||||
function renderFirmwareTarget() {
|
||||
const target = $("#firmware-port").value;
|
||||
const file = $("#firmware-file").value;
|
||||
const data = firmwareData;
|
||||
$("#firmware-host-label").hidden = target !== "ota";
|
||||
$("#firmware-install").textContent = target === "download" ? "Build & download" : "Build & install";
|
||||
$("#firmware-install").disabled = !data || data.job?.state === "running" || !data.available || !data.profiles.length || target === "usb";
|
||||
const ready = target === "download" && !!file && !!data?.downloads?.includes(file);
|
||||
$("#firmware-download").hidden = !ready;
|
||||
if (ready) {
|
||||
const image = firmwareFile(file);
|
||||
const link = $("#firmware-download-link");
|
||||
link.href = image.href;
|
||||
link.download = image.name;
|
||||
link.textContent = `Download ${image.name}`;
|
||||
}
|
||||
}
|
||||
$("#open-firmware").onclick = () => {
|
||||
$("#firmware-dialog").showModal();
|
||||
firmwareRefresh(true);
|
||||
@@ -2717,16 +2801,17 @@ $("#close-firmware").onclick = () => {
|
||||
$("#firmware-dialog").addEventListener("close", () =>
|
||||
clearInterval(firmwarePoll),
|
||||
);
|
||||
$("#firmware-port").onchange = () =>
|
||||
($("#firmware-host-label").hidden = $("#firmware-port").value !== "ota");
|
||||
$("#firmware-port").onchange = renderFirmwareTarget;
|
||||
$("#firmware-file").onchange = () => {
|
||||
$("#firmware-host").placeholder = $("#firmware-file").value.replace(
|
||||
/\.yaml$/,
|
||||
".local",
|
||||
);
|
||||
renderFirmwareTarget();
|
||||
};
|
||||
for (const action of ["validate", "build", "install"])
|
||||
$("#firmware-" + action).onclick = async () => {
|
||||
const target = $("#firmware-port").value;
|
||||
try {
|
||||
await api("firmware/jobs", {
|
||||
method: "POST",
|
||||
@@ -2737,11 +2822,15 @@ for (const action of ["validate", "build", "install"])
|
||||
? "validate"
|
||||
: action === "build"
|
||||
? "build"
|
||||
: "install",
|
||||
: target === "download"
|
||||
? "download"
|
||||
: "install",
|
||||
target:
|
||||
$("#firmware-port").value === "ota"
|
||||
target === "ota"
|
||||
? $("#firmware-host").value.trim()
|
||||
: $("#firmware-port").value,
|
||||
: target === "download"
|
||||
? ""
|
||||
: target,
|
||||
}),
|
||||
});
|
||||
await firmwareRefresh();
|
||||
|
||||
@@ -248,9 +248,10 @@
|
||||
</div>
|
||||
<div id="install-setup">
|
||||
<p>
|
||||
Connect the screen with a USB data cable to the machine running Home
|
||||
Assistant. ESP Screens creates its own profile with unique
|
||||
keys, builds the firmware, and puts it straight on the screen.
|
||||
ESP Screens creates the screen's own profile with unique keys and
|
||||
builds the firmware. Connect the screen with a USB data cable to the
|
||||
machine running Home Assistant, or download the firmware and put it
|
||||
on the screen from your own computer.
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
@@ -285,7 +286,7 @@
|
||||
<label id="node-label" hidden
|
||||
>Device name<input
|
||||
name="name"
|
||||
pattern="[a-z][a-z0-9-]{0,29}"
|
||||
pattern="[a-z][a-z0-9\-]{0,29}"
|
||||
maxlength="30"
|
||||
autocomplete="off" /><small
|
||||
>Lowercase letters, digits, and dashes. A different name for
|
||||
@@ -322,6 +323,11 @@
|
||||
<strong id="progress-title"></strong>
|
||||
</div>
|
||||
<p id="progress-detail"></p>
|
||||
<div id="install-download" class="download-box" hidden>
|
||||
<a class="button" id="download-firmware" href="">Download firmware</a>
|
||||
<ol class="steps" id="download-steps"></ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
<div id="install-result" hidden>
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key"></code>
|
||||
@@ -355,7 +361,8 @@
|
||||
<p>
|
||||
The built-in ESPHome CLI uses your own profile (YAML with keys)
|
||||
and secrets. Choose the screen's profile, then a USB port (screen
|
||||
connected to the Home Assistant machine) or <b>Wi-Fi / OTA</b> with the hostname.
|
||||
connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname,
|
||||
or <b>Download</b> to put the firmware on the screen from your own computer.
|
||||
<b>Check</b> only validates, <b>Build only</b> compiles,
|
||||
<b>Build & install</b> puts the firmware on the screen. Your tiles
|
||||
stay as they are in ESP Screens. The first build takes a few minutes.
|
||||
@@ -377,6 +384,15 @@
|
||||
><button id="firmware-install">Build & install</button>
|
||||
</div>
|
||||
<p id="firmware-status" role="status"></p>
|
||||
<div id="firmware-download" class="download-box" hidden>
|
||||
<a class="button" id="firmware-download-link" href="">Download firmware</a>
|
||||
<small>
|
||||
Put it on the screen with
|
||||
<a href="https://web.esphome.io/?dashboard_install" target="_blank" rel="noopener">ESPHome Web</a>
|
||||
in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file.
|
||||
It holds your Wi-Fi password and the screen's keys: keep it to yourself.
|
||||
</small>
|
||||
</div>
|
||||
<pre id="firmware-log"></pre>
|
||||
</dialog>
|
||||
<dialog id="override-dialog" class="override-dialog" aria-labelledby="override-title">
|
||||
|
||||
@@ -1335,6 +1335,32 @@ pre {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Download: the built firmware and how to put it on the screen from your own computer. */
|
||||
.download-box {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
margin: 4px 0 16px;
|
||||
}
|
||||
.download-box a.button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.download-box .steps {
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.download-box .steps li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.download-box a:not(.button) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.download-box > small {
|
||||
margin-top: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
#install-log-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.67"
|
||||
version: "0.2.68"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Download: ESP Screens builds a screen's firmware and the owner flashes the file from their own computer."""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
from firmware import Firmware # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
from test_screen_owned_settings import fake_ha
|
||||
|
||||
PROFILE = {'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen', 'wifi_ssid': 'ssid', 'wifi_password': 'password'}
|
||||
|
||||
# Stands in for the ESPHome CLI: it logs, and `compile` writes the factory image where ESPHome 2026.6 puts it
|
||||
# (PlatformIO: <build>/<node>/.pioenvs/<node>/, native ESP-IDF: <build>/<node>/build/). FAKE_LAYOUT=none writes
|
||||
# nothing, FAKE_EXIT makes it fail, FAKE_SLEEP keeps it busy.
|
||||
FAKE_CLI = '''#!{python}
|
||||
import os, sys, time
|
||||
from pathlib import Path
|
||||
stage, profile = [arg for arg in sys.argv[1:] if arg != '--quiet'][:2]
|
||||
profile = Path(profile)
|
||||
print('INFO Reading configuration ' + profile.name, flush=True)
|
||||
time.sleep(float(os.environ.get('FAKE_SLEEP', '0')))
|
||||
if os.environ.get('FAKE_EXIT'):
|
||||
print('ERROR Compiling ' + profile.stem + ' failed', flush=True)
|
||||
sys.exit(int(os.environ['FAKE_EXIT']))
|
||||
if stage == 'compile':
|
||||
build = Path(os.environ['ESPHOME_BUILD_PATH']) / profile.stem
|
||||
folder = {{'pio': build / '.pioenvs' / profile.stem, 'idf': build / 'build'}}.get(os.environ.get('FAKE_LAYOUT', 'pio'))
|
||||
if folder:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / 'firmware.bin').write_bytes(b'app')
|
||||
(folder / 'firmware.factory.bin').write_bytes(b'\\xe9factory-' + profile.stem.encode())
|
||||
print('INFO Successfully compiled program.', flush=True)
|
||||
'''
|
||||
|
||||
|
||||
def fake_cli(folder):
|
||||
"""The environment that finds the stand-in `esphome` before any real one."""
|
||||
bin_dir = Path(folder) / 'bin'
|
||||
bin_dir.mkdir(exist_ok=True)
|
||||
cli = bin_dir / 'esphome'
|
||||
cli.write_text(FAKE_CLI.format(python=sys.executable))
|
||||
cli.chmod(0o755)
|
||||
return {'PATH': f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"}
|
||||
|
||||
|
||||
class DownloadChecks(unittest.TestCase):
|
||||
def test_the_download_target_is_checked_before_anything_is_written(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
with patch('firmware.shutil.which', return_value=None):
|
||||
with self.assertRaisesRegex(ValueError, 'ESPHome CLI is missing'):
|
||||
f.install({**PROFILE, 'target': 'download'})
|
||||
with patch('firmware.shutil.which', return_value='/usr/bin/esphome'):
|
||||
f.task = Mock(done=lambda: False)
|
||||
with self.assertRaisesRegex(ValueError, 'already running'):
|
||||
f.install({**PROFILE, 'target': 'download'})
|
||||
f.task = None
|
||||
for target in ('downloads', ['download'], '/dev/ttyUSB0'):
|
||||
with self.assertRaisesRegex(ValueError, 'USB port'):
|
||||
f.install({**PROFILE, 'target': target})
|
||||
self.assertFalse((Path(tmp) / 'esphome' / 'kitchen.yaml').exists())
|
||||
self.assertFalse((Path(tmp) / 'esphome' / 'secrets.yaml').exists())
|
||||
|
||||
def test_only_a_profile_built_by_this_app_is_served(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
f.create(PROFILE)
|
||||
for name in ('../secrets.yaml', 'secrets', 'kitchen.local.yaml', 'missing.yaml', None):
|
||||
with self.assertRaises(ValueError):
|
||||
f.image(name)
|
||||
# A factory image left in the build folder, from before this app started, is not offered.
|
||||
old = Path(tmp) / 'data' / 'build' / 'kitchen' / 'kitchen' / '.pioenvs' / 'kitchen' / 'firmware.factory.bin'
|
||||
old.parent.mkdir(parents=True)
|
||||
old.write_bytes(b'old')
|
||||
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
|
||||
f.image('kitchen.yaml')
|
||||
self.assertEqual(f.downloaded, set())
|
||||
self.assertEqual(f.status()['downloads'], [])
|
||||
|
||||
def test_the_newest_image_wins_when_the_node_was_renamed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
profile = f.profile(f.create(PROFILE)['file'])
|
||||
build = Path(tmp) / 'data' / 'build' / 'kitchen'
|
||||
images = []
|
||||
for node, when in (('old-name', 1_000_000), ('kitchen', 2_000_000)):
|
||||
image = build / node / '.pioenvs' / node / 'firmware.factory.bin'
|
||||
image.parent.mkdir(parents=True)
|
||||
image.write_bytes(node.encode())
|
||||
os.utime(image, (when, when))
|
||||
images.append(image)
|
||||
# A link is never served, even when it points at something newer.
|
||||
outside = Path(tmp) / 'elsewhere.bin'
|
||||
outside.write_bytes(b'not ours')
|
||||
os.utime(outside, (3_000_000, 3_000_000))
|
||||
link = build / 'linked' / 'build' / 'firmware.factory.bin'
|
||||
link.parent.mkdir(parents=True)
|
||||
link.symlink_to(outside)
|
||||
self.assertEqual(f.factory_image(profile), images[1])
|
||||
|
||||
|
||||
class DownloadJobs(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_download_target_builds_and_offers_the_factory_image(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
result = f.install({**PROFILE, 'target': 'download'})
|
||||
self.assertEqual((result['file'], result['job']['action'], result['job']['state']), ('kitchen.yaml', 'download', 'running'))
|
||||
self.assertEqual(len(result['api_key']), 44, 'the page still shows the key for pairing')
|
||||
with self.assertRaisesRegex(ValueError, 'still being built'):
|
||||
f.image('kitchen.yaml')
|
||||
await f.task
|
||||
self.assertEqual(f.job['state'], 'success', list(f.logs))
|
||||
self.assertIn('ESPHome: compile', f.logs)
|
||||
self.assertNotIn('ESPHome: upload', f.logs, 'nothing is flashed from Home Assistant')
|
||||
self.assertEqual(f.status()['downloads'], ['kitchen.yaml'])
|
||||
path, name = f.image('kitchen.yaml')
|
||||
self.assertEqual((name, path.read_bytes()), ('kitchen.factory.bin', b'\xe9factory-kitchen'))
|
||||
self.assertEqual((f.installed, f.downloaded), (set(), {'kitchen.yaml'}))
|
||||
|
||||
async def test_a_build_without_a_factory_image_fails_instead_of_offering_nothing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_LAYOUT': 'none'}):
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
f.install({**PROFILE, 'target': 'download'})
|
||||
await f.task
|
||||
self.assertEqual(f.job['state'], 'failed')
|
||||
self.assertIn('no factory image', f.logs[-1])
|
||||
self.assertEqual(f.status()['downloads'], [])
|
||||
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
|
||||
f.image('kitchen.yaml')
|
||||
|
||||
async def test_the_native_esp_idf_layout_is_found_too(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_LAYOUT': 'idf'}):
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
f.install({**PROFILE, 'target': 'download'})
|
||||
await f.task
|
||||
path, _ = f.image('kitchen.yaml')
|
||||
self.assertEqual(path.parent.name, 'build')
|
||||
|
||||
async def test_a_new_build_withdraws_the_old_file_until_it_succeeds(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
f.install({**PROFILE, 'target': 'download'})
|
||||
await f.task
|
||||
path, _ = f.image('kitchen.yaml')
|
||||
with patch.dict(os.environ, {'FAKE_EXIT': '1'}):
|
||||
f.start({'file': 'kitchen.yaml', 'action': 'build'})
|
||||
with self.assertRaisesRegex(ValueError, 'still being built'):
|
||||
f.image('kitchen.yaml')
|
||||
await f.task
|
||||
self.assertEqual(f.job['state'], 'failed')
|
||||
self.assertTrue(path.exists(), 'the old file is still on disk')
|
||||
with self.assertRaisesRegex(ValueError, 'Build the firmware first'):
|
||||
f.image('kitchen.yaml')
|
||||
|
||||
async def test_build_only_offers_the_file_and_a_check_does_not(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, fake_cli(tmp)):
|
||||
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
|
||||
f.create(PROFILE)
|
||||
f.start({'file': 'kitchen.yaml', 'action': 'validate'})
|
||||
await f.task
|
||||
self.assertEqual((f.job['state'], f.status()['downloads']), ('success', []))
|
||||
f.start({'file': 'kitchen.yaml', 'action': 'build'})
|
||||
await f.task
|
||||
self.assertEqual((f.job['state'], f.status()['downloads']), ('success', ['kitchen.yaml']))
|
||||
with self.assertRaisesRegex(ValueError, 'Unknown firmware action'):
|
||||
f.start({'file': 'kitchen.yaml', 'action': 'flash'})
|
||||
self.assertEqual(f.status()['downloads'], ['kitchen.yaml'], 'a refused job keeps the file')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class DownloadRoute(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_page_downloads_the_built_file_and_the_card_says_so(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {**fake_cli(tmp), 'FAKE_SLEEP': '0.3',
|
||||
'ESPHOME_CONFIG': str(Path(tmp) / 'esphome')}):
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
inventory = await (await client.get('/api/inventory')).json()
|
||||
headers = {'X-Screen-CSRF': inventory['csrf']}
|
||||
refused = await client.post('/api/firmware/profiles', json={**PROFILE, 'target': 'download'})
|
||||
self.assertEqual(refused.status, 403, 'CSRF like every other change')
|
||||
response = await client.post('/api/firmware/profiles', headers=headers, json={**PROFILE, 'target': 'download'})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual((await response.json())['job']['action'], 'download')
|
||||
url = '/api/firmware/profiles/kitchen.yaml/download'
|
||||
early = await client.get(url)
|
||||
self.assertEqual(early.status, 400)
|
||||
self.assertIn('still being built', (await early.json())['error'])
|
||||
await m.firmware.task
|
||||
self.assertEqual((await (await client.get('/api/firmware')).json())['downloads'], ['kitchen.yaml'])
|
||||
pending = (await (await client.get('/api/inventory?light=1')).json())['pending']
|
||||
self.assertEqual([(p['file'], p['installed'], p['downloaded']) for p in pending], [('kitchen.yaml', False, False)])
|
||||
download = await client.get(url)
|
||||
self.assertEqual(download.status, 200)
|
||||
self.assertEqual(download.headers['Content-Type'], 'application/octet-stream')
|
||||
self.assertEqual(download.headers['Content-Disposition'], 'attachment; filename="kitchen.factory.bin"')
|
||||
self.assertEqual(download.headers['Cache-Control'], 'no-store', 'the file holds the Wi-Fi password')
|
||||
self.assertEqual(await download.read(), b'\xe9factory-kitchen')
|
||||
pending = (await (await client.get('/api/inventory?light=1')).json())['pending']
|
||||
self.assertEqual([(p['file'], p['installed'], p['downloaded']) for p in pending], [('kitchen.yaml', False, True)])
|
||||
self.assertEqual((await client.get('/api/firmware/profiles/other.yaml/download')).status, 400)
|
||||
self.assertNotEqual((await client.get('/api/firmware/profiles/..%2Fsecrets.yaml/download')).status, 200)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user