Skip to content

feat(web): the box page shows the owner's cars - #1694

Open
frahlg wants to merge 3 commits into
masterfrom
claude/box-page-cars
Open

frahlg wants to merge 3 commits into
masterfrom
claude/box-page-cars

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Step 5 of the car design (design/vehicles/car-entity-2026-10-15.md): the box's own page shows the owner's cars.

Fredrik, 17 October 2026: "jag ser ju inte min bil någonstans". Core has known the car since #1647, #1650 and #1660, and GET /api/cars on his box lists the Tesla. But no file in web/ read /api/cars. The dashboard's car parts read the loadpoint, which knows no car while the cable is out.

What the owner sees

Overview, a card per car, plugged in or not.

  • Name, then the level with its source and age: "72% · From the car · 1 h ago". An estimate gets ≈ and the energy since the reading ("≈ 75% · From the car · 1 h ago + 2.4 kWh"). A level set by hand says "Set by hand", and an assumed one says "Assumed at plug-in".

  • The age counts on between reads, from soc_age_s plus the time since the read. If the list stops updating, the card says "Not updated since 11:08". Nothing is shown as live.

  • Charge limit, goal ("Goal 80% by 07:00, every day") and "Solar only".

  • The charger the car is on, and how the box knows ("The car said so", "The only car", "You chose it", plus "not XC40" after a correction). "Check charging" opens the existing EV modal for that charger.

  • While a charger asks, a car it may be shows "On garage?" rather than "Not plugged in".

  • The place comes from the chargers' own read. If that read is older than 15 s while the cars still answer, or the charger has gone quiet, the card says "Charger status unknown" instead of the last place it knew.

  • The charge limit shows only beside a level that came from the car, whose age it shares.

Which car. When a charger asks (car_identified_by: asking), its notice reads "Which car is on garage?" with one button per car in choose and "None of these". A tap sends POST /api/loadpoints/{id}/car with the plug-in's session and gives up after 30 s, as the EV modal's writes do. A refusal shows in words ("That plug-in has ended."). On a LAN device that has not signed in, the page's own fetch asks for the house password and sends the tap again. If the person cancels, the note says choosing the car needs the house password. Once the box has named a car, the notice says "XC40 connected", and "Not this car?" opens the same choice, because a tap may correct the box (docs/cars.md). The buttons are rebuilt only when what they show changes, so the 2 s poll never pulls one away mid-tap.

Settings → Cars. Each car shows its VIN, make, level sources ("Level from tesla-ha") and charger tags (or "Hidden until this device signs in" when the box left them out), and has a "Made by this box" note for a box-minted car. You can rename a car, set its battery size, add one (the page mints the 128-bit id, as the app does) and remove one, with a confirm step. The tab writes through the car routes, never /api/config, and the Save button hides on it.

The EV plan, goal and charge-now controls stay in the EV modal, as they were. Moving them under the car would have meant splitting the modal, which is not simple, so the car opens it instead. No new settings.

Core: the smallest routes the page needs

The car ops were cmd frames only. Each op now has a route that reads its body with the op's own argument checks (appproto.CarAddArgs, CarUpdateArgs) and passes it to the same runtime. A refusal comes back with the op's code and reason (appproto.CarRefusal, now shared):

Route Op Tier
POST /api/cars car.add Local, needs ftw.cars.write
PATCH /api/cars/{id} car.update Local, needs ftw.cars.write
DELETE /api/cars/{id} car.remove Local, needs ftw.cars.write
POST /api/loadpoints/{id}/car session.car.set Actuate, Via(session.car.set)

The routes that keep the list are Local: a phone keeps sending the ops as cmd frames, with their id and expiry, and the session refuses these routes. The tap is priced like POST /api/loadpoints/{id}/soc: the session tells a phone to send the op.

GET /api/cars no longer hands out VINs to anyone who asks. The LAN listener makes every caller an owner while the house password is off, so a box published under a public name without FTW_API_TOKEN used to return VINs and charge tags. handleCars now gives every field only to a request that has proved itself as a protected one must: a local address, loopback, the house password, the API token on a public name, or an owner's app session. Everyone else gets the viewer shape with redacted: true. Reading the list itself stays open, so a viewer still sees each car's level.

Found on the way

Adding a car whose VIN a box-minted car already has is how the owner's app takes that car over. Core then gives it the new id, name and size. On the box page that quietly turned "Model Y" into "Twin, 60 kWh" while saying "Added Twin." The tab now stops that and names the car that has the VIN. Core's behaviour is unchanged.

Evidence

  • go test ./internal/api ./internal/appproto: api_cars_test.go runs the routes against a real cars.Runtime and loadpoint manager. It covers add, a duplicate VIN, a bad id, update, a refused size, an unknown car, remove twice, the tombstone, a tap with no car, a tap for an ended plug-in, a tap, None of these, an unknown charger and the route tiers. The argument checks: a negative size, a body that is not an object, a non-text identifier, a null identifiers clearing the list as the op does, and the path naming the car. Who may write: a viewer phone, a watch and an agent get 403, and the owner's phone gets 200. With the house password on, a LAN write without it gets 401, from another site 403, and with it 200. Who sees the VIN: a public name without the token, a LAN device without the house password, a watch, an agent and a viewer get the short list; the token, the LAN, loopback, the house password and the owner's phone get every field. The VIN test fails without the fix.
  • npm test: 798 pass. New: web/cars.test.mjs (28) and web/settings/tabs/cars.test.mjs (11). dashboard-poll-visibility.test.mjs now counts the fourth GET in the status poll.
  • make verify: clean.
  • Browser check against a local Core (this branch, built from source) with two simulated OCPP 1.6 chargers (sim-ocpp), sim-sungrow as the site meter, and a rig-only standalone car source that reports 72% read an hour before start, with an 80% limit. Through the page I added a car, renamed one, tried a duplicate VIN, removed one, tapped a car on each charger, and opened the EV modal from a car. The modal showed the tapped car's 76 kWh and its goal. The home box was not touched.

