fe1e314df9586456822324a9749b77ce22071ce7
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fe1e314df9 |
The store comes apart into the four roles it always had
`weed server -s3` was never one thing. Master, volume, filer and the S3 gateway ran as four goroutines under one process, on one volume, sharing one fate. Splitting them into four containers changes nothing a client can see — the same bucket answers on the same port — but it makes three things possible that were not: the SeaweedFS admin UI, which wants a cluster to look at; a restart or an upgrade of one role without the others; and, eventually, a second volume server somewhere else. A fifth container carries the panel itself. The single-process files stay exactly as they were. These are `.split.` twins beside them, four in all, one per folder per shape, each with the .env example of the same name that both READMEs already promise. Identities are the part that could not simply be copied across. SeaweedFS picks its credentials from one source, in order: an -s3.config file, the filer's IAM store, then AWS_ACCESS_KEY_ID and its secret — and a higher source replaces a lower one rather than adding to it. The existing files use the env pair, which is fine precisely because nothing else writes identities there. Hand somebody a panel that can, and the first user they create lands in the filer's store, the store outranks the environment, and PocketBase's key stops existing — with the first failed upload as the notification. So the gateway here is started with no config file and no AWS_* at all, and the init container seeds PocketBase's identity into the filer's store instead: the same store the panel writes. One source of truth, PocketBase's key sitting in Object Store → Users beside every other, keys minted there picked up without a restart, and a rotated PB_S3_SECRET re-applied in place on the next boot rather than added as a second identity. That seeding is now allowed to fail. The bucket-create it grew out of was best-effort — `|| true`, on the reasoning that the API Server's own S3 check would report a gateway that was genuinely unreachable. That reasoning does not survive the change: a gateway whose IAM store is empty does not refuse anyone, it serves everyone, and the bucket would be wide open rather than unreachable. So the step ends by grepping the configuration back for the access key, the gateway waits on it completing successfully, and a seed that did not land stops the stack instead of opening it. The prod files publish the gateway and the panel, both on loopback, and nothing else. Port 8080 on the volume server hands out file content by file id with no authentication of any kind — the S3 credentials have no bearing on it — so publishing it would publish every attachment in the stack, and the panel shows what that port and the master's would. The panel's own password is required rather than defaulted, because weed serves it with authentication switched off entirely when it is empty, and a page that mints bucket credentials is the bucket. It is passed as WEED_ADMIN_PASSWORD rather than a flag so it stays off the process command line, and SEAWEED_ADMIN_BIND is the knob a remote host needs, named after PB_BIND and API_BIND for the same reason. Master, volume and filer share one /data mount rather than taking three of their own. That is precisely the layout `weed server -dir=/data` writes — the master's raft state, the volume's .dat and .idx, the filer's filerldb2, no two of them naming the same file — so a stack can move between the single-process file and its twin in either direction with nothing to migrate. A second volume server would need its own, and the files say so where somebody would go looking. The dev files map the volume server to 8081 on the host: 8080 there is already the API Server, and in the all-in-one it is the API Server inside the image. Unexercised: written on a machine without Docker, so none of the four has been brought up. Every flag, health path and env name was read out of the pinned 4.45 source rather than recalled — -mdir, -volumeSizeLimitMB, -defaultStoreDir, -max, admin's -master and -dataDir and WEED_ADMIN_*, the filer's and gateway's /healthz, the panel's unauthenticated /health — and the four files were parsed, interpolated against their examples, and checked for duplicate host ports. A `docker compose config` on the target host is still the first thing to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9a2a4ab72e |
The files leave the volume the database sits on
Every attachment — a document scan, a fuel receipt, a workshop invoice, a
photo of a part's box — has lived inside pb_data, in a directory beside the
SQLite file. One volume held both, so neither could be sized, backed up or
moved without the other. PocketBase can keep those bytes in an S3 bucket
instead, and now it is told to.
Nothing on the way to a client changes, because an attachment was never a
storage URL to begin with: it is fetched from GET /api/{records}/{id}/file,
which re-checks car access and asks PocketBase for the bytes as the service
account. PocketBase streams from the bucket through that same endpoint rather
than redirecting to it, so the web app, the phone and the plugin cannot tell
which side of the switch they are on.
The bootstrap that already creates the collections now writes PocketBase's
files-storage settings too, from PB_S3_*, on every boot and only when they
differ from what is already there — then asks PocketBase to prove it can reach
the bucket, and says so in the log either way. Two asymmetries are deliberate.
A read of the settings masks the stored secret, so a rotation of the secret
alone is invisible from here and needs another PB_S3_* to move with it. And it
never turns S3 back off: files already written to a bucket are reachable only
while PocketBase still points at it, so dropping the configuration would strand
them rather than undo anything.
Each deployment shape is one compose file with an .env example of the same
name, not a base plus an overlay to remember — six of each per folder, for
Docker and Docker-AIO alike: the plain one, .seaweedfs, .s3, and the three prod
twins. The SeaweedFS files run master, volume, filer and gateway as one process
and a one-shot init container beside it, because PocketBase never issues a
CreateBucket and SeaweedFS will not conjure one on first upload. The credentials
do double duty there — the gateway's only identity is also what PocketBase
authenticates with. In the all-in-one that gateway is a second container rather
than a fourth process under supervisord: keeping the object store inside the
image, on the volume the files are being moved off, would have defeated the
point and would have meant rebuilding.
Files uploaded before the switch are not carried across; PocketBase copies
nothing, and both READMEs say so where an operator will read it.
The TLS overlay and its Caddyfile go. The section they served stays, without
them: nothing in the stack terminates TLS any more, so it now names the four
variables to set in front of whichever proxy already does — TRUST_FORWARDED_PROTO
being the one that decides whether a charger is believed about how it arrived.
Unexercised: this was written on a machine without Docker, so the pinned
SeaweedFS image, the bucket-create and the settings write have not been run
against a live stack. The Go side builds, vets and tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
181f55a849 |
The phone catches up with the month the web had
Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b4f4f034d |
A task holds the whole night, not one end of it
One command per task was the wrong unit. A charging window is two commands and reads as one intention, so it was two rows that had to be named twice, switched off twice, and kept in step by hand — and there was nowhere to put the third thing, the ease down to 10 A once the house is asleep. So a task holds a flow. Steps are rows in the editor: an action, a time, and the ceiling under the one action that takes one. The chargers and the days belong to the task, because they are the same for every step of a night, and the switch governs all of it. The steps keep the order they were written rather than being sorted by the clock. A night crosses midnight, and clock order files "start at 23:00" last, behind the stop that closes it — which is not the flow anybody described. Nothing about firing depends on the order: every step is timed on its own, and the sweep asks each one whether its minute has come. Run now moved onto the step. A flow is not a thing that can happen at once — firing a start and the stop that closes it back to back would leave the charger where it began and prove nothing — so the button fires the one line it sits on, and the outcome names the step by its time. The stored shape changes with it: action/amps/time give way to a steps list. The collection was a day old and empty, so this replaces them outright rather than carrying a compatibility path for a schema nothing has run on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0f48093d1a |
A week that starts where the person reading it starts theirs
The scheduler's day picker began on Sunday because that is where Intl numbers the days from, which is a fact about the API and not about anybody's week. Monday leads it across most of Europe. A row of seven buttons in the wrong order is not just odd to read — it is easy to misclick, and a misclicked day in a schedule is a car charging on the wrong night. So Settings › Appearance asks, beneath the date and the clock, as the third question a region gets: first day of the week, following the region unless it is answered outright. The same shape the time format already had, and the same "auto" default, so nothing changes for an account that never opens it. The rule lives in lib/format.js beside the clock's, with the ordering, the day names and the sort all coming from there. The two places that lay weekdays out — the picker and the line each task is summarised on — read it rather than each keeping an opinion, so a day set is written and read back in the same order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5a4515978f |
One schedule for every charger, and a clock on the server to keep it
The charger's own cloud schedule is one window inside one box: charge between these hours, every day, and that is the whole vocabulary. A third tab on Charging holds a list instead — each line an action, a time, the days it repeats on and the chargers it acts on — and one list covers the whole account rather than each charger hiding its own. The clock is the server's. A schedule that only fires while a tab is open is a reminder, so a ticker sweeps every enabled task and fires whichever minute has come. It sends by handing a synthesised request to the same control endpoint the page's buttons use, so a scheduled command goes through the same cascade, ownership gate, rate limit and audit trail — what the owner cannot press by hand, the scheduler cannot send for them. A task names its chargers, or names none, which means all of them and keeps meaning that for a charger imported next year. Times are stored as a wall clock plus the zone they were written in, so 23:00 stays 23:00 wherever the server sits. One action per task: a charging window is the two tasks that open and close it, which is how it is read back, edited and switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8e4c22cfcf |
The settings card takes a second look, because the charger answers late
Measured on an A5191 rather than guessed at. The settings frame is not lost and it is not missed: it lands about half a minute after the read that asked for it, by which time that read has long returned. The value goes into the server's state and stays there — six polls afterwards all carried the same settingsAt, served from cache in a dozen milliseconds. What was missing was the second look. Nothing on the page took one, so a card that came up before the answer arrived stayed empty until something else happened to refresh it. That is the whole of "it doesn't always load". So a refresh that comes back without the settings half queues another, at six seconds, twelve, twenty-four, forty-five, and then stops. Bounded because the message the server asks with is one the reference reads as carrying an Anker bug: a charger that never answers is a real possibility, and a page left open all day must not poll one for ever. The patience resets per charger, and the chase stops on an error and on unmount. The previous attempt at this treated it as a wait that was too short and lengthened it from four seconds to twelve. That was the wrong half of the problem — the delay is thirty-odd — and waiting it out inside a UI request would be a poor trade. The longer wait is left where it is; it was not wrong, only insufficient. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4bcf1305be |
The bolts in the list keep asking who is reachable
Two things kept the charger list showing whatever was true when the tab opened. The map of who is online was replaced wholesale on every read, so a service that would not answer took every charger it knows about grey with it — and then the read marked the question asked, and the guard above it meant nothing asked again. One failed call and the list sat colourless until somebody pressed Refresh. It merges now, and only when something actually answered: a provider that answered overwrites its own entries, a provider that could not be reached leaves its last word standing. "We could not ask" is not an answer, and it is not "unknown" either. And it only ever asked once. There is a thirty-second poll now, running only while the tab is being looked at — not on the public tab, not while the page is hidden, and asking straight away on the way back rather than waiting out an interval that was never going to fire. The poll takes the reachability half alone, which is what the second argument is for. That is one call per connected service, back in about a quarter of a second. The per-charger views behind it are a dozen cloud endpoints each, and a background poll has no business spending those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd636bbe03 |
A time box that reads on the clock the user chose
The schedule windows met this account with "12:00 AM", clipped to "12:00 A!" by a box too narrow for it, while the card above them printed 00:00. Both halves of that are the native control: <input type="time"> renders in the browser's locale, and its am/pm did not fit. Nothing on the page moves it. lang= was measured rather than assumed — five inputs set to en-US, en-GB, da-DK, pl-PL and nothing came out identically wide, because the control follows navigator.languages and not the document. That is the wall DateField hit for dates, so the answer has its shape: the typing half is ours, the value stays 24-hour "HH:MM", and what the box shows follows the setting. Four digits, the colon inserted as they are typed, the meridiem its own control rather than something to spell. No picker button: a calendar earns one, four digits do not, and the browser's popup would have brought the 12-hour reading back in with it. A half-typed or impossible time emits nothing rather than the part of it that parses, so a block's Apply leaves that field out instead of writing a time nobody meant. format.js exports which clock is in force now, so what prints a time and what accepts one cannot disagree about it. The box is 80px because 12:30 measures 66 with its padding and the first attempt at 64 clipped — which was the complaint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ff73e5110 |
Brightness and the solar floor become sliders too
Both are a place on a short, known range, which is what the slider row added for the current limit is for. Typing 70 into a box that only accepts tens was the worse way to say it. The solar minimum gets no floor note under it. The current limit's says that below six amps the charger pauses rather than charging slowly, which is a sentence about a ceiling; this is the least a solar charge will draw, and the same words would be wrong about it. Main breaker limit stays a box. Ten to five hundred amps is too wide a range to aim at with a slider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c1b76a801d |
An inherited field shows what it inherited, not an example
Country read "DE" under the words "inherited from your organization", while the organization it was inheriting from said DK. The DE was never a value at all — it was the example placeholder, left in place when the field locked, and an example in that position is not a hint. It is a wrong answer to the question the box is being asked: which country am I inheriting? The server had already settled what may be shown. It sends the secrets back as dots and everything else in the clear, country included, and only the panel was throwing that away. So a locked field now placeholders its effective value, and the example is kept for the case it was written for: an empty box waiting to be filled in. Applied to the non-secret cascading fields rather than to the one that was noticed — Green Cell's port, serial, timeout and command topic had the same example hardcoded a card further down, and would have told the same lie the moment an org set them. The dots are left alone. They are the panel's own masking, and rerouting them through the server's effective value would be the same result by a different path — not worth changing how a secret is displayed as a side effect of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
830ef0cde5 |
The region says which clock, this app says how to punctuate it
Auto was the region's answer whole, dot and all, so Denmark got 13.45 while every explicit setting beside it wrote 13:45. One screen punctuating a time differently from the next is not local colour, it is an inconsistency, and it was ours to fix rather than the locale's. So the region is asked one question now — does this reader expect 13:45 or 01:45 pm, which is a real difference in how people tell the time — and the printing is the same two lines for all three settings. Denmark, Poland and Japan read 24-hour and get 13:45 from auto; the US reads 12-hour and gets 01:45 pm from it, with the same colon and the same marker as everywhere else. The marker reads in English wherever it appears. That is the trade the setting already made when it offered a 12-hour clock to regions that do not use one. This drops the formatToParts pass from the commit before it: once both halves are fully specified there is nothing left to ask the locale, and the answer is shorter written out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
263d35c688 |
Picking a clock picks its separator too
24-hour read as 13.45 in Denmark, because that is how Danish writes a clock and toLocaleTimeString was doing as it was told. But somebody who leaves "follow the region" and picks 24-hour has just said they want the region to stop deciding — and they mean 13:45. So the two explicit modes build the string from formatToParts and pin the mark between the hour and the minute. Only that one literal is replaced: everything else stays the locale's, which is why Japanese keeps 午後 in front of it and English keeps its lowercase pm after. 12-hour got the same for free — it had the identical 01.45 pm. Auto is left alone. A setting that says to follow the region has no business arguing with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d51a34c44 |
The settings card stops emptying when one message is late
A snapshot has two halves and they travel separately: telemetry comes from the trigger, the settings only when the charger has something to say about itself. A read can land with the first and not the second — most often the first read after a reconnect — and the card was seeded from that answer alone, so it collapsed to the one control telemetry happens to carry. That is the state of one message, not the state of the charger. Three things, from the outside in. The card keeps what the charger has reported, per serial, across reads. A value stays until another replaces it. They are its own last word either way, and the same ones the server fills a grouped command's siblings from when a caller leaves them out. A charger that goes quiet is no longer written off for good. The miss counter decides whether a read waits for the settings frame at all, and it only ever rose: three unanswered requests early on and no later read waited again, however freely the charger answered afterwards. The comment said "recently enough"; the code said "ever". Answering clears it now. And the first settings are worth the wait a settings write already gives them. Stale settings and never-reported settings were both allowed four seconds. Stale has something to fall back on; never-reported is the empty card, so it gets the full wait — still bounded by the miss counter, so a charger that truly never answers costs it three times and no more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c65ce0c081 |
Two tiles named for what they were, not for what they show
The control card's first two tiles were named when OCPP was the only thing they read. "Connector" is what an OCPP connector state is, and "Energy" is what a meter total is. Both tiles learned to read the charger's own snapshot instead — statusDesc and the session's own energy — and neither name followed. They take the readings card's names now, and only where they are showing the readings card's values: the same statusDesc it calls Charging status, the same session energy it calls Session energy. One value, one name, in both places it appears. OCPP keeps the old two, which are right for what it puts there. Both strings were already translated, so this adds none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b339d3dac |
Tap the card, and that is the whole enrolment
The reader already filled the number in; adding it still meant walking back to the keyboard and pressing Add. That walk was the entire cost of the two-step version, and the card is in your hand at the charger. One press now opens the reader and writes whatever is held against it. The name is the server's own convention — RFID and the card's last four digits — because the name is left empty and rfidSaveCard fills it in. Deriving the same pattern here would have been a second place for it to drift; every card already on the account reads that way. A name typed into the box still wins, since throwing away what somebody typed is worse than the convention. The reader is a value now rather than a side effect on the form, and the write is shared with the typed path so the two cannot judge their answers differently. The caller holds the busy flag across both halves: nothing re-enables in the gap, where a second press would have opened a second twenty-second window. The number lands in the box on the way past, so a write that fails leaves something to retry rather than a card nobody can name. The old button stays for the times the number is wanted without the card being added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e190364c77 |
One ceiling, one slider, in the card that owns it
The current limit sat in the control card and in the settings card at once. Over the cloud those were not two settings that happen to agree: the "limit" command builds its frame from the same table entry the maxCurrentA setting does, so it was one wire field with two controls. The control card gives it up wherever a settings card can take it — which, now that the cloud has one, is both transports that read the charger. OCPP keeps its slider. A charging profile is not a setting the charger reports, so there is no settings card to move it to, and clearing a limit is OCPP's alone: the cloud sets a ceiling and has no message for "no ceiling". Taking the control away there would have left those modes unable to set a limit at all. It arrives in the settings card as the slider it was, not as the number box the table gave it. Slider rows stack — label and the value it is at on one line, the track beneath, the floor hint under that — because a ceiling is a thing you slide between two known ends rather than type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dde5410788 |
The clock stops being a side effect of the region
Whether a time read as 13.45 or 01.45 pm was decided by the region picker, which also sets the decimal separator and the currency layout — so a Dane who wanted a 12-hour clock had to move their numbers to get one. Time format is its own setting now, beside the date format it is the other half of. It defaults to "auto", the region's own convention, which is what every timestamp in the app already said: nothing moves until somebody picks something. The 24-hour setting asks for hourCycle h23 rather than hour12:false, because with hour12 the en-US formatter prints midnight as 24:00. One helper, so it reaches everywhere at once: formatDateTime now calls formatTime, and every clock the app draws goes through it — the charger's telemetry and settings, a session's start, when a charger was linked, the provider panel's own timestamp. The users collection gains a time_format select in both places the schema is declared; it is in reconcileOrder, so a restart adds the field and nothing has to be migrated by hand. The native time inputs in the charger's settings card are left alone: the browser renders those in the OS convention whatever this says, and a text box that respected the setting would be the worse control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fddab6815 |
The control card says which charger the buttons move
The product shot was already in the account's charger list and already relayed; only the information card ever drew it. The card whose buttons start a session had no sign at all of which charger it meant — the name lives two cards further down, and two A5191s on one account look alike in a dropdown. Identified by the serial in force rather than by the record highlighted in the list beside it. They are usually the same charger, and when they are not, a picture of the other one is worse than no picture. Either source will do: the account's own list, or the live half held per provider, which carry the same image. A shot that will not load is remembered by its URL, so the picture stops being drawn and comes back on its own if the service starts answering for it again — nothing to reset when the serial changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d4aecb668 |
The settings card stops being a Modbus card
Both transports that read the charger can also be told things, so both get the card now. Modbus has four writable registers and keeps the four controls it had. The cloud has the charger's whole settings group — two dozen of them, everything the Anker app sets short of the card list — and they were readable in the readings card and settable nowhere. Drawn from a table rather than written out one control at a time, because the charger's commands own sets of fields and take a command whole: a schedule frame carrying only its switch is a schedule whose times have just been set to midnight. So a section is one command, its fields are sent together, and Apply is per section. Only what the charger has reported gets a control — a blank box that writes whatever it was left at is worse than no box when the value travels as a sibling. The meter and monitor the balancing features watch stay read-only: there is no command for them, and what their modes select is undocumented. The phase select offers the automatic and single-phase this command carries, and shows a reported three-phase rather than reading as something the charger did not say. Nothing left the readings card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3064bff8ad |
The charger's own card list gets a door, and says where it differs
0104 was already implemented and already in the action catalogue; nothing routed to it, so the only way to see the device's list was as a side effect of writing a card. It has an endpoint now, and the panel a button. The two lists are compared where they meet: a card the charger holds and the account has forgotten still opens it, and a card only the account holds will not, and neither shows anywhere else. The comparison is drawn only when they disagree, and the device's reading is dropped on a refresh rather than measured against an account list from a later moment. The new test asks all four card routes without a token: a capability the plugin implements and the catalogue advertises is still unusable if nothing routes to it, and no other test here would notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7176867eb3 |
Tap the card on the charger and the number fills itself in
The enrolment the Anker app does, done here: 0108 a2=7 opens the reader, 0908 brings back the UID. The frames this sends are byte-for-byte the ones the app was captured sending — checksum included — which is what the new test asserts. Adding and removing now write the charger as well as the account: the device write is the app's own message, the account write is the inferred one that carries the name, and either may fail without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4c73d4ac05 |
0103 writes a card, 0104 asks for the list, 0108 a2=7 opens the reader
Caught on the charger's own command topic while a card was removed and added back in the Anker app. Enrolling at the charger is three MQTT messages and no REST call: open the reader, take the UID the tap publishes, write it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62b0a29598 |
The reader speaks: 0904 is the card list, 0908 is the tap
Named from a live capture on an A5191: 0904 carried the nine UIDs the account's own card list answers with, and 0908 arrived the moment a card touched the reader, carrying its UID — and arrived without one when the window closed empty. 0911 names the OCPP backend the charger is pointed at. A UID is bytes, not a number, so type 0x04 now reads as hex. With the frame log on, the command topic is subscribed too: the app's own commands are the half no capture has seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a7719fca6a |
A line per frame, for the frames nobody has named
ANKER_MQTT_FRAME_LOG logs every inbound cloud frame with its bytes, decoded or not — the ones this package drops are exactly the ones worth naming, so they are logged before the drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
245870a96a |
The add and remove buttons, and the read that checks them
Anker documents neither rfid write, so the bodies are inferred from the field names get_device_cards answers with, and every write re-reads the list: what the card shows is what the account holds, never what an undocumented endpoint claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a5842201c0 |
The cards that open the charger get a card of their own
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4188d049cb |
An email is an email, whichever list it arrived in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
928a39e03e |
Each RFID card as a card, not as list[3]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
905eb24cc1 |
The service's own field names, said in the card's words
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0657decbd1 |
The sign-in fields say nothing until you type
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ff6242c8f |
The last message in the map, and it reboots the charger
0108 was the one thing in the MQTT inventory nobody had wired: the device power mode, whose single documented value restarts the charger. It is the only way to reboot a charger that is on neither a CSMS nor the local network — which is most of them — so the cloud transport sends it now, and "reset" reaches it too, since that is what the OCPP path has always called the same act. Nothing waits for a confirmation: the device that would send it is the device rebooting, so the command answers at once and says the charger drops off the cloud for about a minute. The gate is unchanged and now covers both spellings — an explicit confirm plus a password step-up, audited either way. Modbus still refuses, because no register does this, but its refusal now names both transports that can rather than only the CSMS. Both clients already had the reset button and its password prompt; they were hidden in every mode that reads the device, which is why the cloud never showed one. Modbus is now the only mode without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
90558d60b2 |
What the app can set, the cloud connection can set
The broker transport could move a session along — start, stop, boost, skip the delay, cap the current — and nothing else. Everything the charger is actually configured with sat one field away in the same messages we were already decoding: the schedule it charges on, the plug lock, auto-start, the LED, load balancing, solar charging, and the Modbus server the local transport depends on. Readable, and unreachable. The obstacle was never the cloud, it was the shape of the protocol. A setting is not a register write. It is a *command*, and a command owns a set of fields inside a message type — mostly one, but five own several, and the charger reads the whole command as the new truth. A light-off schedule sent carrying only its switch is a schedule whose start and end have just been set to midnight. So a grouped write resends the siblings the caller did not name, using the values the charger itself last reported, and refuses when it has never reported them. That last part is not caution for its own sake: load balancing and solar charging carry the serial of the meter they watch, and nothing outside the charger knows it. An empty one would be adopted. Those values do not arrive with the telemetry, either. The fast 0410 stream a realtime trigger turns on carries none of them — the settings come on 0405, 0840 and 0900, which the charger sends when it has something to acknowledge. So a grouped write may have to send a trigger first purely to make the charger talk about itself, and says so plainly when even that produces nothing. Everything a caller supplies is encoded before the cloud is touched at all. A request naming one bad value changes nothing rather than half of what it asked for, and a mistyped setting costs a validation error instead of a sign-in, a certificate fetch and a broker connection to be told no. mqttsettings.go holds one table and it is the only place a setting is defined: the wire field, the name a caller uses, the state key its current value comes from, and how a value becomes bytes. The names are the snapshot's own, so a caller can read a status, change one entry and send it back. The existing limit command now builds its frame from that table too rather than encoding field a8 a second time. Reading grew to match. The frame decoder gains the fields the grouped writes must carry back — the two load-balance settings, both monitor serials, the solar monitoring mode — plus the swipe gestures, and the snapshot exposes the rest of what is now writable. One name was wrong and is corrected: field d9 was called chargingMode after the Modbus register at 20088, but the reference has it as the solar charging mode, so it becomes solarChargeMode and moves in beside the solar settings. A mislabelled reading is bad; a mislabelled writable field is worse. Over HTTP it is one action rather than a dozen, because the charger groups the fields anyway: POST .../settings with a settings object, and settings sharing a command travel in one frame instead of overwriting each other. The other two transports refuse it by name and say which one has it, the way they already refuse each other's commands. The audit trail records the values, not just that a write happened — a setting that changes what the charger will draw, or whether it answers on the LAN at all, is worth being able to trace afterwards. Two things worth saying plainly. This is built from the reference project's message maps and checked against its own frame layout, not against hardware — there is no charger on this end to point it at. And modbusEnabled is a loaded gun: writing it off stops the charger serving the register map, and the way back is this transport, or the app. The ignore rule for the local Modbus map artifact widens to the protocol maps that now sit beside it. 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> |
||
|
|
641f427db4 |
The chargers you own, on the phone as well
The web's Charging page grew a real home half while the phone kept a demo one. There, a charger is a record imported from a connected service; here it was a hardcoded row called "Home charger", and the only real thing on the tab was a single OCPP control card. Modbus had been a working transport for a while, and the phone had no way to give it the address it needs. The home tab is now the four cards the web shows, about whichever charger is picked, and the list of the ones you have imported. Control acts on the charger and offers what the transport actually has — boost on Modbus, clear-limit and reset on OCPP. Connection asks for a serial or an address depending on which, and falls back to a text box for a serial the account does not list. Readings render the Modbus snapshot the way it gets asked about: the per-phase matrix, what the charger is doing, what it is set to, what it is, and any alarm word. Information stands without a control mode at all, because what a charger is is known either way; beside it the service's own view of whether it is reachable, asked for when the tab is opened rather than on every build. Rearranging is the one place the two apps differ, for the reason the car's view picker already differs: the web drags the tab bar and the card headings, and on a touch screen the bar owns that gesture and a heading is the fold toggle. Both arrangements are made in a sheet with handles instead, and still saved to chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in either app shows up in the other. Which cards are folded stays on the device. Settings groups its integrations into the same categories the API Server panel does, says Online as well as Offline, and shows firmware in a charger's line. Shared strings are copied out of the web's i18n files rather than retyped, per TRANSLATIONS.md; only the arrange sheet's own three are written here. The readings and information cards look up some sixty keys by name at render time, so models_format_test now guards those the way it guards the car's — an enum value is deliberately left out, since a charger may report a number this release has never heard of and falling back to it is the point. 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> |
||
|
|
05f8bd07ed |
The register map, kept locally without keeping it in the repo
The Anker Modbus reference page is published as an artifact and lives in the project root as a working copy. It is generated rather than authored, and the published page is the one that gets updated, so tracking the local copy would only invite the two to drift apart in review. 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> |
||
|
|
aaa89dfe10 |
Relays that run at 33 degrees, not 331
The two relay temperatures came back as 331 and 319 from a charger sitting idle with nothing plugged in. The spec's gain column says 1 for both, so we reported them as 331 °C and 319 °C — a reading that would have meant a fire rather than a wallbox at room temperature. The gain is 10. The same table hands the maximum current setting a unit of watts and the timeout a unit of amps, so its unit and gain columns are not load-bearing here; what settles the alignment is the LED brightness two registers earlier, which reads exactly 100 at gain 1, and the fact that the neighbouring registers all decode as tabulated. Read back from the charger afterwards: 33.1 °C and 31.9 °C. The field becomes a float, as the voltages and currents beside it already are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cf4fd14b56 |
The table the charger actually keeps its measurements in
Modbus mode never returned a reading: every status poll came back as "the charger did not answer", though the charger was answering all along. It was refusing the question. The A5191 splits its map across two tables where the spec's single 2xxxx column suggests one — 20000-20100 are input registers and reject FC03 with an illegal-address exception at every address in the range, while 21000-21005 really are holding registers and read back over FC03. We inferred one space from the spec's layout and asked for all of it with FC03. The client learns FC04, sharing a body with FC03 since the two differ only in which table the server consults, and the plugin's two measurement reads move to it. Writes stay on FC06, where the controls already live. Confirmed against an A5191 on firmware 1.0.6.1: identity, live block and the control registers all decode as the spec tabulates them. 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> |
||
|
|
f7472bada3 |
Reach the charger where it is, instead of waiting for it to call
OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a route in through the customer's router. Our own handler then demanded two more things the V1 does not offer — TLS on a charger that connects over ws://, and Basic auth credentials the Anker app has no field for — so every connection was turned away before the upgrade. Anker publishes a Modbus TCP register map for this charger, and it inverts the problem: we dial the charger, on its own network, with no inbound reachability to arrange. That works for a charger behind a router that OCPP cannot reach at all. internal/modbus is the protocol, hand-rolled against the spec like the MQTT and WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same 0-8 status enum the cloud already reports, per-phase measurements, and the writable registers behind start, stop, current limit, boost and phase mode. A new "modbus" control mode routes the existing control endpoints down it, so the REST surface, the rate limit, the confirmation step and the audit trail are the ones already there. The commands the register map has no equivalent for say so by name rather than failing as unknown, and a current below the charger's 6 A floor is refused because it pauses the charge rather than slowing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9a82a1cca |
One hostname a charger can be told about and actually reach
Charger control needs TLS, and the stack speaks plain HTTP, so the README said "terminate TLS in a reverse proxy" and left the operator to work out which four settings have to agree. This adds the proxy: a Caddy overlay that fronts the Web App BFF — which already carries /api/ and /ocpp/ — so browsers and chargers arrive at the same name and the certificate is issued on first boot. The four settings are derived from DV_DOMAIN, since one value getting typed right is better odds than four: OCPP_PUBLIC_URL, OCPP_REQUIRE_TLS back on, CORS, and TRUST_FORWARDED_PROTO on the Web App. That last one is the non-obvious one — without it the BFF overwrites Caddy's X-Forwarded-Proto with its own plaintext hop and the API Server rejects the charger it just told to connect over wss. The README's OCPP section was stale besides: it still sent chargers to the API Server port alone, from before the BFF carried that path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9f5c8dc49a |
The address chargers are given is now an address that answers
The API Server tells a charger to dial the host it was itself asked on. The panel asks through the Web App, so the address handed out is the Web App's — which proxied /api/ and nothing else, and answered the WebSocket handshake at /ocpp/ with index.html. A charger pointed at the endpoint the screen showed could never connect to it, and the screen went on saying "Not connected" without a hint as to why. Both front doors now carry /ocpp/ through to the API Server: the BFF via the same reverse proxy, which relays the 101 by hijacking, and the all-in-one image's nginx via a location of its own, with timeouts long enough for a session that is idle between heartbeats. The proxied hop also has to say how the client arrived, since the API Server reads X-Forwarded-Proto to decide a charger reached it over TLS. That header is set from this server's own connection and overwrites whatever came in: believing a client on that point would let a plaintext charger claim wss and walk past OCPP_REQUIRE_TLS. TRUST_FORWARDED_PROTO opts into the inbound value for the one deployment where it is true — TLS ending at a proxy in front of the stack. 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> |
||
|
|
d71c1b4691 |
The panel header stops folding "Sign out" onto two lines
The console shell was capped at max-w-4xl (896px), narrow enough that the header row ran out of room and the last button wrapped mid-word. It now uses the same 1368px cap as the Web App shell, so the header has the space it always assumed it had. Rebuilt the embedded dist to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
190ae923a6 |
The Anker token outlives the request that fetched it
The manager builds a throwaway plugin instance for every per-user call —
HealthCheckWith, InvokeWith, InvokeBatchWith each construct, Init, probe and
Shutdown. The auth token lived on that instance, so it died with the HTTP request
that fetched it: opening the Anker panel signed in once for the health probe and
again for the charger list, and a page that also asked for OCPP info signed in a
third time. Every refresh, a fresh login.
Anker throttles passport/login per IP per minute and answers code 26161 ("Failed
to request.") once tripped, so this is the shape of the failure the panel has been
reporting; the cloud has also historically kept one token per account, so each of
those logins could evict the one the mobile app was holding.
Tokens and the login backoff now live in a package-level session keyed by the
account signing in, so every instance configured for that account shares one
login. Re-configuring the same credentials keeps the token; a different account,
or the same account on the other regional server, gets its own session. Sessions
unused for a fortnight are pruned, so an edited password does not leave its entry
behind for the life of the process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c1aee0fac1 |
One refused sign-in, not five: the chargers poll no longer locks the account
A chargers poll asks four cloud views. Each called apiRequest, each found no token, and each ran its own login — so a login Anker refuses was offered four times in one poll, and the next poll spent the fifth. Five is what disables the account for ten minutes, which is how "code 26161: Failed to request." turned into "your account has been disabled" on the very next attempt. The plugin now remembers a refused login instead of repeating it: the failure is cached and replayed to every caller until a backoff window passes — a minute at first, doubling to fifteen, or the full ten minutes when Anker says it has already locked the account (code 10019). New credentials clear it, so a fixed password is tried at once. chargerInventory signs in once up front. A login the cloud refuses is not four views failing, so it is reported as itself rather than as three warnings with the lockout notice buried in the last one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
423bc2ab16 |
The charger list sorted by a field the collection never had
Opening Home chargers answered {"status":400,"message":"Something went wrong
while processing your request."} — PocketBase's generic refusal, here for an
unknown sort field. home_chargers declares its own fields and nothing else:
PocketBase adds no created field to a collection defined through the API, which
is exactly why control_audit and organizations declare theirs. The list handler
sorted by created anyway. It was the only handler in the server that sorts by
created — every other one sorts by name, km or date, fields their collections
actually declare — so the gap had never had a chance to show.
The field is now declared, and reconcile adds it to the collection already
standing on the next boot, since home_chargers is in reconcileOrder. Import order
is the only order a charger has: it carries no date of its own, and a wallbox
bolted to a wall does not accumulate events the way a car does.
The list also stops depending on that. A rejected sort now falls back to the
unsorted query rather than failing the request: the order is a nicety, the list
is not, and an owner reading a database error about a field they cannot see is
the worst of both. It also makes the deploy order stop mattering — the page works
before the bootstrap has run, and the sorted query wins once it has.
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> |
||
|
|
a7cab50e06 |
Apprise: a gateway to hand a message to, not a hundred protocols to carry
Apprise is a Python library that speaks 100+ notification services behind one URL grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a server that takes no dependencies, and none of it needs to be: caronc/apprise-api wraps the library in HTTP and is meant to run as a container beside us. So the connector carries no notification protocols of its own. It posts a body to an endpoint the operator runs and lets Apprise fan it out, which is also why adding a service later costs nothing here. Targets are addressed one of two ways and configKey is the switch. Stateful means the URLs live on the Apprise server under a key, narrowed by a tag expression, and recipients are then edited there — no credential for any downstream service is ever held in DriverVault. Stateless means the URLs travel with the request, from a secret config field, which is simpler for one destination and worse for ten. A call that names its own key or urls takes that destination alone rather than merging with the configured one: honouring a caller's URLs while still falling back to the configured key would deliver the message somewhere nobody asked for. baseUrl is Required, which no other connector's address is. Toyota, Anker and Greencell leave everything blank at the global layer because the superadmin → org → user cascade exists to fill it in, and a blank there means "let the user choose". There is no cascade behind this one — a notification gateway is infrastructure the operator runs, not an account a driver owns — so nothing further down can supply the address, and a blank is simply a plugin that cannot work. Better to fail at enable than at the first notification nobody sees. Three limits are choices rather than gaps. /add and /del are not implemented: the Apprise config belongs to the operator, we post to it, and a connector that can delete a notification config has a wider blast radius than one that can only send through it. privacy=1 is forced on /json/urls rather than offered as a parameter, so a target listing reads mailto://user:****@host and downstream tokens stay on the Apprise side of the wire. Attachments are remote URLs the Apprise server fetches; multipart upload is the API's own path for files and not ours. Health follows the rule Greencell set. A reachable server whose config holds nothing to notify is degraded, not down: the half we address works and the missing half is the operator's config. Two cases earn their own line — a config key set against a server running with stateful mode disabled can never resolve, and /status answers 417 rather than 500 when Apprise finds a problem with itself, so that is a parsed answer and not a transport failure. A proxy that strips our Accept header gets the same codes back as plain text, which is read rather than called unreadable; an HTML error page from something that is not Apprise is not, and a test pins the difference. Notifications needed a category of their own, and that is the one change outside the plugin: the constant, the tab order in PluginsCard.vue, and the label in all three panel languages. The cost is now written down in the plugins README beside the Descriptor example, since the previous five categories predate anyone having to add a sixth. The plugin's tests run against an apprise-api stand-in built from that project's views.py — both notify paths, the override rules, 204-as-empty against 424-as-failure, and every health branch. builtin_test.go is the other half: the blank-import list in builtin.go is a silent failure mode, since a connector left out of it compiles, passes its own tests, and never appears in the panel. What is not covered is a live instance; there is no Docker on this machine, so the wire contract comes from reading upstream's source rather than from running it, and a smoke test against a real deployment is still worth doing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a49d48f659 |
The panel's webfonts, in the one position CSS accepts them
The lockup was the visible symptom and the wrong suspect. Matching it to the Web App's component changed nothing a reader would notice, because the panel was not rendering Archivo at all — it was rendering system-ui's italic bold, which is a different letterform at the same size, and had been since the stylesheet was written. The Google Fonts @import sat after @import "tailwindcss". Tailwind v4 inlines its import into the rules it generates, so anything importing after it is no longer at the top of the sheet, and CSS drops an @import that follows real rules. The built stylesheet carried zero occurrences of fonts.googleapis.com; the build had been saying so on every run, in a warning easy to read as noise about a comment. Moving the font import above Tailwind's is the whole fix, and the Web App's own stylesheet has always had that order with a comment explaining it — that comment comes across, plus what it cost here. This was never only the wordmark. Every rule reaching for --font-sans or --font-mono was falling back too, which is the entire panel: the section nav, the card titles, and the endpoint tables whose monospace is how a path reads as a path. Checked against the built bundle rather than the dev server, since the dev pipeline is exactly what was hiding it: the page now reports Archivo italic 800 loaded, and the wordmark measures 115.05px — the same width the Web App's rail lockup measures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e648634ce1 |
The plugin list, grouped by what a plugin actually is
Category has been in the plugin contract since it was written — apis-external, drives-external, drives-local — and every builtin declared the same one, so it grouped nothing. Two of the three talk to a wallbox and one talks to a car manufacturer, and those are different questions an operator arrives with: the Toyota card is where a driver's account gets linked, the Anker and Greencell cards are where a charger's broker and credentials live. So vehicles and chargers join the constants and the three builtins say which they are. The panel groups on that field rather than on a list of names, which is what keeps an external plugin from needing panel code. Tab order mirrors the constants; a category with nothing in it gets no tab, and a single group hides the bar entirely, so an install with one connector looks exactly as it did. A category the panel does not recognise — or an empty one — falls to the external-APIs tab rather than vanishing, because a plugin nobody can see is a plugin nobody can disable. The selected tab falls back to the first group when its own goes away, which is what removing the last external plugin does. Registration still asks only for name, base URL and provider, so a plugin registered at runtime lands under Other APIs until its manifest names a category. That path already works and is the honest default: the panel is guessing about a service it has never spoken to, and the service can say. The header lockup is the other half. It was a copy of the Web App's mark rather than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter than brand-400. The Web App's Logo.vue moves in verbatim, props included. The one thing it cannot inherit is which variant to render: the Web App's rail is always dark, while this panel flips with its own theme toggle, so on-dark is bound to the theme and the hand-rolled bar fills that existed to survive that flip are gone. 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>
|
||
|
|
5e6b8b4b1c |
Anker Solix: the charger's mode, and the modes it can be moved into
The connector was written against anker-solix-api v3.7.0 and upstream is at 3.8.1 now. The reassuring half of the check first: nothing we depend on moved. The passport/login ECDH exchange, the headers, and every endpoint path this plugin calls are identical across v3.7.0...v3.8.1 — the only apitypes movement touching an EV charger was get_device_rfid_cards being reordered within its own dict. The 400 new lines in charger.py are the A2345 USB charger, which shares a filename with our device and nothing else. What did land for the V1 is two entries in the release notes, and both are MQTT: 3.8.0 gave standalone chargers the usage-mode entity they were missing, 3.8.1 added a switch that reads those modes as a plain on/off so EVCC and its like have a binary to hold. We control chargers over OCPP, not MQTT, so the command path is not ours to port. The reading of state underneath it is, and that half does come over the cloud. So charger-state. The status code arrives under two different names depending on which system family a site belongs to — operating_state inside a scene's charging_pile_list, evChargerStatus inside HES system running info — and upstream's poller quietly renames both to ev_charger_status on ingest, which is the tell that they are the same number. We ask both and merge, because a site answering only one of them is the normal case rather than a fault; the call fails only when neither view is there. chargerMode and chargerModeOptions then follow ev_charger_mode_state and ev_charger_mode_options as written, including the rule that a stopped charger is startable only from standby, and the binary is the same one 3.8.1 chose: everything that is not stop_charge counts as on. The gap worth naming is that the boost flag and the plug and start countdowns reach upstream over MQTT and never over the cloud, so three of the six modes cannot occur here. That is not a bug to be found later — chargerMode takes them as parameters and the callers pass their zero values, so the day an MQTT source exists the derivation is already correct and only its inputs change. The package doc says so in the scope list beside the other limits. Five endpoints upstream has had all along and we never exposed come with it, all EV-charger-scoped: the site scene, energy_analysis under device_type ev_charger, a charger's RFID cards, Anker's own OCPP endpoint list, and one vehicle's details. charger-status takes the featuretype it was hardcoding at 1, since upstream's exporter asks for both 1 and 2 and there was never a reason for us to see only half. 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> |
||
|
|
a203414ceb |
Phone App: the garage on the car's own screen
The one place a service badge is worth reading is the car it belongs to, and that is the one place the app could not be opened: Android Auto runs no Flutter engine, so a Flutter app is simply absent from the head unit. The same APK now carries a second face — Car App Library templates the host draws itself, in Kotlin under android/app/src/main/kotlin/com/drivervault/phoneapp/car/. Two screens. The garage lists a car per row with its due badge on the second line, worst first, because the host renders only the first handful of rows and the car this list exists to mention is the overdue one rather than whichever was added first. A tap opens what that car has coming: the odometer, the next service, and the reminders the server holds for it — typed in and auto-derived from documents and the service schedule alike, in the order it sorted them. None of it is a second implementation of the app. VaultStore reads the session the phone signed in with — the active server's base and token — out of shared_preferences' own store, which both halves share, so a server switched on the phone is the server the car reads from with nothing to keep in step; only cc_active_base is new, because an untouched home entry carries no address of its own, its base being kDefaultApiBase, a compile-time define nothing outside Dart can see. CarStrings reads the same assets/i18n files by the same dot paths, so a badge on the head unit is the string format.dart already puts on the phone, in the language the account chose: of the 32 keys the car screens ask for, 28 are keys a phone screen already used, and only carApp.* is theirs. CarFormat is format.dart's twin — same date pattern and number grouping from the account's settings, same worst-of-date-and-km service badge. A new test reads the Kotlin for the keys it looks up and fails if any is missing from a language file, since the analyzer's reach stops at the Dart. It only reads. A screen you cannot type into is a poor place to edit a car and a driver is a poor person to ask, so VaultApi has no write in it to reach for by accident. Three things the README now says out loud. The service is declared IOT, the closest category the library defines for something that is a garage rather than a map or a media player, which matters to a store submission and not to a sideload. The app lock does not reach the head unit: the flag is in memory and the credentials behind it in encrypted storage, neither readable from the car service, and there is no fingerprint reader in a dashboard to satisfy it with. And the home charger is not on there — the chargers endpoint relays its plugin's payload verbatim with no shape to read, and the serial the control card is driven by is never persisted, so the car would have nothing to name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fa569c4030 |
Sign in before the first call, not after a 401 that never comes
Logging in failed on both the Web App and the Phone App with PocketBase's
{"data":{},"message":"The requested resource wasn't found.","status":404}.
The login itself succeeded; the profile fetch right after it — GET /api/me
— was what 404ed, and the web app renders the relayed body on the login
form, so it read as a rejected sign-in.
PocketBase answers a record read it will not allow with 404 rather than
401: it hides the record instead of refusing the credentials. The pb
client only re-authenticated on a 401, so with no cached token the first
request went out carrying no Authorization header at all, came back 404,
and the retry never fired. Nothing ever tried again — the server kept
404ing long after PocketBase was healthy.
The cache is empty in exactly the two cases that matter: a startup where
the up-front Authenticate failed because PocketBase wasn't up yet, which
main.go treats as non-fatal on purpose so a superadmin can still log in
and fix the connection; and a Reconfigure from the panel, which clears
the token so the new credentials get used.
So acquire the token before the first attempt rather than hoping for a
401 to prompt it, across all four superuser paths. Bad credentials now
surface as the authentication failure they are instead of masquerading
as a missing record. With no service account configured there is nothing
to acquire and the call proceeds as before, since the endpoints that need
superuser access already answer 503 on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
12ec10a797 |
Phone App: more than one server, and a session for each
The web app can be pointed at two DriverVault stacks and switch between them in a click. The phone had one address and one session: reaching a second garage meant retyping the API base in Server settings and signing in again, losing the first server's token on the way — the same act, undone, every time you switched back. So lib/servers.dart is the web's servers.js ported rather than reinvented, down to the storage keys: cc_servers holds the list, cc_active_server the one being read, cc_session_<id> the token minted by that server and no other. The two apps describe the same thing the same way, and the upgrade path falls out of it — cc_token, cc_user and cc_server_url are read once at boot and folded onto the home entry, so the build carrying this signs nobody out. Home is the address the build ships with (kDefaultApiBase, still overridable per device from the login screen) and cannot be removed: it is what a dropped session falls back to. Any other server is added by address, with /api appended if the path is left off, because a server a phone can reach is internet-facing already. The part worth reading twice is which session a rejection ends. ApiClient no longer holds a base or a token — it pins the active server's id, base and token at the moment a request goes out, so a 401 arriving after a switch clears the session of the server that actually refused it rather than whichever one is active by then. The fallback is the web's: a remote server timing out drops its own token, the app returns to home while home is still signed in, and only when nothing is left to fall back to does the login screen come back. Log out still clears every server at once, since leaving the app means leaving all of them. Switching rebuilds the shell, keyed on the active id, because record ids belong to the server that issued them — a garage, a charging page and a settings panel still holding the other server's rows would each have to be told to forget them separately. The appearance prefs come across with the profile of whoever owns the account on the server now active. Where the picker lives is the one place the phone cannot copy the web. There is no app rail here, so it became the first button in the Garage header, beside the theme toggle and log out, which is that same cluster. It names the active server once there is a choice and goes straight to adding the second when there isn't; the eyebrow reads GARAGE · Work for the reason the rail names it — two garages otherwise look identical. The login screen gets its own way in, because a remote session can expire and land you there with that server still active, and a picker reachable only from inside the app would leave nowhere to go. One judgment call inside the sheet: saving a connected server at a new address saves and stops, rather than falling through to the sign-in it now needs. The token was minted by the PocketBase behind the old address and is dropped with it, but the credentials to replace it were never asked for, so treating the save as a login would report an empty password as the error. The strings are copied out of Web App/web/src/i18n/ like the rest of the shared wording. Two are not the web's: home reads "the address this app ships with" rather than "served with this app", since the phone has no origin to be served from, and sameOrigin has no meaning here at all and was dropped. Biometric sign-in stays global. It was never per-server and replays its stored credentials against whichever server is active; making it per-server is a change of its own, and the login screen now names the server it is about to sign into. Nothing changes on the API Server. On Android there is no origin to allow, so the CORS list the web app has to satisfy to reach a second server doesn't enter into it. Verified: flutter analyze is clean and flutter test passes, 35 tests to 46. The new ones cover the registry — a bare origin gaining its /api, a fresh install knowing one unnamed server on the built-in address, the legacy keys landing on home and being cleared, two servers holding their tokens apart, a rename keeping a session where a move drops it, removing the active server falling back to a home that is still signed in, home refusing to be removed, and a restart reading the list, the active id and every session back. Not verified: none of it has been run. There is no device or emulator on this machine and no API Server to answer, so the picker, the add sheet, a real connect, the 401 fallback and the shell rebuild on a switch exist only as code the analyzer is happy with — the tests reach the registry, not a screen. No APK was built. The legacy migration was exercised against mocked SharedPreferences, which is not a phone that had the old build on it: that is the first thing to check on a device, since the failure mode is a silent sign-out. 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>
|
||
|
|
7718b32013 |
Phone App: off the plugins that bring their own Kotlin
flutter build apk warned that file_picker and shared_preferences_android apply the Kotlin Gradle Plugin themselves, and that a future Flutter will refuse to build an app whose plugins do. Both have versions that let Flutter's built-in Kotlin do it instead; neither of them is a version bump on its own. shared_preferences_android was free — 2.4.27 is inside the constraint that was already there and only pub.lock was holding it back. file_picker is not: 10 and 11 both apply KGP, so 12 is the floor, and 12 split into federated packages whose windows one wants win32 ^6, which flutter_secure_storage 9 forbids. So the fix reaches flutter_secure_storage, and that is the part worth reading twice. v11 satisfies win32 but its changelog is explicit: data written by a version before v10 is unusable after it, because v10 is what migrates the Jetpack Security (EncryptedSharedPreferences) backend Google deprecated to the package's own ciphers. Going 9 to 11 in one step would leave the stored credentials unreadable and quietly switch biometric login off for anyone who had it on. v10 satisfies win32 ^6 just as well, so the constraint is pinned below 11 with the reason written down: once a build carrying v10 has run on every device that had biometric login enabled, the ceiling can go. encryptedSharedPreferences: true goes with it — v10 ignores the parameter and migrates on first access, and v11 has removed it. file_picker 12's API is smaller and the call sites got smaller with it. FilePicker.platform.pickFiles returning a result whose files list had to be checked for emptiness becomes FilePicker.pickFile returning one nullable file, which is what both callers wanted. PlatformFile.bytes (populated only when withData was asked for) becomes readAsBytes(), so the "bytes, or read the path, or give up" ladder both callers carried is one await — and the give-up branch that raised errors.noFile and the import's notJson is gone, because a file that was picked can now always be read. Verified: flutter analyze is clean and flutter test still passes 32. flutter build apk --debug succeeds and prints no KGP warning, where the build before this named both plugins. Not verified: nothing was exercised on a device — the phone came off USB before the reinstall, so this APK has not run. The two things to try first are the ones that changed under the picker: attach a PDF to a service record, and Settings, data, import a previously exported JSON. Biometric login is the third — it should survive, since v10 migrates rather than resets, but a device that had it on is the only place that claim can be checked, and if the migration does fail the app treats it as stale credentials and asks for the password. Android is the only target built; the win32 bump underneath is untested because this app has no windows/ folder to build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b7abb4b84 |
Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.
A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.
The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.
Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.
Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.
Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.
Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.
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> |