Imported from zygimantas/esphome-tesla-ble-scheduler (
web/AGENTS.md). Install upstream withnpx skills add zygimantas/esphome-tesla-ble-scheduler --skill web. Copyright stays with the author.
web/
- The page is built into the firmware (
js_includeandcss_includein device.yaml), so a change needs a build and a flash. The board serves it as /0.js and /0.css. - It shows and picks times in the phone's time zone, assuming the board's is the same.
- render() runs from requestAnimationFrame, which hidden and background browser tabs don't fire. When you test in a headless or hidden browser, replace requestAnimationFrame with a direct call, or the page stays empty.
- To try it without the board, serve a page that loads web_ui.css and web_ui.js as /0.css and /0.js, a fake /events (Server-Sent Events with
stateevents like{"id":"text_sensor/Charging status","value":"Charging","state":"Charging"}, andpingevents with the board's uptime in seconds, like{"uptime":12}), GET /settings and /settings/options, without which the page shows no cards, and a handler that accepts the POSTs; one that never answers them tries the 8 s timeout. - Biome formats and lints this folder; it replaced Prettier, whose CSS output differs slightly. A biome-ignore comment goes right above the declaration it's about, not above its rule.
[hidden] { display: none !important; }stays: without it, elements with their own display rule ignore the hidden attribute. - The layout is the owner's. The page keeps its own size, like an app, by request: no zooming or sideways panning (the viewport's maximum-scale=1 and user-scalable=no,
touch-action: pan-y, and Safari's gesturestart stopped, as Safari ignores user-scalable=no), while ESPHome's own page at ?full zooms as usual. Ready by is one dropdown of half-hours, as separate day and time dropdowns confused them, grouped by day, by request, each time keeping its day, like Thu 07:00, as a closed dropdown shows only its option. Charge limit and Ready by are the Schedule card's first rows, always, by request, rather than a card of their own, with what the mode offers below them: Create schedule or Start charging now, the windows with Delete schedule, Stop charging, or nothing while unplugged. Only Reset savings, Pair key and Restart board ask first, Pair key with what to do in the car: the setup's Create key doesn't, as the step says what to do. Create key went from Board, by request: a key removed in the car brings back the setup's key step instead, once the car turns away one of the board's commands (scheduler/AGENTS.md). There's no Advanced or Board card, by request: Restart board is in Settings, and there's no Factory reset anywhere, as it erased the Wi-Fi too; users start over by installing again over USB and letting ESPHome Web erase the board. - A new board's page is only its setup, one card titled Setup, by request, at one step at a time, without the steps' names, apart from Setup: prices, Setup: your car, the phone's and the car's, and Setup: charging key, by request, and without a way back, also by request: the prices, the phone, the car and its key, in that order, by request, so the prices, which take the bill, come on the computer that installed the board and the rest in the car. The prices' step comes with only Continue (Save, with Cancel, after the setup), the phone's with Continue here, the car's with Continue (the VIN's check and its save), and the key's with only Create key, by request, and no Cancel or Back, by request. A step done stays done, by request: the prices and the car's battery and power change in Settings after the setup. The prices' Continue saves them without the car, and the car's Continue them with the VIN, the battery and the power, which the board takes at once, so it can find the car; once it has one, a save restarts it only for another car, market area or currency, as the board's answer says (Saved: the board restarts), by request, and applies at once otherwise. The key's step waits for the car to answer the key, without a Finish, by request, past the board's No car yet from before the car's save; then the card says the setup is done and how to add the page to the home screen, until its OK, by request, so the setup has a clear end. Settings without prices or the car, or a key the car doesn't know, bring the setup back at the step they need. Nothing else is on it: no phone messages or Bluetooth signal, by request.
- The setup's fields stand each under its plain name, with a small question mark that opens its hint under the field, by request, rather than notes always shown, and so do Settings' after the setup, by request, where they keep their question marks and hints; a hint hides with its field. Savings' two rows have a question mark too, after the value, by request, whose hint has the period's energy, what it cost and the saving against charging at once, which were notes always shown under the rows. The prices' note on the bill's two parts went into the grid plan's and the contract's hints. Country / Area is a country's name, with its market area where it has several. Grid plan, where the country has plans, has only the plans, with My plan isn't listed, a box under the list (a field's own helper, which may change the list above it), for a real plan the board doesn't know yet (another operator's, say), as no one should have to search a long list for a way out; it turns the list off, and a note then tells the two kinds apart, by request: a standard plan that other customers have too is worth asking for (the Grid plan issue form) or adding (CONTRIBUTING.md's Plans) and choosing once a release brings it, and any other goes up as a custom plan, in the plans' format, now or later. Grid plan is at Choose on a new board where there are several, as nearly every home there is on one and skipping it would leave out its hours (Choose shows only in the closed list), and at the country's only plan where there's one, as in Spain and Slovenia, where every home pays it; where the country has none, a note in its place says the same, and each note is above Upload custom plan. Continue saves without a plan either way, by request. Contract type, renamed from Supplier's price and then Electricity contract, as it asks the kind of price, not an amount, is Dynamic (spot, exchange), the EU's word for a price that follows the market as often as it settles, or Fixed (or a monthly average), as the contract says: fixed leaves out
market:, writes the country's currency where it isn't the euro, as Dynamic does too in Czechia, Hungary and Switzerland, whose SMARD prices in euros the board converts at the ECB's daily rate, and charges at once without a grid plan, as every hour then costs the same. The supplier's part per kWh is right under it, optional, so the costs the board shows are whole: Supplier's margin with Dynamic, 0.00 at first and left out of the file while it's 0, as dynamic contracts add one too, and Supplier's part, without grid fees, written asfixed_price, with Fixed, as the margin with Dynamic, 0.00 at first, by request, and written even at 0, as it marks a fixed contract without a grid plan, whose fees it otherwise goes on top of: where a supplier quotes one price with the grid fees in, as in Lithuania, the whole price would count them twice, so it's the supplier's own line on the bill. - There are no VAT or time zone fields, by request, as the country decides both: every save writes the phone's time zone where it's one of the country's, as on the Canary Islands, else the country's main one, and with Dynamic the market area's VAT from COUNTRIES, none in NO4 (whose corner of Trøndelag does pay it), so a new rate needs a release and reaches a board at its next save; the board knows only those zones. Plans' names, from their
name:lines, are short enough for a phone, like ESO Standartinis, four zones. A price's label ends in its unit, like (EUR with VAT per kWh), in the currency it's in. Hours of one's own, a My own choice under a Cheaper hours row, were dropped by request: plans keep prices current for everyone, while own hours go stale every January, and hours no plan can hold, like France's off-peak hours set for each address, go in a custom plan. No Choose where the page can guess, by request: a new board starts at Dynamic, and Choose stays only where nothing can be guessed: several plans, or a country with several market areas or an unknown one. No lists of suppliers or grid operators: a supplier's name doesn't say which kind of price it is, and the plans name their operator. - The car's fields are VIN, whose hint says why the board needs it and where to find it, Battery (kWh), guessed from the VIN's 4th character, the model (95 for S and X, 75 for 3 and Y), as the VIN's battery codes differ by year and by source, which follows the VIN as it's typed until the owner types a size, with the usual sizes in its hint, and Charging power (kW), 11 at first, with where the app shows the power in its hint, as both decide how long charging takes. The VIN's field takes only what a VIN can hold as it's typed (capitals and digits, I, O and Q as 1 and 0, at most 17), and the step's Continue, rather than the browser, checks the VIN's form before saving it and says what's wrong on the card (17 letters and digits without I, O or Q, a Tesla maker code and the check digit, which Tesla's US, Shanghai and Berlin VINs all carry; a new factory's code goes in TESLA_MAKERS). The key's step has two sentences on the key, that the board adds its own like a phone key and that it can only charge, without a question mark, by request, then one box for the user to tick, I am in the car with my Tesla key card, in their own words, by request, and one button for the whole step, by request, which says what it waits for: Looking for the car … until the board has the car's Bluetooth signal (BLE Signal, which comes before the car knows the key), as the car can't be asked for the key without it, then Create key, by request, which works once the box is ticked and asks the car for the key; the card then shows only what to do in the car (tap the key card, confirm on the screen), above the button, now Waiting for the car … for the 30 s or so the car waits for the key card, then Try again, rather than an explanation and numbered steps at once. The box is unticked again whenever the page leaves the step, as when the car later loses the key.
- After the setup, the Setup card goes, and one Settings card, which had the prices on a card of their own until they joined it, by request, stays on the page below Savings, folded, by request, rather than behind Change prices and Change settings buttons under a Board card: a click on the title opens or folds it, and Cancel folds it, and opening or folding goes back to the board's settings. It has the prices' fields, a line, by request, then Battery and Charging power and the ntfy topic, below the prices by request, another line, Uptime and Version, by request, and Save, Send test message, Pair key, Restart board and Cancel, in that order, by request, as the Board card that had Restart board, Uptime and Version went into Settings, by request, Pair key, red like Restart board, by request, for a key the car lost or never got: renderSetup() moves the car's and the prices' fields, each in a div of its own, between the setup's steps and Settings. The VIN is the setup's only, by request: it belongs to the car and changes only by starting over. Settings the board turned away (Settings: … in Status) open the card, without Cancel. A No grid plan selected card, in amber, by request, under Status, says what the board leaves out without a plan and that Settings, below, has the plans, without the notes' instructions, by request, until the settings have a plan or a custom plan, in countries without plans too. Don't add an expected cost of the schedule ahead (the savings card shows what charging already cost), schedule warnings (they go in the phone message), settings beyond the setup's and Settings', which hold them all, price charts or the range in km.
- Users handle no settings file (root AGENTS.md, Settled), so nothing keeps a hand-written file's other lines: the form writes the whole file, which the board keeps as it is, POSTs it to /settings and fills from GET /settings. Upload custom plan: under Grid plan, where My plan isn't listed is ticked or the country has no plans, below the note, an outlined Upload custom plan button picks a .yaml, .yml or .txt file, in the setup's prices and in Settings alike, named so by request. There's no row for the plan, by request: with a custom plan, Grid plan's list shows Custom: and its
name:, or Custom plan without one, greyed as the list is off, in countries without plans too, where the list shows only for it, and Reset custom plan, in red like Reset savings, by request, takes the place of the box, the notes and Upload custom plan, by request, which drops it and goes back to the country's plans, to choose one or upload another. The form writes the plan undertariff:line for line, each indented by two spaces and an empty line left empty, so the board's errors in it, "custom plan: line 3 …", count the plan's own lines, and reads it back from the lines undertariff:other thanplan:, with My plan isn't listed ticked. It stays when the country changes, and Cancel goes back to the board's. The settings take 4 kB, the plan included, and the board's message names no plan, so the page turns away too long a plan at once, keeps the one before, and says on the card, in sight, to leave out its comments. - ESPHome Web's Visit Device opens the page's own address (improv_serial's
next_urlin device.yaml), at the setup's prices, by request, rather than a QR code on its own at /#qr. The phone's step, after the prices, says above the QR code, by request, that the next steps are easier in the car, then what to do in the order it's done, by request: scan the code first, as it's too late once at the car, unplug the board from the computer, plug it into a phone charger in the garage, or wherever the car charges, by request, as a socket "there" read as one in the car, then sit in the car with the key card, in bold, by request, as the key's step needs the car and the key card, then has the page's address as a QR code, whose link ends in #car, which opens the setup on the phone at the car, by request, and in words below it, without the electricity bill or a reminder about the plan and the contract, by request, and Continue here, as nobody is held to the phone. A phone or a tablet (a coarse pointer) skips the step, by request, as it's already the one to take to the car. qrCode() makes version 3 codes with error correction M, up to 42 bytes; check a change to it against a reference, as python-qrcode gives the same modules for the mask the page picks, and jsQR reads them back. - A new release shows as Update available, the first card, above Status, by request, rather than installing itself: the Firmware update entity (
update/Firmware, release.yaml's, checked once each time the app opens, as the page presses release.yaml's Check for update on its first connection, by request, never at the start, on a timer or on reconnecting) is at UPDATE AVAILABLE, with the release in its value. The card names it and the release the board runs (Release,text_sensor/Release), by request, and its what's new opens GitHub's releases in a new tab, the newest first, each with its notes, to scroll down to the one the board runs, by request, rather than a comparison of the two, which showed the code. In the setup too, above its card, by request, so a board installed from an older release can update before it's set up. Later, an outlined button under Update, hides the card until the page loads afresh, by request. Update installs it without asking first, as the card says what it does; the page then shows Updating … the way it shows Restarting …, and says the update didn't install if the entity reports anything but INSTALLING: UPDATE AVAILABLE when the download fails, UNKNOWN or NO UPDATE when it never started, as on a board that had just restarted and not checked yet. It closes the stream before loading afresh, as the states that follow the board's first ping would say so too. A custom build without release.yaml'supdate:never shows it. - After a save that restarts the board, Restart board or Update, the page shows only the card with Status, with Restarting … (Updating … after Update) in Status, by request, rather than No connection and the old values, and loads afresh once the board is back: it knows from the uptime in ESPHome's
pingevents, sent on connecting and every 10 s, which is then shorter than the time since the restart was asked for. An ESPHome without it there would leave the page at Restarting …: check after upgrading ESPHome. - Status shows only the status, on the left, without its name, by request, and a question mark like a field's on the right, by request, but a link: it opens docs/status.md on GitHub at the status's own line, with a text fragment (
#:~:text=), so each line there starts with the status in bold and a colon, and a new status needs one; Charges at … and Settings: … go to their lines' fixed forms. Browsers scroll to it once the page shows. - Until the board's settings are in, the page shows no cards (the
loadingclass), as they decide between the setup and the rest, by request: no flash of the wrong cards on a refresh. A read that fails tries again 5 s later while the board is there. The page shows only the card with Status (thealoneclass) while the board restarts, while it has no link to the board (No connection), settings in or not, and, once they're in, until the board's status comes after connecting (Connecting …), by request, so the cards come together and Settings isn't there to change while nothing could save it. - Fields take only sensible values, by request, which the browser checks at Continue and Save, and which a number field shows under it in red, as From 20 to 200 kWh, as soon as it holds another, by request, as Safari on a phone may not show the browser's message at all: Battery 20 to 200 kWh, Charging power 1 to 22 kW (home charging, up to an older Model S's 22 kW), and a supplier's margin or part up to about a euro per kWh in its currency (
EURO), which turns away cents typed for euros. Save checks only Settings' shown fields. The board's own checks stay wider. - Phone messages: the ntfy topic's field has a button inside at its end, a renew icon, always there, by request, which fills it with tesla- and 20 random letters and digits, from 32 so each random byte picks one evenly, a new topic at each press, and copies it, to paste in the ntfy app on the same phone, rather than a QR code; a question mark by its name has what the button does and how messages come, rather than a note always shown, by request. The field takes only what a topic can hold as it's typed or pasted, letters, digits, - and _, at most 64, by request, as the VIN's does (keepOnly()). Send test message, outlined, under Save, by request, sends a message to the field's topic, saved or not, from the page straight to ntfy.sh, in the board's format, so the app can be checked before saving. The page is plain HTTP, without the clipboard API, so it copies the field's selected text with execCommand.
- The look follows ESPHome Web (web.esphome.io), as the owner asked: its colors (#009ac7, its error red #e74c3c and warning orange #f39c12, and its light and dark backgrounds), white cards with a thin border and 12px corners, titles in sentence case, fields and buttons with 6px corners (primary ones filled, others outlined), checkboxes of 24px, 14px from their words, for a finger, by request, a button right after a note 22px below it, as far as below a row, in every card, by request, a hint's question mark, which replaced an i, in a small rounded square, as a circle sat badly by the fields' corners, the mark and its border both in the fields' border grey, and its words with a thin line in the fields' border grey at their left, 3px short of their top and bottom, by request, and system fonts. Its header bar too, in the same blue in light and dark, and sticky at the top, by request: the owner's logo, a calendar with a plug in a circle, white with its details in the bar's blue (currentColor) as ESPHome's logo is, as tall as the pills, by request; the page's icon, in a browser tab and on a home screen, is that logo on the bar's blue, by request, a PNG drawn from it as the page starts (addIcon()), as an iPhone's home screen takes no SVG; and on the right pills, by request, rather than the project's name, a subtitle and the link to the board, which Status shows (Connecting …, No connection, Restarting …, Updating …): each with its icon (Material's) and filled while the page has the board, the board's Wi-Fi and Bluetooth (its link to the car) signals in dBm, with the unit, by request, which went for a while and came back, and last, by request, the car's battery level, which Status no longer shows. A signal turns ESPHome Web's warning orange when it's weak (Wi-Fi below -75 dBm, Bluetooth below -85), as the battery does below 20%, by request, and a moon follows Bluetooth's while the car sleeps (the Tesla part's Asleep), by request, as it explains why the battery level or a start takes a moment, and a plug follows the battery's while the car is plugged in (the Tesla part's Charger), by request, kept while the car's charging state is Unknown, which reads the Charger as OFF, as the controller does. A pill without data turns a darker red than ESPHome Web's error red (#c0392b), by request, once the page has the board's settings or has lost the board, so none flashes red as the page opens.
- A choice shows or hides only the fields below it, never above, by request: so the prices go Country / Area, Grid plan (whose list is the country's), Contract type, then its margin or supplier's part (which goes on top of the plan's fees), each field depending only on those above it.
- Text: no supplier's or grid operator's names as examples, as the page and the docs serve every country; the plan list's names are data, and a country's README.md in plans/ names its operator; a plain hyphen with spaces in time ranges, a space before an ellipsis ("Getting prices …"), and no en or em dashes.
