166 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 3c34b708b9 A mode that does not work stops being on the menu
The control-mode picker offered all five paths to everyone, always. When one of
them is broken in a deployment — OCPP is, right now — there was nothing to do
about it: the superadmin could pick a different mode for the global layer, but
the option stayed in every organization's and every user's dropdown, waiting to
be chosen. The cascade could impose a mode. It could not withdraw one.

So each layer now carries a second, separate thing: a list of the modes it hides
from the layers below it. controlModesDisabled sits beside controlMode, on the
global layer as a plugin config field and on an organization as part of the same
pluginSettings blob its credentials already live in. A superadmin ticking Own
CSMS and Proxy CSMS takes both OCPP paths out of every picker underneath; an org
admin ticking Modbus takes it out of their own users'.

Three decisions are worth naming.

A hide-list governs the layers below, not the layer holding it. The superadmin
can keep running Proxy globally while hiding it from everyone else, which is
what you want while a mode is being repaired rather than retired: the operator
testing the fix is the one person who still needs to select it. The alternative,
a list that also invalidates its own layer's choice, would have made the panel
contradict itself — a mode chosen in one field and switched off in the one below
it.

But a hidden mode really is hidden, not merely absent from a dropdown. A user
who had picked Proxy last month stops resolving to Proxy the moment the
superadmin hides it, and falls back to monitoring only. Filtering the picker
alone would have left every existing charger on the broken path and quietly
disagreed with the list the operator had just filled in. Resolution now walks
the layers accumulating what each hides from the next, so a stored value only
takes effect if the layers above it still permit it.

And off is never hideable. It is what a charger falls back to and what an empty
cascade resolves to, so a layer that could take it away could leave the layer
below with a picker holding no valid choice at all. It is not among the
checkboxes in any of the three clients, and the parser drops it if it arrives
anyway.

The panel needed a field shape it did not have — several options, any number
chosen — so ConfigField grows a "multiselect" type, stored as the
comma-separated string that fits the flat map every other field already uses.
That is generic: any plugin can declare one now, and the PUT body is unchanged.
The phone's field specs grew the same way, a scopeOptions hook that narrows a
declared option list to what the server still offers, rather than teaching the
integration card about control modes specifically.

Both clients clamp a stored mode that has since been hidden back to off before
drawing the picker, so the box shows what will actually happen rather than a
choice that would be dropped on save.

Verified: Go tests pass, both frontends build, flutter analyze is clean, and the
panel's new checkbox field was rendered against the real stylesheet. The
end-to-end path — superadmin hides a mode, an org admin and then a user reload
and find it gone — has not been walked on a live stack; the panel is embedded in
the Go binary, so the remote deployment needs a rebuild before any of this is
visible there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:54:30 +02:00
tajniak81andClaude Opus 5 d4b9d0870e The server notices its own token has run out
Signing in worked and everything after it did not: the login screen took the
password, PocketBase handed back the record and the token, and then /api/me
answered "The requested resource wasn't found." The three symptoms underneath
looked unrelated — a 404 on the profile, an organization whose name came back
empty, a user list with nobody in it — and they are one thing. The API Server's
superuser token had expired.

PocketBase does not say so. A record call carrying a token it no longer accepts
is not refused; the header is ignored and the request is served as a guest, and
the collection rules answer in the transport's place. The organizations
collection is superuser-only, so it 403s. A user record is guarded by a view
rule, so it 404s — hidden rather than denied. The users list is rule-filtered,
so it comes back 200 with an empty array. Not one of those is a 401, and a 401
was the only thing that made this client sign in again.

So the token was acquired once at startup and then kept for the life of the
container, and the retry meant to renew it could never fire. Uptime longer than
the token's lifetime was all it took. Nothing had to change for it to break,
which is why it broke on a stack nobody had touched.

The client now reads the exp claim PocketBase stamps into the token and renews
before spending it, a minute early so a call cannot land just after it lapses.
A token with no readable expiry is still taken at face value, and the 401 retry
stays where it is as the backstop for the case this does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:52:39 +02:00
tajniak81andClaude Opus 5 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>
2026-09-04 20:35:42 +02:00
tajniak81andClaude Opus 5 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>
2026-09-04 19:32:19 +02:00
tajniak81andClaude Opus 5 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>
2026-09-04 15:11:19 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 23:32:07 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 23:12:10 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 22:47:30 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 22:05:40 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 21:19:07 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 21:13:42 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 21:01:01 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:51:57 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:34:51 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:29:49 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:26:38 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:21:28 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 20:10:13 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 18:53:48 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 18:32:52 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 18:20:33 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 15:19:57 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 14:35:27 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 13:45:00 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 13:30:14 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 13:04:52 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 12:08:53 +02:00
tajniak81andClaude Opus 5 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>
2026-09-03 10:50:00 +02:00
tajniak81andClaude Opus 5 a5842201c0 The cards that open the charger get a card of their own
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:36:52 +02:00
tajniak81andClaude Opus 5 4188d049cb An email is an email, whichever list it arrived in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:00:48 +02:00
tajniak81andClaude Opus 5 928a39e03e Each RFID card as a card, not as list[3]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:57:51 +02:00
tajniak81andClaude Opus 5 905eb24cc1 The service's own field names, said in the card's words
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:33:03 +02:00
tajniak81andClaude Opus 5 0657decbd1 The sign-in fields say nothing until you type
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:51:19 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 23:15:12 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 23:05:40 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 22:53:36 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 22:18:45 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 21:45:08 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 21:31:43 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 21:01:24 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 20:58:15 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 20:22:50 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 18:28:45 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 16:47:10 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 10:50:00 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 10:28:41 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 10:00:54 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 09:11:54 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 09:04:04 +02:00
tajniak81andClaude Opus 5 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>
2026-09-02 08:52:20 +02:00