b2d333a63f009d85513e576cfca5123c11ade9d5
76
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b2d333a63f |
The charger was never asked what it is set to
The trigger buys telemetry and only telemetry, so a charger that has been read a hundred times and commanded none reports amps, volts and nothing else: no schedule, no balancing, no Modbus server, not even its firmware. The message that asks for that half is 0040, and the reference keeps it commented out because the app sends its timestamp without a value type. The app is what the charger answers, so the oddity is reproduced rather than corrected — sent when the settings half is missing or older than ten minutes, waited four seconds for, and after three unanswered requests still sent but no longer waited on. The three settings the panel has and the writer did not — swipe up, swipe down, smart touch — are writable now, which is all eleven of the 0100 commands. Nothing else in the map was missing: every named field of every message was already decoded, and the raw keys the card shows are fields the reference does not name either. Both cards drop a row with nothing in it, which turned a charger that reports only its ceiling into a charger that reports no current range at all. Half a range is still a bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3f9d5b943f |
Four questions asked of an account that answers twenty-one
The connector called ten endpoints of the read surface the map lists, and the charger card showed four views. Everything else an EV charger can reach is now a capability too: the sessions and the history, the savings, the sharing, the binding, the group, the Wi-Fi, the firmware and its update log, the tamper records, the site's own detail, price, networks and energy — plus the vehicle catalogue, dynamic pricing, the currencies and the notification views. Thirty-eight endpoints, one action each. The two message views are GET, so the request path grew a GET half that shares the login retry with the POST one. The per-charger fan-out asks all of them, six at a time rather than one after another, and a charger that belongs to a site brings that site's four views with it once the by-serial lookup has found it. A view that answers with nothing now says so instead of vanishing: the station record is empty for a standalone charger because it has no station, which is an answer worth reading. And "source 0" in the OCPP box carries the address the account's endpoint list gives it. Anker's account-level writes stay out, as do the endpoints whose payloads were only ever read out of the app package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fb42791f8d |
The card beside it had boxes, so this one gets boxes
Charger information was one long list of everything the service knows; the readings card next to it had been splitting its fields into a box per group all along. Same treatment here: Device, Status, Network and On the account, plus the service's own fields and the per-charger views, each in its own sunken section under a heading. Both clients, since the web and the phone draw the same card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a7f04cba0 |
A dash printed above the value it was missing
The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.
The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.
One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.
Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.
Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.
Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8e2073fc4c |
The map knew the names the card was showing as hex
Every field in the cloud MQTT map that has a documented meaning now reads as a named row, on the same labels the register map uses for the same quantities. The raw block stays, and shrinks to what genuinely nobody has identified — which is the only honest reason for a key like 0410.b9 to be on screen at all. Three fields the reference decodes for nobody are decoded here. ac is where the charge is coming from — off or paused, grid, solar — and it is called chargingSource rather than chargingMode, because that name already belongs to a Modbus register and the last time a cloud field borrowed one, d9 spent a release reporting the wrong thing under the right name. b6 is the session's order id. f1, f2 and f3 are the identity fields the reference marks multi-value: four bytes read as the parts of a version, in the order they arrive, which is what the account view's own firmware string looks like. If the panel shows those parts reversed, the order is the thing to flip — it is the one assumption here that the wire has not yet confirmed. The rest was already decoded and simply never drawn. The readings card now shows the session's start, its id, the charging source, whether a cable is in, the charging window, and — since a reading is worth what its age is — the live-stream flag and both stream clocks, because telemetry and settings arrive on different messages with different triggers. Per-phase session energy joins the phase matrix as a fourth column, appearing on the transport that counts a session and staying away from the one that does not, exactly as the reactive and apparent pair does. The settings block gains the fourteen the register map has no address for: plug lock, auto restart, random delay, the schedule and its mode, the weekend window and how the weekend is handled, the light-off schedule and window, the breaker limit, the solar mode and its minimum current, automatic phase switching, the three panel gestures, and what the two balancing features are watching — the meter and monitor serials by name, their two unpinned numbers as the numbers they are. A local network block says whether the charger's own Modbus server is on and where, which is the answer the Modbus mode's setup screen otherwise has to be given by hand. The device block gains the controller version. A test now holds the line the projection quietly drew: every name in the message maps must reach a snapshot field. A name added to a map without a field to land in would otherwise surface in the raw block looking like something we understood. Left raw: a1, the frame opener the charger echoes back; b7, which the map itself calls unidentified; b9, bc and bd, which appear in no map; the five-minute 0400; and 0857 — a message type the reference's closed inventory of fourteen does not contain and this charger publishes anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
197ff73a39 |
The one card with a button is the one with a crooked arrow
Four of the charging cards fold from a header that is a single button: the title at the left, the arrow hard against the right edge. The fifth has a refresh button in its header, and that button was placed after the toggle — so the arrow ended up a button's width in from the edge, alone among the five, and the eye finds it by searching rather than by knowing where it is. The header now spends its width the way the others do. The heading keeps the title and stays the drag handle, the refresh button takes the place beside the edge, and the arrow is its own control at the end of the row. It folds the card exactly as the heading does, so nothing that worked before stops working; only the order changed. The same layout lives in the Phone App's _FoldCard, with the same fault, so the arrow moves past the action slot there too. All five cards share that widget and only this one passes an action, which is why the other four look identical before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2425a8d3d6 |
A field we have no name for is still a field it sent
Two cards on the Charging page were answering with a fraction of what the charger and the account actually report, and in both the losses happened quietly, in a parse that kept the fields it recognised and dropped the rest on the floor. Charger information asked three account-wide views and kept fourteen fields. A charger registered on its own is absent from the site view, which is the only one of the three carrying state, charge power and OCPP status — so exactly the charger that stands alone got the column of dashes, and nothing said why. The per-charger station record, get_evcharger_station_info, is what the mobile app opens when you tap a charger, and it is the one view that answers for a charger outside a station; it is now the fourth view, asked per charger, a failure there costing that charger's row and no more. Alongside it, every field each of the four views sent is kept as attrs, under the cloud's own key, nested objects joined with a dot and arrays carrying their index. First view to answer a key wins, which is the rule the named fields already merged by. Two hundred keys and two hundred and forty runes per value keep a station record with a session list from becoming the whole card. Charger readings lost data twice over. The frame decoder skipped any field byte its per-message map could not name, and a message type with no map decoded to nothing at all; those fields are now kept under the message and the byte they arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a meaning and we do not have the other half. Then the projection read forty-odd names into typed fields and dropped the remainder: sessionStartedAt, the per-phase session energies, the three touch modes, the load-balance monitor and its meter flag, the solar monitor. Those land in extra, and the list maintains itself — the four accessors note every key they read, extra is what is left, and a field modelled later stops appearing there without anyone remembering to remove it. Keeping unnamed fields had one consequence worth guarding. An unmapped message now decodes to something rather than nothing, and ingest stamped settingsAt for anything that was not telemetry — the timestamp a control command waits on to say the charger acknowledged it. A frame we cannot read is not an acknowledgement, so the stamp is now conditional on the message type being one we map, while its fields are kept either way. Both cards show the remainder as what it is: the service's own key, no unit, no translation, no renaming, under a heading that says whose words these are. The blocks appear only when there is something in them, so a Modbus charger's readings card and a charger the cloud says nothing more about are unchanged. Naming one of these fields is a later commit, made from evidence; inventing a label for it today would only make a guess look settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1dc20461de |
A raw key, a bare number, and two columns of dashes
Switch the control mode to the Anker cloud and the charging cards still read as though they were built for Modbus. Three separate causes, and none of them was the shared layout — the cards already branch on the control mode in sixteen places, which is why the address fields, the reset button and the skip-delay button each appear only under the transport that has them. The loudest was a missing string. The cloud transport arrived with three keys the templates call and the language files never got: cloudNote, cloudLocalFound and skipDelay. A key missing from English returns itself, deliberately, so that a gap shows up in the UI instead of rendering as a blank — and it did, as "charging.control.cloudNote" sitting in the connection card where a sentence belongs. All three are added, in both surfaces and all three languages, so the Phone App is not left showing the same raw key. The second was an enum wearing one name over two transports. Modbus register 20087 reports the phase mode as 1 single-phase or 3 three-phase; the cloud's own field reports 0 automatic or 1 single-phase. Only phaseMode1 and phaseMode3 had labels, so a cloud charger sitting on automatic rendered "Running on 0" — the enum fell back to printing the number, which is the right fallback and the wrong answer. phaseMode0 is added. Worth naming the shape of this one: it is the same collision that made the cloud's d9 field wrong when it was called chargingMode after the register at 20088, and a third transport reporting a 3 that means something else would break it again. The third was honest but useless. Reactive and apparent power are registers of their own and the cloud has no message carrying either, so over that transport those two columns could only ever be three dashes each. They now appear when the charger actually reports them, which also tidies up a Modbus charger whose firmware leaves them out. The layout stays shared. A value both transports report should keep one name and one row, and what each transport can be told still branches where it has to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
576df58776 |
Go the way the owner's phone already goes
Control had two transports and neither fitted the ordinary customer. OCPP waits for the charger to dial in, which needs a public endpoint it can reach, a certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the charger, which needs the server on the charger's own network. Between them they cover a charger we host and a charger we stand next to; the common case is a charger behind someone else's router, and that had nothing. It was never unreachable, though. The charger holds a connection open to Anker's own broker — it is how the mobile app drives it from anywhere, and it is the mqttStatus register the Modbus snapshot has been reporting all along. So a third control mode joins that broker as the account: get_user_mqtt_info issues a client certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same topics the app publishes on. Nothing on the customer's side has to be forwarded, addressed or certificated. What travels is not an API call. The payload is a JSON envelope around a base64 binary frame the device itself speaks — marker, little-endian length, message type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec rather than a client, written from the message maps in anker-solix-api and anchored on the one frame that project documents byte for byte. A frame whose fields do not tile exactly up to the checksum is refused rather than half-read: these arrive over a link we do not control, and a truncated frame must not read as a charger reporting zeros. Two of the charger's habits shape the rest. It publishes nothing unless asked, so a status read arms a telemetry trigger and waits for the next frame, and a poll inside that window answers from what has since arrived. And a broker connection costs a fetched certificate and a TLS handshake while the plugin manager builds a throwaway instance per request — so the connection lives on the account's shared session beside the auth token, for exactly the reason the token lives there, and closes itself after five idle minutes. The transport also sees two signals no other one does: the boost flag, and the plug and start countdowns. The package doc has said since the first commit that they are never set and the derived mode must do without them. Here they are set, so a charger that has been told to start and is counting down a delay says so rather than sitting in "preparing", and "skip the delay" is offered only while there is a delay to skip. The clients generalise instead of growing a second layout. Both snapshots name the same quantities the same way, so what was Modbus-only in the readouts is now whichever transport read the charger — ModbusStatus becomes ChargerStatus on the phone, mb becomes dev on the web. What each transport can be *told* still differs, and the buttons branch on that: reset and clear-limit stay with OCPP, the timeout and phase registers with Modbus, skip-delay with the cloud. A command a transport has no equivalent for is refused by name, saying which one has it. The cost is worth saying plainly. This leans on Anker's cloud being up and on an unofficial protocol the app may change under us, where Modbus leans on nothing but the LAN. And it is checked against the reference implementation's own worked example rather than against hardware — there is no charger on this end to point it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b27ca3ee19 |
The two cards stop keeping numbers from each other
Charger control and Charger readings each held a reading the other did not. Control's tiles showed the charger's operating state and the session's energy, which appeared nowhere in the readout that claims to be the whole snapshot; readings had the total power and the session length, which are the two numbers you actually look at after pressing start to see whether anything happened. Both gaps close. The control card grows two tiles — total power (20068) and session length (20082) — laid out two by two beside the connector state and the energy, and they use the readings card's own labels and formatting so the wording cannot drift apart. They appear only when the charger reports them: the OCPP status carries neither, so on that path the card keeps its original two tiles rather than showing a pair of dashes. The readings card grows the other two: the charging status (20097) leads the live block, since what the charger is doing is what the block is about, and the session energy (20084) follows the session length it belongs beside. Nothing is exclusive to one card any more, which is the point — a number that can only be read in the card that acts on it is a number you have to go looking for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e02855173a |
One register, one slider
The settings card took over the charger's current ceiling last commit and the control card kept its own slider for it, so the same register had two controls a card apart, each showing whatever it was last dragged to rather than what the charger holds. The control card gives it up on the local path. What is left there is what the card is named for: start, stop, boost — things done to the session in front of you, not settings the charger keeps. It stays on the OCPP path, where there is nothing to hand it to. A charging profile is not a setting the charger reports back, so there is no settings card on that side of the page, and the clear button belongs to it — clearing is an OCPP command the register map has no equivalent for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
65a5c67afc |
The settings card sets things
The card added last commit showed the charger's settings and did nothing with them, which for a page whose whole point is acting on the charger is half a card. It also took the settings block out of the readings card to do it, so reading the charger top to bottom now had a hole in it. The readings card is whole again — phases, live data, settings, device, alarms, exactly as before. What the settings card holds is the same values with controls on them. Which values get a control is the register map's decision, not a design one. Six holding registers are writable, and four of them are settings: the current ceiling, boost, the timeout and the phase count. Charging mode, the two balancing flags and the LED brightness sit in the measurement block, which the charger reports over FC04 and does not accept writes on — they are set in the Anker app. So the card is in two halves and says which is which, rather than offering a control that would quietly do nothing. The limits come from the same places the server's own checks do: the slider floors at 6 A because below that the charger pauses instead of charging slowly and ModbusSetMaxCurrent refuses it, its ceiling is the charger's reported rating, and the timeout floors just above the spec's "more than five seconds". Phase and boost write on the change itself, having one value each; current and timeout are typed, so they wait for Apply. Every write goes through the existing control action, so it is gated, rate limited and audited like the buttons in the control card, and the card reseeds from the snapshot afterwards — the charger clamps what it is given, and the form should show what it took rather than what was asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e1c063d4b1 |
What the charger is set to, where the setting is made
The nine values the charger reports back about itself — the current limit, the timeout, the phase setting, whether boost took, the last command it accepted, the charging mode, the two balancing flags, the LED — sat fourth in the readings card, under a phase table and a live-data block. They are the readback of what the control card's buttons just wrote, so they were being read straight after pressing something, at the bottom of the longest card on the page. They get their own card, directly under control in the default order, and the readings card keeps what it is for: what the charger is doing right now, what it is, and any alarm. Nothing is shown twice. The card folds and drags by its heading like the other four, on the same chargerCardOrder. A column somebody has already arranged doesn't mention this key, so there it arrives at the end rather than in the middle of a layout that was chosen — the rule a tab added in a later release already follows — and one drag settles it. It appears on the Modbus path only, because that is the only transport that reports a settings snapshot at all; the OCPP one has nothing to put in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6877d311ea |
One word was answering two questions
The charging page carries two badges about the same charger, a card apart. The connection card asks whether this server can reach the charger to control it — in Modbus mode a live dial, every status call. The information card asks what the connected service says about it. Both said "Offline" for no, in all three languages, so a charger the service can see while its saved local address has gone stale reads as a page contradicting itself. It isn't: the charger talks to the vendor's cloud over its own uplink, and Modbus is a local path that answers only from the network the charger is actually on. The connection badge now says "Not connected", which is what it was measuring all along and pairs with the "Connected" it already used. Online/Offline stays with the service, where it is the service's word. Both apps show this badge from the same key, so both files change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f3235c403c |
A page opens where you put its tabs
Every tab bar in the app drags into the order you want, and then all three of them opened on a tab picked in the source anyway: "public" on Charging, "info" on a car, "personal" in Settings. Dragging Home chargers to the front of the charging bar rearranged the bar and changed nothing about where the page landed, which is the opposite of what dragging it there says. So the front of the bar is now the landing tab, everywhere. An arrangement is already the statement of what you want to see first; it just wasn't being read as one. Settings > Appearance overrides it per page for the case where reading order and landing tab are two different wishes, with "First in the bar" as the default and the meaning of no override at all. The rule lives in one place, lib/tabs.js, because it is one rule and three pages: the saved choice if that tab is actually on the bar, otherwise whatever leads it. The bar it is given is the one that will really render, hidden tabs and inapplicable ones already dropped, so a default that no longer has a button - a tab switched off for that car, Users on a non-admin - falls back to the front instead of opening nothing. The tab key lists moved there too, since the picker needs all three and would otherwise have copied them. Each page starts on no tab and keeps following the profile until the user says otherwise, rather than guessing and then correcting itself: the arrangement and the default both arrive with /api/me, which on a hard refresh lands after the view has mounted. A click ends the following, and so does the start of a drag - rearranging a bar must not pull the content out from under the pointer. In Settings ?tab= still wins over both, since that is what /admin redirects to. Stored as defaultTabs on the profile, one page->tab map validated per page: a tab that exists but on another page is an error, and an empty value is stored as an absent key so "no default" has a single representation. Also adds charger_tab_order and charger_card_order to the PocketBase setup script. They were never there - the arrangements of the last two commits had no column to persist into on a freshly set-up server - and default_tabs would have gone the same way beside them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c64d6e84c |
The cards drag too, held by their headings
Same arrangement the provider panel gives its readings, applied to the four charging cards: drag one and the column reorders live under the pointer, the card being dragged goes half-transparent, the one it is over takes a ring, and the rail's lock holds the lot still. Two things differ from the readings row, both because these are four different things rather than four of one. A card is placed with the CSS order property instead of by moving markup, so each keeps its own template and its own v-if. And the handle is the card's heading rather than the whole card — a card that was draggable everywhere would fight the current-limit slider and the address fields for the pointer. Saved on the profile as charger_card_order, beside the tab order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1418a566fd |
The charging tabs drag, like a car's do
Reordering the bar meant editing the template, which is a poor way to ask for Home chargers first. The tabs now drag into either order on the same native drag events as a car's tabs and the garage, down to the live reorder as the pointer crosses a tab, the grab cursor, and the rail's lock holding the bar still for anyone who would rather not nudge it on the way to a tab. The arrangement is saved on the profile as charger_tab_order, beside the garage order and for the same reason: it is a layout choice that should follow the account rather than the browser, unlike which cards are folded. The field is reconciled onto the users collection at boot, so no migration step. Its normalizer is the garage's, which now takes the field name and cap as arguments instead of being copied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2a5d21d328 |
Controls first, because that is what the page is opened for
The column opened with the charger picker and its address — the two things touched once and then never again — and put the start button below them. Order reversed: control, then connection, then readings, then information. The comments on both cards described the old order, so they move with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b48019b08f |
Four cards is three too many to scroll past
The charging column grew a card at a time and now runs well past a screen, so each of the four folds away: connection, control, readings, information. The mechanism is the provider panel's, down to the chevron and the localStorage key — folding is a reading habit of this browser, not an account setting, and one learned gesture should work in both places. The connected badge stays in the connection header while it is folded, since whether the charger is reachable is the thing worth seeing without opening anything. Charger information keeps its Refresh button beside the toggle rather than inside it, a button being no place for another button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcc44da735 |
Setting up the connection is not the same as using it
The control card opened with the parts that get touched once — which charger, and where it lives on the network — and only then reached what gets used every day. Split in two: a connection card holding the picker, the address and the connected badge, then a control card holding the tiles and the buttons. The not-connected hint and the error line move up with the connection, since that is what they are about, and the control card simply does not appear until there is a connection to control over. That also retires the inner template that repeated the card's own condition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5547daa689 |
The readings get their own card
The register data was appended to the control card, which turned a card for acting on the charger into a long scroll with the buttons at the top of it. It is a separate concern and now a separate card: control above, readings below, alongside the charger information card that was already there. Its title says readings rather than information, since the card below it holds what the record knows about the charger while this one holds what the charger itself just said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62cb691f98 |
Everything the register map carries, sorted the way it gets asked about
The Modbus snapshot reported about half of what one poll already brings back. The rest was read into the block and thrown away: line-to-line voltages, reactive and apparent power per phase, the PWM flag, the control-pilot voltage, and the identity block's product number, rated power and current range. All of it now decodes — no extra requests, the registers were in hand already. Added alongside it: the control block, read back over FC03. It answers a question the live registers cannot, which is what the charger is *set* to as opposed to what it is doing — a boost that was asked for reads there while the live block still reports none running. Best effort, so a charger that refuses it still reports its state. Two registers the spec leaves blank are decoded on the hardware's evidence. The control-pilot voltage reads 11873 while the CP signal register reports state A, which that enum names as 12 V, so the register is millivolts. The identity block's current range is in amps, whatever its unit column says about watts and kVA. The charging card lays this out in sections rather than a wall of forty numbers: per-phase measurements as the matrix they are, then live state, then settings, then the device itself, with alarms surfacing only when a word is non-zero. Strings in en/da/pl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f025dc100f |
The local mode, offered by the menu that picks the mode
Modbus TCP has been a working control path since the server learned to dial the charger, and the Charging page has asked for the address it needs — but the Control mode selector never listed it. The mode could be reached only by writing it through the API, which is to say not at all. Both selectors now offer it, and both stop offering what it does not use: the OCPP provisioning card and its "Use this one" button are gated on the modes where the charger dials us, and Modbus gets a line saying where its address is asked for instead. On the phone that gate is more than tidiness — the field is a DropdownButtonFormField, so a mode saved from the web left it holding a value none of its items matched. Strings added in en/da/pl for both apps. The mode hint no longer says control happens "over OCPP", because it no longer always does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1aedc0ddc7 |
A serial the account does not list is still a serial worth showing
The dropdown could only show a serial it had an option for. A remembered one the account does not report — a charger imported before the account was linked, one the cloud is quiet about today — selected nothing, so the field sat blank while that serial was the one every command went to. Nothing on screen said which charger was being driven, or let it be corrected. The field now falls back to the text box in that case, the way it already does when the account lists no chargers at all, and shows the serial actually in force. One link switches between picking and typing, so a serial off the list is not a dead end and the list is not the only way in; coming back to it lands on a charger the list holds rather than blanking the dropdown again. Which of the two is showing is decided when the list arrives, not on every keystroke — recomputing it as the serial is typed would turn the text box into a dropdown mid-word, the moment what had been typed happened to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ccd785742 |
The charger's address, asked for where control is set up
Modbus mode had no way in from the app: the mode could be picked in Settings, but the address the server dials had to be PUT by hand. The control card now asks for it in the place the charger is already being controlled from. Which provisioning the card shows follows the mode, because the two are not alternatives to each other — OCPP installs a token into the charger, Modbus records where the charger is. So does what the card offers: boost is a Modbus command and appears there, clear-limit and reset are OCPP ones the register map has no equivalent for and stay behind. The status tiles read whichever snapshot arrived. An OCPP session counts a meter in Wh and names a connector state; a Modbus snapshot counts the session's own energy and names the charger's. Both land in the same two tiles. When nothing answers, the server has already tried the address and says what it found, so the card shows that rather than a hint written in advance. The hint naming Own and Proxy CSMS as the way to control a charger was true until there was a third mode; it now names all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
019c28db85 |
The card is about the charger you picked, not all of them
Charger information stacked every imported charger, so the list beside it selected one and the card ignored the choice — with two chargers the page said everything twice and the picking meant nothing. It now shows the selected one alone, and falls back to the first until something is picked: a card that stays blank until clicked is a worse greeting than the charger most people have only one of. The per-block ring goes with it. Highlighting the selection inside a card that shows nothing else is a distinction without a second thing to draw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9686cae8b9 |
The charger row asks the same way everything else does
Removing a home charger kept its own inline prompt, written before there was an app-wide one. Two mechanisms for one question is one too many: it now calls askConfirm() like every other destructive action, and the panel, its pending-removal state and the ask/cancel pair go with it. The button still disables while the delete is in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c9ffb9c698 |
One prompt for the whole app, asked where the browser cannot refuse it
Fifteen destructive actions were still gated behind window.confirm(), the same call that made removing a home charger look broken: a browser that suppresses native dialogs never shows it and returns false, so deleting a service, a part, a user or an organization would quietly not happen and say nothing about why. askConfirm() puts the question in the page and resolves to what was actually pressed, so each call site changed by one line and reads the way it did before. ConfirmDialog is mounted once in App.vue and draws over everything, including a modal — removing a server is asked from inside one, which is what Modal's new zIndex is for. The home charger keeps its own inline prompt: that one belongs to its row rather than to the middle of the screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
749f42f481 |
Remove asks in the page, not in a dialog the browser may refuse to show
Removing a charger was gated behind window.confirm(). A browser that suppresses native dialogs — an embedded webview, a blocked-dialogs setting — does not show it and hands back false, so the click answered "no" on the user's behalf: no request, no error, nothing. The button looked broken and the endpoint was never reached. It always had been fine; a delete with an unknown id still answers 404 and the ownership path still resolves. The prompt is part of the row now — the warning, Cancel, Remove — the way the charger reset in the same view already asks. Remove disables while the delete is in flight, and a failure lands in the error line the list already has. Fifteen other confirm() call sites share the flaw and are left for their own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
21a54c4cfa |
The bolt in the list takes the colour the badge already had
Every charger in "Your chargers" drew its bolt in the same green, state or no state — the list's one visual signal said nothing. It now takes the green and amber the information card's badges use, off the same live map, so the list answers "which one is up?" without opening anything. A charger the service is silent about draws muted rather than green: a green bolt for an unknown charger is a claim. The icon carries a title as well, so the state is not left to colour alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7cbc81778c |
Every row, every time, dashes included
The information card drew a row only when it had a value, so two chargers side by side had two different shapes and neither could be read against the other — and a field the service is silent about looked the same as a field the card never offers. Every row is drawn now, with a dash where there is nothing to say, which is itself worth seeing. Service id loses the rule that hid it when it matched the serial. Kept, it would have printed a dash for a charger that does have one, and a dash that means "no" where the answer is "the same as above" is worse than the repetition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ad785ee9f8 |
The fields the cloud sends, kept all the way to the card
Normalizing a provider's charger list threw most of the answer away: firmware, the site id, how the charger is registered, the charge power and the cloud's own OCPP reading all arrived from Anker and none of them got past providerCharger, which carried seven fields and dropped the rest. The information card could not show what it was never handed. It carries them now, and the card lays them out: firmware beside the model, site and site id where the charger lives, "Registered as" for standalone / in a system / bound, and — when the service knows — state, charge power and OCPP status. Charge power is relayed exactly as worded upstream, since the unit is theirs and putting one on it here would be inventing it. Settings' own list gains the firmware in its subtitle. A field no view supplied still leaves no row, so an account whose chargers stand outside a system reads shorter rather than emptier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
86ea97b414 |
Online said out loud, not left as the absence of "Offline"
Settings listed a charger's reachability with one badge, drawn only when the cloud said online:false. A reachable charger got nothing — the same nothing a charger the cloud declined to report on gets — so the account's two chargers read as one offline and one unremarkable. Online now has a badge of its own, and the silent case stays silent: not saying is not the same as saying no. The Charging page said nothing at all, having only the record, which knows what a charger is and not whether it is answering. So it asks: each connected service's charger list is read into a live map keyed by the provider's own id, and the information card carries the badge and, when the service reports one, the operating state. Asking costs a round trip per service, so it happens when the home tab is first opened — the moment the question is being asked — and after that only when the user presses Refresh or imports a charger. A service that will not answer leaves its chargers unbadged rather than offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4203ca93db |
What a charger is, readable without a CSMS to control it with
The Home chargers tab put a card where the control panel goes and, with neither Own nor Proxy CSMS turned on, filled it with a single sentence pointing at Settings — an empty box beside a list of two real chargers the app already knew the vendor, model and serial of. That knowledge now has somewhere to be. Charger information lists every imported charger with the fields its record actually holds; a field the service never sent is left out rather than shown as a dash, and the provider's own id appears only when it is something other than the serial. The selected charger carries the same accent ring the list beside it does, so picking one still reads as picking the one control drives. The card stands on its own either way: with control on it sits beneath it, and with control off the Settings hint becomes a footnote under something worth reading instead of the whole card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
06f91578da |
Integrations, sorted into the categories the panel already sorts them into
The API Server panel has grouped plugins by category since the plugin list was drawn — car manufacturers, EV chargers, notifications, each tab carrying its count. The Settings page had the same integrations in one flat stack, so the two screens described the same set of things in two different shapes. Now they agree: the same categories, the same counted tabs, the same accent border on the active one. The category ids come from the Category* constants both sides already read, so nothing here invents a third vocabulary. Only a category holding something gets a tab, which is the panel's rule too: two tabs today rather than six empty ones. Below one category the bar hides itself entirely — with nothing to switch between, a single tab is a label pretending to be a control. The active group falls back to the first rather than to a fixed id, because the three integration views load independently and a tab can be momentarily empty on first paint; falling back keeps the section populated instead of blanking it. The cards moved into a space-y-4 wrapper and lost their per-card mt-4, so the first card sits the same distance under the bar whichever tab is showing — previously Toyota was the only one that could ever be first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a3f69fa5ef |
Home chargers: your own wallbox as a record, imported the way a car is
The Home chargers tab has been showing a hardcoded "Home charger · 11 kW · NACS" since it was drawn, and the control card asked for a serial as free text — a number printed on a box hanging in a garage, typed in by hand while the connected account already knew it. The garage solved the same problem for cars a while ago, so this is that solution aimed at the wall: pick the charger off a service you have connected, press Import, and it becomes a record of yours. A charger is a record rather than a live listing because it has to outlive the account it came from. Disconnect Anker and the wallbox is still on the wall; the integration is how the charger was found, not what it is. Hence home_chargers, owned by a person and not related to any car — it charges whichever car is plugged into it, and it outlives all of them — and hence no sharing: a charger is one household's business in a way a car shared with a partner is not. The provider layer is vehicleproviders.go's shape on purpose, down to the soft gate: a listing answers 200 with an empty list and the sentence that says what to do about a closed gate, a write answers 400, because there the caller asked for something that did not happen. Anker and Greencell are two adapters over plugins that already exist, so the next charger service is an adapter appended to chargerSources() and nothing else. What is deliberately absent is the car import's checkbox panel: a charger is a name, a serial and the hardware behind it, all of which the list already carries, so there is nothing to choose and the whole screen is pick one, press Import. Only the name is editable afterwards. The rest describes hardware and came from the service, and the provider link is written by the import endpoint alone, so renaming a charger cannot quietly orphan it from the account it tracks. Deleting one says as much in its confirmation: the charger is untouched, and importing it again brings the record straight back. The Anker gate moved into ankerGate() beside greencellGate(), because the same four-case switch was about to exist in a third place. Behaviour is unchanged — the same sentences, and the probe still skips the personal opt-in, since checking credentials is what you do before switching the integration on. The phone app still has the old tab; parity there is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a809980d8b |
Anker health: count the chargers the panel lists, not the ones one endpoint admits to
The probe still asked get_user_bind_and_not_in_station_evchargers and read its userBindEvChargersCount, so it reported "0 EV charger(s) bound to account" for an account whose two chargers the panel was listing directly underneath — the same blind spot the capability was just moved off, left behind in the health check. It now takes the same inventory the chargers capability returns and counts that. Authenticated with nothing on the account is degraded rather than ok, following Greencell's rule: the half we address answers, and the empty half is the account or the country that picks the regional server, so the message says so instead of reporting a healthy connection to nothing. A count reached with some view missing says how many views stayed silent, because the number is then a floor rather than a total. The web panel colours degraded amber, as it already did for Greencell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e138fad3f4 |
Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever asked for, and its name says exactly what it withholds. A charger that belongs to a system is not in it. Its userBindEvChargersCount, though, counts every charger bound to the account — so an owner with two chargers in a system got "authenticated; 2 EV charger(s) bound to account" from the health probe and an empty list from the capability that is supposed to show them. A working login that finds nothing. So the capability now asks every view the cloud has and merges them by serial. The standalone list still answers for chargers standing on their own; get_site_list walks the systems and reads each one through get_scen_info, falling back to get_system_running_info where that is silent — the power-service / HES split charger-state already knows; and get_relate_and_bind_devices contributes model, firmware and the Wi-Fi flag, and discovers anything in the A519 family that the first two missed. Whichever way a charger was registered, one of the three has it. The merge is first-writer-wins per field rather than last view overwriting: the standalone record knows the name, the site record knows the live state, and neither should blank what the other established. A view that fails is a warning on the document instead of an error on the call, because one dead endpoint should not cost the chargers the other two found. Only losing all three is a failure. When nothing comes back at all the response says so in its own words and names the remaining suspect — country picks the regional server, and the wrong one authenticates happily and shows an empty account. The other half of "not showing any chargers" was that neither client ever showed a list. The serial was a text box, and the number is printed on a charger hanging on a wall. Both apps now list what the account holds — name, serial, model, site, state, an offline badge — and hand the serial to the OCPP control card instead of asking anyone to go and read it. Where control is off the list still stands on its own, as the answer to the first question an owner has after entering credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
340a81b0d6 |
Greencell: the charger on your own broker, not a cloud it never had
The HabuDen has no cloud API to connect to. It is commissioned over Bluetooth in
the Greencell GC app, pointed at an MQTT broker the owner runs, and from then on
publishes there — so the connector is an MQTT client rather than an HTTP one,
and nothing in it reaches Greencell. The wire contract is Home Assistant's own
greencell component and the greencell_client 1.0.3 library beneath it, which is
the only published description of the topics: a BROADCAST on /greencell/broadcast
draws device announcements, and /greencell/evse/{sn}/ carries current in
milliamps, voltage, power under "momentary", the EVSE state, and the access level
chosen in the app.
That meant an MQTT client, and the server takes no dependencies, so internal/mqtt
is hand-rolled the way internal/ocpp's RFC 6455 layer is. It is scoped to what
this connector needs and says so: QoS 0 for everything we send, clean session,
no reconnect — a connection lives for one plugin call, which is exactly how the
manager builds and tears down an instance. Inbound PUBLISH is accepted at QoS 0,
1 and 2 with the acknowledgements each requires, because the QoS of a delivery is
the broker's choice and not ours; an unacknowledged QoS 1 is redelivered forever.
Read-only, and the reason is worth writing down rather than rediscovering. A
device in EXECUTE mode accepts START, STOP, SET_CURRENT and QUERY — but the topic
those go to appears in no source: not Greencell's integration page, not
greencell_client, and Home Assistant ships sensor-only for that same reason.
Publishing to a guessed topic would be a control feature whose failure mode is a
driver believing they stopped a charge. So the access level is reported, and
commandTopic is the seam: an operator who has watched their own broker and found
theirs sets it, and a state read then sends QUERY — the one command a READ-mode
device also honours — instead of waiting out the charger's publish cadence. The
day the topic is public, control is a payload away from the same field.
What the cascade resolves here is a broker, not an account, so host, port, TLS and
credentials resolve together from the highest layer that names a host: an
organization's address paired with a user's password would address a broker with
credentials never meant for it. The serial, the QUERY topic and the listen window
each describe the charger rather than the endpoint, so each resolves on its own.
Two reading rules the tests pin. A phase the device did not report stays nil
rather than zero, because zero amps on a charger is a real measurement — a JSON
null decoding to 0.0 was a live bug until a test caught it — and a partial read
returns with received/complete flags instead of failing, since a device that
publishes some topics on a slower cadence is still worth reading. And a reachable
broker with no charger on it is degraded, not down: the half we configure works
and the missing half is the device. The plugin's end-to-end tests run against an
in-process broker written to the raw wire format, so a bug in the client cannot
hide behind a matching bug in the fixture.
The apps get the third connector card. The panel needed nothing — it renders a
plugin's ConfigFields itself — but the per-user panes are still hand-written per
integration, which is now three near-copies and the argument for the generic
version already noted in the plugins README. The web form splits the broker from
the charger because the server resolves them differently. The phone card is a
declarative config against the shared widget, which gained a number field type, a
degraded state that reads amber rather than red, and a fix for a locked field
that was covering its own displayed value with dots. Twenty keys in three
languages across both apps; Greencell, HabuDen and the literal QUERY join the
proper nouns that stay in English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fd75833707 |
The cabin's temperature, and the one it is heading for
The climate cards landed with the endpoint migration, but only as two more folded dumps of key/value pairs. What a driver opens that tab for in January is one number, and it was three taps down inside a card called Climate. So currentTemperature and targetTemperature join the headline readings, beside the pair of electric ranges and for the same stated reason: neither figure answers the question on its own. A cabin at 12° means nothing until you know it is climbing towards 21°, and the gap between them is how long to leave the scraper in the boot. Being derived from headlineMetricSpecs, both are arrangeable the moment they exist — a car's saved order of readings can name them without anything else being told they are there, and a test now says so rather than leaving it to be noticed when a PATCH starts rejecting a key. The unit is fixed at Celsius, because Toyota Connected is the European service and there is no imperial reading to convert from. That is a default and not a claim: a payload that names its own unit is still believed over it, the way every other reading here works, so a service that one day reports Fahrenheit is labelled Fahrenheit rather than relabelled into a wrong Celsius. The two apps needed the two labels in three languages each and nothing else. That is the shape working: a section is an id the app localizes and a reading is a key it localizes, so a card added on the server arrives in both clients already folded, already arrangeable, already translated. The one thing the Web App did need was a corrected comment — the note explaining why cards fold still said Toyota reports eight sections, and it is the argument for folding them, so it should count the ten there now are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22a22ec43a |
Toyota: the status route the car answers, not the one it retired
Toyota put the /v1/global/remote read routes behind AWS SigV4 in mid-2026. A bearer token is no longer a credential there, so the doors-and-windows card has been asking a gateway that answers 403 — the one section of the provider tab that could only ever have been in error. The MyToyota app reads that state from /v1/vehicle/status now and pytoyoda followed it in 5.2.0; so does the connector. The electric route did not move, and the comment above the endpoint block says which of the two namespaces each one lives in, because the obvious tidy — sweep the rest onto /v1/vehicle/* — would break the ones that still work. The same migration gave the climate reads a home worth porting: /v1/vehicle/ climate-status is what the cabin is doing, climate-settings the preset it was told to do it at. Both are GETs with a vin, both are new cards on the tab, and their headings are in all three languages on both apps. Nothing about the tab's plumbing changed to hold them — a section is an id, an action, and whatever JSON comes back, which is the point of that shape. Left where they are: the POST wake calls. Upstream refreshes a stale reading by waking the modem, and this connector is documented as read-only, so climate and status show what the car last reported rather than what it would say if asked twice. The cost is a reading that can be hours old, and it is the honest one to pay for a connector that promises not to touch the vehicle. Two tests keep the migration from being undone by hand: one fails if any advertised capability points back at a retired route, the other if a capability is advertised without being wired into Invoke, which is the way the next endpoint would go missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
35e6c511b7 |
Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.
It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.
There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.
A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.
The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.
Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.
Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.
Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f7caeaf907 |
A date typed in the order you chose, not the browser's
Settings › Date format steered every date the app printed, but not one it asked for: `<input type="date">` renders in the browser's own locale and no page setting can move it, so DD-MM-YYYY tables sat above 08/22/2026 boxes. DateField takes over the typing half — a masked box whose segment order comes from the same prefs.dateFormat lib/format.js reads — and leaves the picking half to the browser, behind a calendar button. The value in and out stays ISO, so no caller changed. Native validation now also catches a full-but-impossible date; the old input let a half-typed one through as no date at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d4033dbcef |
A build date you may only half know; one look for an empty cell
Two changes, both about showing what is actually known rather than a tidier version of it. The build date asked for a day. A car's build date is often only a year, or a month and a year - the VIN plate is stamped with a month, the papers carry a day, a grey import neither - so a field insisting on all three is answered either with an invented day or with nothing, and both throw away what the owner did know. The field now picks its own precision: a full date, a month and year, or a year, each with the control that suits it. A year is typed rather than picked, because a date picker that makes you walk back to 1998 is worse than four keystrokes. Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601 reduced precision, and printed back at exactly that precision. The three shapes sort and compare as strings in date order, which is why the prefix is stored rather than a date with a precision field beside it. The formatter takes the string apart rather than parsing it: "2015-03" read as a UTC instant and printed in local time hands back February west of Greenwich. Narrowing the precision keeps what is still true, so a day dropped from "2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward half of the control and it is deliberate: there is nothing to widen a year with, and leaving "2015" behind an empty month box would store a date the screen is not showing. The column was free text with no validation at all, which was tolerable while only a date picker could write it and is not now that three shapes are legal. normalizeBuildDate parses rather than pattern-matches, so "2015-13" and "2015-02-31" are refused instead of stored as something no reader can print. The phone needed changing to avoid destroying this. It parsed buildDate with DateTime.tryParse, which returns null for "2015" - so a half-known date would have shown as a dash, and saving the car from the phone would have written "" back over it. It holds both date fields as the string they arrived as now, prints them at their own precision, and hands back anything it cannot set. Its picker still only makes full dates; a precision control there is a separate job. Separately: an empty cell of the service table had three different looks in one row. The dash under Notes was body-coloured, as though it were content; the one under File was 12px, having borrowed the size of the Download button that would otherwise be there; the one under Changed parts was muted at 14px. They are one constant now, muted at the row's own size, which is what Next date and Next km already did for a missing value. The Download link keeps its own styling - it is an action, not a value. Verified in a browser: a stored "2015-03" loads as month precision in a month picker, month to year narrows to "2015", year to day clears, "19x98abc" typed into the year box sanitises to "1998", saving sends buildDate:"1998" and the Information tab then reads "1998" - while a full first-registration date beside it still reads 06-08-2026. All five empty cells across the three columns now compute to the same size, colour and weight, with the filled ones unchanged. go vet and go test ./... pass with a new test over the three valid shapes and six rejects; flutter analyze is clean and 22 tests pass, one new, covering a half-known date in two date formats and the time zone that could shift it; npm run build is clean. Not verified: First registration still demands a full date. The same argument applies to it and the field is now a reusable component, but it was not asked for and is one line away. The web formatter's month-name paths - the DMY and MDY formats, which spell the month out - are covered only by the phone's mirror of the logic, the web app still having no test runner. A car created through the Toyota import bypasses the new validation; it only ever produces full dates, so nothing invalid gets in that way, but it is not guarded. Both apps need redeploying before any of this is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c5d431c560 |
Changed parts: one column in the service table, not one each
Oil & Oil filter, Engine air filter and Cabin air filter had a column of Yes/No
each in the Service history table, 375px of the 1022px table between them for
three bits of information. The form has always kept them together in one Changed
parts section, which is the honest shape: they are one answer to one question
about a service, not three unrelated readings. The table said otherwise, and the
list is going to grow - every part added would have taken another column and
pushed the table into a sideways scroll.
They are one column now, 234px with the widest summary on screen, and its width
no longer depends on how many parts exist. The cell names what was changed
rather than counting it, because a history is read down the page and "2" tells
you nothing about which two; past two names it becomes the first part and a
tally, which is what keeps one line one line as the list grows. Nothing changed
reads as an em dash.
The detail is a dropdown, not a dialog. This is read-only detail about one row
of a table you are reading down: a modal would black out the rows being compared
against and charge an open-and-close for each one. It is pinned under the button
it was opened from, closes on an outside click, Escape or a scroll - it is fixed
to a point on the screen, so a table that moves underneath would leave it
pointing at the wrong row - and there is one panel rather than one per row. It
lists every part with a Yes or a No, the unchanged ones included, so the em-dash
row still answers the question instead of being a dead cell.
One list in lib/serviceParts.js now drives the form's checkboxes, the cell's
summary and the panel. That is the point of the change as much as the width is:
adding a part was three edits that had to agree, and is now one entry plus its
boolean on the API's service_records collection. The form builds its state and
its payload from the list rather than naming the three fields twice - the save
payload is unchanged in shape, which was checked against the wire rather than by
reading it.
This walks back part of the previous commit, which had just made all three
hideable separately: the server's column set drops oil/engineFilter/cabinFilter
for a single "parts" key, and a test now asserts those three are not columns of
their own, so the table cannot drift back. A car with ["oil"] stored as hidden
would quietly get the combined column - nothing has that stored, the deployed
stack predating the feature, and stale keys are dropped on read rather than
erroring.
Verified in a browser against a stub API: all four summary cases (one part
named, two named, three as "Oil & Oil filter +2", none as an em dash); the panel
opens anchored under its button with the right Yes/No for the row, stays inside
the window, and closes on outside click, Escape, scroll and a second click,
switching rows without leaving a second panel behind; the picker offers "Changed
parts" as one entry and hiding it sends {"hiddenServiceColumns":["parts"]};
dragging sends "parts" in the order with the hidden column holding its slot; the
Edit dialog renders from the shared list and its PATCH still carries all three
booleans with the unticked one false. go vet, go test ./... and npm run build
are clean.
Not verified: no automated test covers any of it - the web app still has no test
runner, so the cases above were driven by hand. The drag and the panel were
exercised through dispatched events rather than a pointer, the browser pane not
compositing, so the native drag image and the panel's behaviour under a real
click-and-hold are unchecked. The dropdown overlaps the rows beneath it, which
is what a dropdown does but was not weighed against a taller table. The deployed
Web App still shows three columns until it is redeployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b60d929ed6 |
Service history: columns you can switch off and rearrange
A car's page has let you choose and arrange two things for a while - which tabs
it shows, and which rows the Information tab lists, both dragged into whatever
order you like. The Service history table was left out of that: nine columns,
hardcoded, in one order, for every car. An EV shows Oil & Oil filter and Engine
air filter on every row of a history that will never record either, and a reader
who mostly wants Notes has to look past four columns of dates and distances to
reach it.
It works the way the other two do, because a third mechanism for the same idea
would be one to keep in step. Both lists are properties of the car, so everyone
it is shared with sees the same table, and both need write access to set. The
columns are stored as the hidden set rather than the visible one, so a column
added in a later release is on by default. The arrangement covers the hidden
columns too, which is what makes a column switched back on return to where it
was instead of reappearing at the end - verified below, since that is the part
of this shape that is easy to get wrong and invisible until somebody hits it.
Date cannot be switched off. Every row of that table is work done on a day, and
a history with the day taken out stops being a history; it can still be dragged
anywhere, which is exactly the rule Information already follows in the tab bar.
That is a judgment call and the annotation that prompted this only circled the
other eight columns - moving "date" into hideableServiceColumns and dropping the
filter in the picker would reverse it in two lines if it turns out to be wrong.
Server: hidden_service_columns and service_column_order on the car, validated
against their own key sets by the endpoint that already does this for tabs,
fields and readings. The arrangeable set is derived from the hideable one plus
the date rather than written out again, so the two cannot drift as columns are
added. Bootstrap appends missing fields to existing collections, so the two
columns appear on the next server start with no migration to run.
Web: the table stopped being nine hardcoded th/td pairs and is now driven by one
list of columns, head and body from the same source, which is what stops a moved
or hidden column from shifting the headings out of line with the cells. The
cells are built a row at a time rather than a call per cell, so a long history
doesn't rebuild every cell three times to read its text, its classes and whether
it is the file column. The column headings kept their existing car.services.col*
translations - the keys are mapped rather than derived, because renaming a dozen
strings in three languages to save a lookup table would be the wrong trade. Four
new strings in all three languages.
Verified: go vet and go test ./... pass, with new tests covering both key sets -
that hiding the date is refused, that a field key is not a column key, and that
the arrangeable set is the hideable one plus the date. npm run build is clean.
The page itself was driven in a browser against a throwaway stub API: the
rewritten table renders identically to the hardcoded one, switching two columns
off removed exactly those two from head and body with the rest still aligned and
sent {"hiddenServiceColumns":["oil","engineFilter"]}, dragging Notes onto Km
reordered head and body live and saved an order with the hidden columns still
holding their places, switching Oil back on returned it between Next km and
Cabin air filter rather than to the end, and a read-only share gets no gear
button, no draggable headings and no drag hint.
Not verified: the drag was exercised by dispatching drag events at the
component's own handlers, not by a pointer - the browser pane was not
compositing, which rules out both screenshots and a real drag - so the native
drag image and cursor are unchecked. No automated test guards any of the web
behaviour; the web app still has no test runner. The API rejects unknown JSON
fields, so this web build against an older API Server would take a 400 when
saving the picker: they deploy together from this repo, but one must not ship
without the other. The phone app is deliberately untouched, having no column
table to arrange, and ignores both new fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
caf4d2996d |
A garage card wide enough for the badge it carries
The service badge gained a second trigger in the last commit, so it now reads "OK · 353d · 13.612 km" where it used to read "OK · 353d". On the garage card there was nowhere to put the extra quantity. Three columns fit the 1024px shell at 328px each, and at that width the badge wrapped with the unit alone on a second line - a pill 44px tall reading "13.612" above "km" - while the name beside it truncated to "Toyota bZ4X To...". The card had been sized for the badge that used to be there. The column count now follows from the width a card needs rather than from breakpoints: an auto-fill track with a 24rem minimum, which the 1024px shell answers with two columns of 502px. A desktop garage is therefore 2-up where it was 3-up. That is the honest answer at this container width - the third column was what squeezed both the name and the badge - and it is the change here most worth disagreeing with, since it is visible on every garage and not only on the cars with two triggers. min(24rem,100%) keeps a phone, narrower than one track, on a single column rather than overflowing it. The badge also stops wrapping outright. Its label is a phrase whose parts have to stay together, and separating a number from its unit is the one break it must not take. The name beside it truncates instead, which it was already prepared to do - so on a phone, where a card is 271px, the badge stays whole and the name gives way. Verified in a browser against the real card: 328 -> 502px, the badge 44px over two lines -> 24px on one, "Toyota bZ4X To..." -> "Toyota bZ4X Touring" in full, and at 375px one column, no sideways scroll, badge still on a single line. npm run build is clean and the compiled CSS carries both rules. Not verified: no automated test covers this - the web app has no test runner, so the widths above were measured by hand. The layout was checked with one car on the screen; a garage of several was not, though the change is to the track rather than to the card. The deployed Web App still serves the previous build and will keep showing three narrow cards until it is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e5759df52c |
Say how far the next service is, not only how long
The service badge has always watched two triggers - the next-due date and the next-due odometer reading - and shown one of them. It ranked the two and printed the worse one's sentence, so a car comfortable on both read "OK · 354d" and never said that the odometer target was 13.612 km away, even though the Information card right under it prints the 15.000 km the badge is counting towards. Whichever trigger arrives first ends the interval, so naming only one of them describes half the thing. Both are named now. With both signals known the label is a severity headline followed by each trigger as a bare quantity - "OK · 354d · 13.612 km", "Due in 12d · 13.612 km" - which is the shape reminderStatus in the same file already uses, it having had the two-trigger problem first. The signals gained the number behind their own wording to make that possible; they were returning only a formatted sentence. Wording is unchanged wherever only one signal has data, which is the case this rewrite most risked disturbing: a car with no odometer target still reads "OK · 354d" exactly as before, one with no service date still reads "13.612 km left", and neither still reads "No data". The new keys are only reached when there are genuinely two numbers to print. An overdue badge lists only the triggers that have actually passed. "Service Overdue 30d · 13.612 km" would read as overdue by 13.612 km, which is the opposite of what that number means, so the trigger that is still comfortable stays out of a sentence headed "Overdue". It costs the remaining distance on a date-overdue badge; the alternative costs the reader's trust in the number. The phone carried a line-for-line copy of this logic and gets the same treatment rather than being left a version behind - the two would otherwise disagree about the same car on the same day. Its signals become a private record type, since Status is public and shared with the expiry, reminder and warranty badges that have no second trigger and no use for the field. Two new keys (status.okIn, status.serviceOverdueBy) in all three languages in both apps. The day and km fragments they interpolate were already translated for the reminder badge, so the parts assemble in Polish and Danish without new wording: "OK · 354 dni · 13 612 km", "OK · 354 d · 13.612 km", each with its own grouping separator. Verified by flutter analyze (clean), flutter test - 21 pass, including the key-parity test that would have caught a key added in English alone - and npm run build for the web. The web function was driven through the real module in a browser over ten cases: both signals known at each severity, each of the two overdue alone, both overdue together, either signal missing, neither, and a zero-odometer car, in all three languages. Not verified: no new automated test covers this. The web app has no test runner and the phone's format tests cover the catalogue lookups rather than the badge, so the ten cases above were checked by hand and are not guarded against the next edit. The deployed Web App still serves the previous build and will keep reading "OK · 354d" until it is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc6febf815 |
Phone App: finish the admin screen; translate the last English strings
Three loose ends from the last two commits, each of which was named as deliberately-not-done and none of which is worth carrying further. The phone's create-user sheet had no organization picker. The endpoint has taken an `organization` since orgs existed and the Web App has offered the choice all along, so a superadmin on the phone could only ever create accounts in their own org - a silent restriction rather than a stated one. The sheet now loads the orgs and offers them to a superadmin, with the same blank "no organization" option and the same hint as the web. An admin still gets no picker, because the server forces its own org on their members and a picker that cannot change the outcome is a lie. The listing is manager-only and can fail, in which case the picker offers only "no organization" rather than blocking the form. A locked role picker or delete action was greyed out with no reason given. The web has explained itself in a title attribute since those guards existed, and the sentences - admin.cantChangeOwnRole and the rest - have been sitting translated in the phone's own language files since the screen was translated. Hover has no touch equivalent, so the two controls take different routes: a long-press on the role picker shows the reason as a tooltip, and the overflow menu carries it under the action, because a disabled menu item cannot be long-pressed and silently greying it out is the thing being fixed. settings.integrations.* and charging.control.* were English-only in *both* apps - 70 keys, identical text, identical key sets - so they are translated once and land in all four language files. OCPP and CSMS are protocol names and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay; everything else follows the wording already in each language's file. The Web App's files are edited as text rather than round-tripped through a JSON dump, because they keep a blank line before every nested block and a dump flattens it - a 900-line translation file is hard enough to read without losing its paragraphs. Both diffs are purely additive as a result. Both apps now have every key in all three languages: 738 in the web, and the phone reports zero fallbacks. A new test locks that in - every key en.json carries must exist in pl.json and da.json - and it was checked by deleting a key and watching it fail, because a guard that cannot fire is not a guard. Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new - flutter build apk --debug, and npm run build for the Web App. The key checker reports 575 static t() keys in the phone and 738 in the web resolving with no fallbacks in either language. Not verified: still nothing run against a live API Server or on a device. In particular the organization picker's happy path - a superadmin creating an account into a chosen org - has not been exercised end to end; it is the one piece here that touches the API rather than only the language files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9abb03ee4f |
Service history: 0 km is a reading, not a blank
A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.
The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.
Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.
Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.
Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in
|