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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user