Screenshots: desktop from headless Chrome at 1280 px; phone from the app's browser at 375 px (2×), where the page has no sideways scroll. The app's browser pane was hidden, so for those shots document.hidden was overridden to false. Otherwise the page marks its data out of date, as it should when hidden.

1. No car plugged in. Model Y has its own reading from an hour ago; the other two have no level.

Dark Light
Phone, dark Phone, light

2. One car plugged in (XC40 on carport, tapped), and a charger asking between two cars (garage: Model Y or EV6).

Dark Light
Phone, dark Phone, dark Phone, light Phone, light

3. Settings → Cars.

Dark Light

For the owner

  • Changeset minor: a visible new capability. It needs Fredrik's yes here.
  • Not built: binding a level source to a car by choice on this page. Today a source binds by VIN, or to the only car. With two cars and one Home Assistant source, the VIN is the way.
  • The rule that a reading more than an hour older than the plug-in does not start the session is Core's, from feat(cars): the car apart from the charger, in Core #1650. On the page it shows as "≈ 30% · Assumed at plug-in" for a car whose last reading was 72% an hour before. That is correct, but an owner may ask about it.
  • Open PR feat(web): the Access tab names the car and charger in a request #1684 also touches web/index.html (Access tab script). The hunks do not overlap.

🤖 Generated with Claude Code

frahlg and others added 2 commits October 17, 2026 11:26
The car ops were cmd frames only, so the box page, which speaks HTTP,
could list cars but not keep them or answer which car is on a charger.
Each op now has a route that hands the same arguments to the same
runtime and answers with the same code and reason:

- POST /api/cars is car.add, PATCH /api/cars/{id} car.update and
  DELETE /api/cars/{id} car.remove. Configure, no step-up, and only a
  caller with ftw.cars.write.
- POST /api/loadpoints/{id}/car is session.car.set, the tap, with
  {session, car_id}. Actuate, Via the op, so a phone is told to send it.

appproto.CarRefusal is the refusal table both use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The overview gets a card per car, plugged in or not: its level with
where it came from and how old it is, an estimate marked with ≈, its
charge limit, its goal and the charger it is on, with how the box knows.
The level ages between reads, and a list that stopped updating says
since when. While a charger asks which car it is, its notice offers one
button per car and None of these, and the cars it may be say "On
garage?" rather than "Not plugged in". A car the box named itself can
be corrected behind "Not this car?". The buttons are rebuilt only when
what they show changes, so a poll never takes one from under a tap.

Settings gets a Cars tab: each car with its VIN, level sources and
charger tags; rename, battery size, add and remove through the car
routes. Adding a VIN a car already has is stopped on the page: the box
would take that as the app adopting the car it made, and quietly
rename it.

The EV modal stays as it was; a car on a charger opens it with "Check
charging".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T09:33:07.322135Z ccdc740 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ccdc7403e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/app.js
Comment thread web/cars.js
Comment on lines +150 to +152
function update(lp, cars, loadpoints, fresh) {
last = [lp, cars, loadpoints, fresh];
const list = fresh ? choices(lp, cars, loadpoints) : [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear tap feedback when the charging session changes

If a tap is pending while the car is unplugged and quickly replugged, this row can be updated to the new session before the old request completes; the old response then writes “That plug-in has ended” into the note and renders it beside the new session. The same stale refusal persists across later polls because update never clears the note when lp.session changes. Reset feedback when a different session ID arrives so failures remain associated with the request that produced them.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

…home

From the review of #1694:

- GET /api/cars gives VINs, charge tags and sources only to a request
  that has proved itself as a protected one must. The LAN listener names
  every caller an owner while the house password is off, so a box
  published under a public name without FTW_API_TOKEN handed them to
  anyone. Others get the viewer shape and redacted: true.
- POST/PATCH/DELETE /api/cars are Local: a phone keeps sending the ops as
  cmd frames, with their id and expiry.
- The routes read their bodies with the ops' own argument checks
  (appproto.CarAddArgs, CarUpdateArgs): a negative size is refused, and a
  null identifiers clears the list as car.update does.
- The card dates the chargers' read apart from the cars': an old read, or
  a charger gone quiet, says "Charger status unknown" rather than the last
  place. The charge limit shows only beside the car's own reading.
- A tap gives up after 30 s, and a 401 says the house password is needed.
- The Cars tab says hidden, not "Not set", when the box left a field out.
- docs/cars.md names the routes, the page and who sees the VIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

frahlg commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Checked the current head db790a0a rather than relying on the unresolved review markers. The charger-freshness point is addressed: the two reads have separate timestamps and the card receives chargersFresh; all 28 web/cars.test.mjs tests pass. That old thread can resolve.

The session-feedback finding still reproduces independently: start a car-choice tap for session A, update the row to session B before the request returns, then finish A with That plug-in has ended.. The note displays A's refusal beside B and persists across polls. A local assertion against an empty note fails on this head.

Reset the note/busy/expanded state when the session changes, and compare the captured session after await onTap before accepting its result; clearing the note only during update would still allow the late response to restore it. Add that delayed-response regression. Keep the PR open for this fix and the existing human UI review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant