Compare commits

...
33 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
94 changed files with 14431 additions and 322 deletions
+16
View File
@@ -379,6 +379,22 @@ Copy `.env.example` to `.env` and fill in. Summary:
| `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at |
| `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) |
| `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent |
| `PB_S3_ENABLED` | `false` | keep PocketBase's record files in an S3 bucket instead of on its own volume |
| `PB_S3_BUCKET` | `drivervault` | the bucket; it must already exist |
| `PB_S3_ENDPOINT` | — | e.g. `http://seaweedfs:8333`. No default: in-stack and external gateways are different addresses |
| `PB_S3_REGION` | `us-east-1` | SeaweedFS ignores it, PocketBase insists on one |
| `PB_S3_ACCESS_KEY` / `PB_S3_SECRET` | — | S3 credentials |
| `PB_S3_FORCE_PATH_STYLE` | `true` | path-style bucket addressing; `false` for AWS S3 proper |
The `PB_S3_*` block is applied by the same on-boot bootstrap that creates the
collections, and only when **all** of bucket, endpoint and credentials are set —
a half-filled config logs a warning and leaves uploads on the local volume. It
writes PocketBase's *Files storage* settings and nothing else: backups stay
where they are, and it never turns S3 back *off*, since files already in a bucket
are reachable only while PocketBase still points at it. Attachments are served
through this server either way ([`internal/api/attachments.go`](internal/api/attachments.go)),
so no client can tell the difference. See [`../Docker`](../Docker) for the compose
files that set these.
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
still honoured for older deployments; the modern names win when both are set.
+23
View File
@@ -51,12 +51,31 @@ func main() {
// bad service account, must not stop the panel from coming up so a superadmin
// can log in and fix the connection.
if cfg.Bootstrap && cfg.AdminConfigured() {
// Record files go to S3 only when the whole bucket is described. Asking
// for it and leaving half of it blank is a misconfiguration worth saying
// out loud, not a reason to point PocketBase at nowhere.
var s3 *bootstrap.S3Options
if cfg.StorageConfigured() {
s3 = &bootstrap.S3Options{
Enabled: true,
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
Endpoint: cfg.S3Endpoint,
AccessKey: cfg.S3AccessKey,
Secret: cfg.S3Secret,
ForcePathStyle: cfg.S3ForcePathStyle,
}
} else if cfg.S3Enabled {
log.Println("WARNING: PB_S3_ENABLED is set but the bucket, endpoint or credentials are incomplete — file storage stays on the local volume")
}
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
UsersCollection: cfg.UsersCollection,
SuperAdminEmail: cfg.SuperAdminEmail,
SuperAdminPassword: cfg.SuperAdminPassword,
SuperAdminName: cfg.SuperAdminName,
S3: s3,
}); err != nil {
log.Printf("WARNING: bootstrap failed: %v", err)
} else {
@@ -73,6 +92,10 @@ func main() {
// endpoints answer 503 until the read succeeds.
_ = srv.StartPlugins()
// The home-charger scheduler's clock. A schedule that only fires while a
// browser tab is open is a reminder, not a schedule, so it is watched here.
srv.StartScheduler()
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
+406
View File
@@ -0,0 +1,406 @@
package api
// The home-charger scheduler: the user's own list of charging tasks.
//
// The charger's own cloud schedule can say one thing — "charge between these
// hours" — and it says it inside one charger. This is a list, and each entry is
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
// chargers, on these days. One named thing, switched on and off as one.
//
// GET /api/charging-tasks — the caller's tasks, earliest first
// POST /api/charging-tasks — write one
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
// DELETE /api/charging-tasks/{id} — forget one
// POST /api/charging-tasks/{id}/steps/{n}/run — fire one step now
//
// A task belongs to the person, like the chargers it acts on, so there is no
// sharing here: everyone sees their own list only. Firing one is the business of
// chargingtasks_run.go, which is also what the ticker calls.
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"drivervault/apiserver/internal/models"
)
// The actions a step may carry. Each is a control command the charger already
// understands; the scheduler adds no verbs of its own, so a task can only ask
// for something the buttons on the Charging page can ask for by hand.
var taskActions = map[string]bool{
"start": true,
"stop": true,
"limit": true,
"boost": true,
}
// maxTaskSteps bounds one task's flow. Well past any real schedule — a night
// rate is two or three steps — and it keeps a client from parking an unbounded
// blob on the record, which the sweep then reads every thirty seconds.
const maxTaskSteps = 24
// chargingTaskRecord is the PocketBase-facing shape of a task.
type chargingTaskRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Chargers []string `json:"chargers"`
Steps []models.ChargingStep `json:"steps"`
Zone string `json:"zone"`
Days []int `json:"days"`
Enabled bool `json:"enabled"`
LastRun string `json:"last_run"`
LastResult string `json:"last_result"`
Owner string `json:"owner"`
Created string `json:"created"`
}
func (rec chargingTaskRecord) toModel() models.ChargingTask {
// The lists are never null on the wire: the page iterates them, and a null
// would make "every charger" and "every day" read as an error there.
chargers := rec.Chargers
if chargers == nil {
chargers = []string{}
}
days := rec.Days
if days == nil {
days = []int{}
}
steps := rec.Steps
if steps == nil {
steps = []models.ChargingStep{}
}
return models.ChargingTask{
ID: rec.ID,
Name: rec.Name,
Chargers: chargers,
Steps: steps,
Zone: rec.Zone,
Days: days,
Enabled: rec.Enabled,
LastRun: rec.LastRun,
LastResult: rec.LastResult,
Created: rec.Created,
}
}
// firstStepTime is the time of day a task begins — its first step's, which is
// what the list is ordered by, so the evening's task sits below the morning's. A
// task with no steps cannot exist, but the ordering must not depend on that.
func (rec chargingTaskRecord) firstStepTime() string {
if len(rec.Steps) == 0 {
return "99:99"
}
return rec.Steps[0].Time
}
// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can
// name one of them without the rest being read as "clear these" — a task
// switched off from the list must not lose its flow on the way.
type chargingTaskBody struct {
Name *string `json:"name"`
Chargers *[]string `json:"chargers"`
Steps *[]models.ChargingStep `json:"steps"`
Zone *string `json:"zone"`
Days *[]int `json:"days"`
Enabled *bool `json:"enabled"`
}
// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted
// as well as "HH:MM"; anything else is not a time of day.
func parseHHMM(raw string) (int, int, bool) {
hh, mm, found := strings.Cut(strings.TrimSpace(raw), ":")
if !found || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 {
return 0, 0, false
}
h, err := strconv.Atoi(hh)
if err != nil {
return 0, 0, false
}
m, err := strconv.Atoi(mm)
if err != nil {
return 0, 0, false
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, false
}
return h, m, true
}
// normalizeTaskTime returns the padded 24-hour form of a time of day, or "" for
// anything that is not one.
func normalizeTaskTime(raw string) string {
h, m, ok := parseHHMM(raw)
if !ok {
return ""
}
return fmt.Sprintf("%02d:%02d", h, m)
}
// normalizeDays drops anything that is not a weekday number and sorts what is
// left, so the stored list reads the same however the client sent it. Empty
// stays empty, which means every day.
func normalizeDays(days []int) []int {
seen := [7]bool{}
for _, d := range days {
if d >= 0 && d <= 6 {
seen[d] = true
}
}
out := []int{}
for d, ok := range seen {
if ok {
out = append(out, d)
}
}
return out
}
// normalizeSteps validates a flow, keeping the steps in the order they were
// written.
//
// Not sorted by time: a night crosses midnight, and clock order would file
// "start at 23:00" last, behind the stop that closes it. Nothing about firing
// depends on the order — every step is timed on its own — so the order is free
// to be the one that reads as the intention it is.
func normalizeSteps(steps []models.ChargingStep) ([]models.ChargingStep, error) {
if len(steps) == 0 {
return nil, fmt.Errorf("a task needs at least one step")
}
if len(steps) > maxTaskSteps {
return nil, fmt.Errorf("a task can hold at most %d steps", maxTaskSteps)
}
out := make([]models.ChargingStep, 0, len(steps))
for i, step := range steps {
action := strings.TrimSpace(step.Action)
if !taskActions[action] {
return nil, fmt.Errorf("step %d: unknown action: %s", i+1, action)
}
at := normalizeTaskTime(step.Time)
if at == "" {
return nil, fmt.Errorf("step %d: time must be a 24-hour clock time, e.g. 23:00", i+1)
}
// The ceiling only means anything to "limit", but it is kept whatever the
// action is: switching a step to "limit" and back should not lose the amps
// that were typed. 6 A is the charger's own floor — below it the box pauses
// rather than charging slowly — and its ratings top out at 32 A.
if step.Amps != 0 && (step.Amps < 6 || step.Amps > 32) {
return nil, fmt.Errorf("step %d: the current limit must be between 6 and 32 A", i+1)
}
// A limit step with no ceiling would fire and ask the charger for 0 A.
if action == "limit" && step.Amps <= 0 {
return nil, fmt.Errorf("step %d: a limit step needs the amps to limit to", i+1)
}
out = append(out, models.ChargingStep{Action: action, Amps: step.Amps, Time: at})
}
return out, nil
}
// taskPayload turns a write body into the PocketBase fields it names, validating
// as it goes. `full` demands the fields a task cannot exist without, so a POST is
// checked as a whole and a PATCH only where it speaks.
func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
payload := map[string]any{}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
return nil, fmt.Errorf("name is required")
}
payload["name"] = name
} else if full {
return nil, fmt.Errorf("name is required")
}
if body.Steps != nil {
steps, err := normalizeSteps(*body.Steps)
if err != nil {
return nil, err
}
payload["steps"] = steps
} else if full {
return nil, fmt.Errorf("a task needs at least one step")
}
if body.Chargers != nil {
ids := []string{}
for _, id := range *body.Chargers {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
payload["chargers"] = ids
}
if body.Days != nil {
payload["days"] = normalizeDays(*body.Days)
}
if body.Zone != nil {
payload["zone"] = strings.TrimSpace(*body.Zone)
}
if body.Enabled != nil {
payload["enabled"] = *body.Enabled
} else if full {
// A task written without saying otherwise is on: nobody fills in a
// schedule in order to leave it switched off.
payload["enabled"] = true
}
return payload, nil
}
// listChargingTasks returns the caller's own tasks, the one that starts earliest
// first — the order the day runs them in, which is the order a schedule is read
// in. Ordered here rather than by PocketBase because the time a task starts is
// now inside its flow, which is not a column to sort on.
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var recs []chargingTaskRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sort.SliceStable(recs, func(i, j int) bool {
return recs[i].firstStepTime() < recs[j].firstStepTime()
})
out := make([]models.ChargingTask, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toModel())
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": out})
}
// ownedChargingTask fetches one task and checks it is the caller's. It writes the
// error response itself and returns ok=false when it is not.
func (s *Server) ownedChargingTask(w http.ResponseWriter, r *http.Request) (chargingTaskRecord, bool) {
var rec chargingTaskRecord
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeError(w, http.StatusBadRequest, "task id is required")
return rec, false
}
if err := s.pb.GetOne(r.Context(), colChargingTasks, id, &rec); err != nil {
writePBError(w, err)
return rec, false
}
// Someone else's task is not found rather than forbidden, as a charger is:
// whether a record exists is not this caller's business either.
if rec.Owner != s.currentUserID(r) {
writeError(w, http.StatusNotFound, "task not found")
return rec, false
}
return rec, true
}
func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body chargingTaskBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload, err := taskPayload(body, true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["owner"] = me
var rec chargingTaskRecord
if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"task": rec.toModel()})
}
func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
var body chargingTaskBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload, err := taskPayload(body, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()})
return
}
var updated chargingTaskRecord
if err := s.pb.Update(r.Context(), colChargingTasks, rec.ID, payload, &updated); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"task": updated.toModel()})
}
func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
if err := s.pb.Delete(r.Context(), colChargingTasks, rec.ID); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// runChargingStepNow fires one step of a task on demand — the "Run now" beside
// it. One step rather than the whole flow, because a flow is not a thing that
// can happen at once: running a start and the stop that closes it back to back
// would leave the charger where it began and prove nothing.
//
// It takes the same path the ticker does, so what comes back is exactly what
// that step will do at its own time, errors included. A switched-off task still
// runs from here: the switch says whether the clock fires it, not whether the
// button does.
func (s *Server) runChargingStepNow(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
n, err := strconv.Atoi(strings.TrimSpace(r.PathValue("step")))
if err != nil || n < 0 || n >= len(rec.Steps) {
writeError(w, http.StatusNotFound, "this task has no such step")
return
}
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
step := rec.Steps[n]
results := s.fireChargingSteps(r.Context(), who, rec, []models.ChargingStep{step})
s.recordTaskRun(r.Context(), rec, step, results)
// The same line that was just stored, so the row the caller updates from this
// answer reads identically to the one a page reload would fetch.
writeJSON(w, http.StatusOK, map[string]any{
"results": results,
"summary": taskRunLine(step, results),
})
}
@@ -0,0 +1,379 @@
package api
// Firing the scheduler's tasks: the ticker that watches the clock, and the one
// path a task takes whichever set it off.
//
// The scheduler is only worth having if it fires with nobody looking, so the
// clock is watched here rather than in the browser — a schedule that needs the
// page open is a reminder, not a schedule. The ticker sweeps every enabled task
// on the server, of every user, and fires whichever ones' minute has come.
//
// A task acts by sending exactly the control command the Charging page's own
// buttons send: the request is built here and handed to the same handler, so it
// goes through the same cascade, the same ownership gate, the same rate limit
// and the same audit trail. Nothing about a scheduled command is privileged —
// what the owner cannot press by hand, the scheduler cannot send for them.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
"drivervault/apiserver/internal/models"
)
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
// twice a minute is enough to land in every minute without the drift a
// once-a-minute ticker accumulates.
const taskTick = 30 * time.Second
// How long one sweep may take. Generous rather than tight: the cloud transport
// alone allows 45 seconds for a single command, and a task can name several
// chargers. A sweep that overran would not overlap the next one — the ticker
// drops a tick nobody is waiting on — but it would abandon chargers halfway
// down a task's list, which is the worst of both.
const taskSweepTimeout = 5 * time.Minute
// taskRunResult is what happened to one charger in one firing.
type taskRunResult struct {
Charger string `json:"charger"` // home charger record id
Name string `json:"name"`
Serial string `json:"serial,omitempty"`
Status string `json:"status,omitempty"` // the charger's own answer ("Accepted", …)
Error string `json:"error,omitempty"`
}
// StartScheduler starts the ticker that fires due charging tasks. It is safe to
// call before PocketBase is reachable: a sweep that cannot read the tasks simply
// finds none and the next one tries again.
//
// One server runs one scheduler. Two API Servers pointed at the same database
// would both sweep and both fire; the deployment is a single server (see the
// README), and the last_run guard below narrows the window rather than closing
// it.
func (s *Server) StartScheduler() {
ctx, cancel := context.WithCancel(context.Background())
s.schedulerStop = cancel
go func() {
t := time.NewTicker(taskTick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.sweepChargingTasks(ctx)
}
}
}()
}
// sweepChargingTasks fires every task whose minute has come.
func (s *Server) sweepChargingTasks(ctx context.Context) {
if !s.pb.Configured() {
return
}
ctx, cancel := context.WithTimeout(ctx, taskSweepTimeout)
defer cancel()
res, err := s.pb.List(ctx, colChargingTasks, url.Values{
"filter": {"enabled=true"},
"perPage": {"500"},
})
if err != nil {
// A database that is not answering is not an error worth a line every
// thirty seconds; the tasks are still there when it comes back.
return
}
var recs []chargingTaskRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return
}
now := time.Now()
for _, rec := range recs {
due := dueSteps(rec, now)
if len(due) == 0 {
continue
}
who, _, err := s.callerForUser(ctx, rec.Owner)
if err != nil {
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
continue
}
// Two steps of one task timed to the same minute contradict each other,
// but nothing stops somebody writing them, so both are sent in the order
// the flow holds them rather than one silently winning.
for _, step := range due {
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
s.recordTaskRun(ctx, rec, step, results)
log.Printf("scheduler: task %s (%s) step %s %s: %s",
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
}
}
}
// dueSteps returns the steps of a task whose minute has come — usually one, and
// none at all for the rest of the day.
//
// The times are read in the task's own zone: the browser that wrote it said
// which, and the server's clock is not the one the user set 23:00 by. A zone the
// host has no database for falls back to the server's own rather than silently
// shifting the schedule to UTC.
//
// A minute that passed while the server was down is not caught up afterwards. A
// charging window that opened an hour ago is not a window anyone still wants
// opened, and firing a backlog on boot would be the surprising half of the
// choice.
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
loc := time.Local
if rec.Zone != "" {
if l, err := time.LoadLocation(rec.Zone); err == nil {
loc = l
}
}
local := now.In(loc)
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
return nil
}
// The guard against firing twice: the sweep runs more often than once a
// minute, so a task that has already run inside this minute is done. It is
// per task rather than per step because two steps never share a minute in
// any flow that means anything — and when they do, they are sent together.
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
if !last.Before(local.Truncate(time.Minute)) {
return nil
}
}
var due []models.ChargingStep
for _, step := range rec.Steps {
h, m, ok := parseHHMM(step.Time)
if !ok {
continue // a step whose time is not a time of day fires nothing
}
if local.Hour() == h && local.Minute() == m {
due = append(due, step)
}
}
return due
}
func containsDay(days []int, day int) bool {
for _, d := range days {
if d == day {
return true
}
}
return false
}
// fireChargingSteps sends the given steps to each of the task's chargers and
// reports what each one said. A task naming no chargers acts on every charger
// its owner has — "all of them" is a standing wish, so a charger imported after
// the task was written is covered by it too.
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
chargers, err := s.ownerChargers(ctx, rec.Owner)
if err != nil {
return []taskRunResult{{Error: err.Error()}}
}
wanted := rec.Chargers
results := []taskRunResult{}
for _, c := range chargers {
if len(wanted) > 0 && !containsID(wanted, c.ID) {
continue
}
out := taskRunResult{Charger: c.ID, Name: c.Name, Serial: c.Serial}
if c.Serial == "" {
// Every transport addresses a charger by its serial, so one without
// is not a charger anything can be sent to.
out.Error = "this charger has no serial number, so no command can be addressed to it"
results = append(results, out)
continue
}
for _, step := range steps {
one := out
status, err := s.sendStep(ctx, who, c.Serial, step)
if err != nil {
one.Error = err.Error()
} else {
one.Status = status
}
results = append(results, one)
}
}
if len(results) == 0 {
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
}
return results
}
// ownerChargers reads one user's imported chargers. The scheduler reads them
// itself rather than trusting the ids on the task: a charger removed from the
// account should stop being acted on, whether or not the task was edited.
func (s *Server) ownerChargers(ctx context.Context, owner string) ([]homeChargerRecord, error) {
res, err := s.pb.List(ctx, colHomeChargers, url.Values{
"filter": {fmt.Sprintf("owner='%s'", owner)},
"perPage": {"200"},
})
if err != nil {
return nil, err
}
var recs []homeChargerRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return nil, err
}
return recs, nil
}
func containsID(ids []string, id string) bool {
for _, x := range ids {
if x == id {
return true
}
}
return false
}
// sendStep sends one step's command to one charger, through the control endpoint
// the page's own buttons use.
//
// The request is synthesised rather than the transports being called directly,
// so a scheduled command cannot end up on a different footing from a pressed
// one: the cascade, the ownership gate, the rate limit and the audit line are
// the endpoint's, and there is no second copy of them here to drift.
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
body := map[string]any{}
if step.Action == "limit" {
body["amps"] = step.Amps
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
req, err := http.NewRequestWithContext(
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
// The endpoint reads its charger and its verb from the route, which nothing
// matched here — this request never went through a mux.
req.SetPathValue("sn", serial)
req.SetPathValue("action", step.Action)
rw := &captureWriter{header: http.Header{}}
s.handleAnkerControlAction(rw, req)
return rw.controlOutcome()
}
// captureWriter is an http.ResponseWriter that keeps the response in memory,
// so a handler can be called without a connection to write down.
type captureWriter struct {
status int
header http.Header
body bytes.Buffer
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) WriteHeader(status int) {
if c.status == 0 {
c.status = status
}
}
func (c *captureWriter) Write(p []byte) (int, error) {
if c.status == 0 {
c.status = http.StatusOK
}
return c.body.Write(p)
}
// controlOutcome reads a captured control response as the charger's answer or
// the reason there wasn't one. The endpoint answers in one of two shapes — a
// status on success, an error envelope otherwise — and both are read here rather
// than the status code alone, because "why not" is the half worth keeping.
func (c *captureWriter) controlOutcome() (string, error) {
var out struct {
Status string `json:"status"`
Error string `json:"error"`
}
_ = json.Unmarshal(c.body.Bytes(), &out)
if c.status >= 200 && c.status < 300 {
if out.Status == "" {
return "sent", nil
}
return out.Status, nil
}
if out.Error != "" {
return "", fmt.Errorf("%s", out.Error)
}
return "", fmt.Errorf("the charger could not be reached (HTTP %d)", c.status)
}
// summarizeTaskRun says how a firing went in one line — what the list shows
// beside a task, and what goes in the log.
func summarizeTaskRun(results []taskRunResult) string {
ok, failed := 0, []string{}
for _, r := range results {
if r.Error == "" {
ok++
continue
}
name := r.Name
if name == "" {
name = r.Serial
}
if name != "" {
failed = append(failed, name+": "+r.Error)
} else {
failed = append(failed, r.Error)
}
}
sort.Strings(failed)
switch {
case len(failed) == 0:
return fmt.Sprintf("%d of %d sent", ok, len(results))
case ok == 0 && len(failed) == 1:
return failed[0]
default:
return fmt.Sprintf("%d of %d sent — %s", ok, len(results), strings.Join(failed, "; "))
}
}
// taskRunLine is the outcome of one firing as the list shows it: which step, and
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
// about which of them it was.
//
// The step is named by its time rather than its action, because everything this
// server writes into last_result is in its own words — charger names, the
// control gate's refusals — while the page has a translation for every action.
// A clock time reads the same in all three languages.
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
return step.Time + " — " + summarizeTaskRun(results)
}
// recordTaskRun stamps a task with when it last fired and how it went.
//
// The stamp is also the guard that keeps a task from firing twice inside its
// minute, so a firing that could not be written down is worth a log line:
// without it the next sweep would send the command again.
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
step models.ChargingStep, results []taskRunResult) {
payload := map[string]any{
"last_run": time.Now().UTC().Format(time.RFC3339),
"last_result": taskRunLine(step, results),
}
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)
}
}
@@ -0,0 +1,332 @@
package api
import (
"strings"
"testing"
"time"
"drivervault/apiserver/internal/models"
)
// The scheduler's clock is the part worth pinning down: it decides on its own,
// with nobody watching, whether to send a car a command. A step that fires in
// the wrong hour is worse than one that does not fire at all, so the zone, the
// weekday and the not-twice-in-one-minute guard are each checked here.
func step(action, at string, amps float64) models.ChargingStep {
return models.ChargingStep{Action: action, Time: at, Amps: amps}
}
func TestDueSteps(t *testing.T) {
warsaw, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
t.Fatalf("Europe/Warsaw: %v", err)
}
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
// A whole night in one task, which is the point of a flow: it opens, it
// eases off, it closes.
base := chargingTaskRecord{
Zone: "Europe/Warsaw",
Enabled: true,
Steps: []models.ChargingStep{
step("start", "23:00", 0),
step("limit", "01:00", 10),
step("stop", "06:30", 0),
},
}
due := dueSteps(base, at2300)
if len(due) != 1 || due[0].Action != "start" {
t.Fatalf("at 23:00 got %+v, want just the start step", due)
}
// The same instant handed over as UTC. The zone on the task is what the
// times are read in, so where the server thinks it is must not matter.
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
}
// The other two steps, each in its own minute and no other.
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
t.Errorf("at 01:00 got %+v, want the limit step", got)
}
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
t.Errorf("at 06:30 got %+v, want the stop step", got)
}
// A minute the flow says nothing about fires nothing — the point being that
// a task with a step at 23:00 is not "on" from 23:00 onwards.
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
if got := dueSteps(base, at); len(got) != 0 {
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
}
}
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
weeknights := base
weeknights.Days = []int{1, 2, 3, 4, 5}
if len(dueSteps(weeknights, at2300)) != 1 {
t.Error("a weeknight task did not fire on a Wednesday")
}
weekends := base
weekends.Days = []int{0, 6}
if len(dueSteps(weekends, at2300)) != 0 {
t.Error("a weekend task fired on a Wednesday")
}
// Fired already inside this minute: the sweep runs more than once a minute,
// and the second sweep must not send the command again.
fired := base
fired.LastRun = at2300.UTC().Format(time.RFC3339)
if len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
t.Error("a step fired twice inside its own minute")
}
// Yesterday's firing is not this minute's.
yesterday := base
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
if len(dueSteps(yesterday, at2300)) != 1 {
t.Error("yesterday's run stopped today's from firing")
}
// A step whose time is not a time of day fires nothing rather than firing at
// midnight, which is what a zero hour and minute would have meant — and it
// does not take the rest of the flow down with it.
broken := base
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
got := dueSteps(broken, at2300)
if len(got) != 1 || got[0].Action != "stop" {
t.Errorf("got %+v, want only the readable step", got)
}
// Two steps timed to the same minute contradict each other, but nothing
// stops somebody writing them, so both are returned rather than one
// silently winning.
clash := base
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
if got := dueSteps(clash, at2300); len(got) != 2 {
t.Errorf("got %+v, want both steps sharing the minute", got)
}
}
// A zone the host has no database for falls back to the server's own clock
// rather than silently shifting the schedule to UTC.
func TestDueStepsUnknownZone(t *testing.T) {
now := time.Now()
rec := chargingTaskRecord{
Zone: "Mars/Olympus_Mons",
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
}
if len(dueSteps(rec, now)) != 1 {
t.Error("a task in an unknown zone did not fall back to the server's clock")
}
}
func TestParseHHMM(t *testing.T) {
for _, tc := range []struct {
in string
h, m int
wantOK bool
}{
{in: "23:00", h: 23, m: 0, wantOK: true},
{in: "7:05", h: 7, m: 5, wantOK: true},
{in: " 00:00 ", h: 0, m: 0, wantOK: true},
{in: "24:00"},
{in: "12:60"},
{in: "12:5"}, // a half-typed minute is not a minute
{in: "123:00"}, // nor is a three-digit hour
{in: "12"},
{in: ""},
{in: "ab:cd"},
} {
h, m, ok := parseHHMM(tc.in)
if ok != tc.wantOK {
t.Errorf("parseHHMM(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
continue
}
if ok && (h != tc.h || m != tc.m) {
t.Errorf("parseHHMM(%q) = %d:%d, want %d:%d", tc.in, h, m, tc.h, tc.m)
}
}
}
func TestNormalizeDays(t *testing.T) {
got := normalizeDays([]int{5, 1, 5, 9, -1, 0})
want := []int{0, 1, 5}
if len(got) != len(want) {
t.Fatalf("normalizeDays = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("normalizeDays = %v, want %v", got, want)
}
}
// Empty means every day and stays empty rather than becoming all seven —
// the two read the same today, but only one keeps meaning "every day".
if len(normalizeDays(nil)) != 0 {
t.Error("normalizeDays(nil) invented days")
}
}
func TestNormalizeSteps(t *testing.T) {
// A night, written the way it is meant: it opens, it eases off, it closes.
// The half-written times are the client's business to send and this
// function's business to pad.
got, err := normalizeSteps([]models.ChargingStep{
step("start", "23:00", 0),
step("limit", "1:00", 10),
step("stop", "6:30", 0),
})
if err != nil {
t.Fatalf("normalizeSteps: %v", err)
}
// Kept in the order it was written. Sorting by the clock would file the
// 23:00 start last, behind the stop that closes it, which is not the flow
// anybody described — and firing does not depend on the order at all.
wantTimes := []string{"23:00", "01:00", "06:30"}
for i, want := range wantTimes {
if got[i].Time != want {
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
}
}
for name, steps := range map[string][]models.ChargingStep{
"no steps at all": {},
"an unknown action": {step("melt", "23:00", 0)},
"a time that is not one": {step("start", "half past", 0)},
"a limit with no amps": {step("limit", "23:00", 0)},
"amps below the floor": {step("limit", "23:00", 3)},
"amps above the rating": {step("limit", "23:00", 40)},
} {
if _, err := normalizeSteps(steps); err == nil {
t.Errorf("normalizeSteps accepted %s", name)
}
}
// The cap bounds what the sweep re-reads every thirty seconds.
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
for i := range tooMany {
tooMany[i] = step("start", "23:00", 0)
}
if _, err := normalizeSteps(tooMany); err == nil {
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
}
}
func TestTaskPayloadValidation(t *testing.T) {
str := func(s string) *string { return &s }
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
full := chargingTaskBody{
Name: str(" Night rate "),
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
}
payload, err := taskPayload(full, true)
if err != nil {
t.Fatalf("taskPayload: %v", err)
}
if payload["name"] != "Night rate" {
t.Errorf("name = %v, want it trimmed", payload["name"])
}
if payload["enabled"] != true {
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
}
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
t.Errorf("steps = %+v, want both, as written", payload["steps"])
}
// The two things a task cannot exist without.
for _, missing := range []chargingTaskBody{
{Steps: steps(step("start", "23:00", 0))},
{Name: str("x")},
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
} {
if _, err := taskPayload(missing, true); err == nil {
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
}
}
// A partial write says only what it names, so a task switched off from the
// list keeps its flow, its chargers and its days.
on := true
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
if err != nil {
t.Fatalf("taskPayload(partial): %v", err)
}
if len(partial) != 1 {
t.Errorf("a switch-only write touched %v, want only enabled", partial)
}
}
// The list is ordered by the time a task begins, which lives inside its flow
// rather than in a column PocketBase could sort on.
func TestFirstStepTime(t *testing.T) {
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
if got := rec.firstStepTime(); got != "23:00" {
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
}
// A task with no steps cannot be written, but sorting must not depend on
// that — it sorts last rather than first.
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
t.Errorf("an empty task sorted to %q, want it last", got)
}
}
func TestSummarizeTaskRun(t *testing.T) {
all := summarizeTaskRun([]taskRunResult{
{Name: "Home-DK", Status: "Accepted"},
{Name: "Home-PL", Status: "Accepted"},
})
if all != "2 of 2 sent" {
t.Errorf("summary = %q, want \"2 of 2 sent\"", all)
}
// One charger failing is the case the list has to be able to show: the
// summary names which, because "1 of 2 sent" alone is not actionable.
some := summarizeTaskRun([]taskRunResult{
{Name: "Home-DK", Status: "Accepted"},
{Name: "Home-PL", Error: "charger is not connected"},
})
if !strings.Contains(some, "Home-PL") || !strings.Contains(some, "not connected") {
t.Errorf("summary = %q, want it to name the charger that failed and why", some)
}
// A single failure is its own reason — no count to read past.
only := summarizeTaskRun([]taskRunResult{{Name: "Home-PL", Error: "control mode is off"}})
if only != "Home-PL: control mode is off" {
t.Errorf("summary = %q, want the bare reason", only)
}
}
// The runner reads a control response the same way whichever transport answered
// it: a status when the charger took the command, the endpoint's own words when
// it did not.
func TestCaptureWriterControlOutcome(t *testing.T) {
ok := &captureWriter{header: nil}
ok.WriteHeader(200)
_, _ = ok.Write([]byte(`{"status":"Accepted"}`))
if got, err := ok.controlOutcome(); err != nil || got != "Accepted" {
t.Errorf("controlOutcome = %q, %v; want Accepted", got, err)
}
// A 2xx with nothing to read still means the command went.
bare := &captureWriter{}
bare.WriteHeader(204)
if got, err := bare.controlOutcome(); err != nil || got != "sent" {
t.Errorf("controlOutcome = %q, %v; want sent", got, err)
}
bad := &captureWriter{}
bad.WriteHeader(409)
_, _ = bad.Write([]byte(`{"error":"charger is not connected to the control backend"}`))
_, err := bad.controlOutcome()
if err == nil || !strings.Contains(err.Error(), "not connected") {
t.Errorf("controlOutcome err = %v, want the endpoint's own reason", err)
}
// An error status with no envelope to read still has to say something.
mute := &captureWriter{}
mute.WriteHeader(502)
if _, err := mute.controlOutcome(); err == nil {
t.Error("a 502 with an empty body was read as a success")
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-BBZfstAT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_8IdQ1wh.css">
<script type="module" crossorigin src="/assets/index-CUgU2Fxs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cd85xDqG.css">
</head>
<body>
<div id="app"></div>
@@ -62,6 +62,52 @@ func normalizeControlMode(v string) string {
}
}
// ankerControlModesAll lists every control mode, in the order the clients offer
// them. off leads because it is the default and the fallback.
var ankerControlModesAll = []string{ankerControlOff, ankerControlMqtt, ankerControlModbus, ankerControlOwn, ankerControlProxy}
// parseControlModes reads a stored comma-separated mode list into a set,
// dropping anything unrecognized. off is never in it: monitoring-only is what a
// charger falls back to, so no layer may take it away from the layers below.
func parseControlModes(v string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(v, ",") {
if m := normalizeControlMode(part); m != "" && m != ankerControlOff {
out[m] = true
}
}
return out
}
// joinControlModes renders a mode set back to its stored form, in the canonical
// order, so what a client sends round-trips to a predictable string.
func joinControlModes(set map[string]bool) string {
return strings.Join(controlModeList(set), ",")
}
// controlModeList sorts a mode set into the canonical order.
func controlModeList(set map[string]bool) []string {
out := []string{}
for _, m := range ankerControlModesAll {
if set[m] {
out = append(out, m)
}
}
return out
}
// controlModesOffered lists the modes a layer may still choose once the layers
// above it have hidden theirs.
func controlModesOffered(hidden map[string]bool) []string {
out := []string{}
for _, m := range ankerControlModesAll {
if !hidden[m] {
out = append(out, m)
}
}
return out
}
// ankerConfig is one layer's Anker Solix settings.
type ankerConfig struct {
Email string `json:"email"`
@@ -70,6 +116,12 @@ type ankerConfig struct {
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
// It resolves independently of the credentials, like Country.
ControlMode string `json:"controlMode"`
// ControlModesDisabled is a comma-separated list of modes this layer hides
// from the layers below it — a mode a deployment (or an organization) does
// not want offered at all. It does not constrain this layer's own choice, and
// it never hides off. Meaningful on the global and organization layers only:
// a user has nobody below them.
ControlModesDisabled string `json:"controlModesDisabled,omitempty"`
}
// ankerChargerBinding is what we know about one charger the caller controls:
@@ -128,6 +180,14 @@ type ankerResolution struct {
available bool // global master switch (plugin enabled in the panel)
orgEnabled bool // org gate (default true; gates the org's users)
enabled bool // caller's personal enable flag
// hiddenForOrg is what the global layer hides from everyone below it;
// hiddenForUser adds what the caller's organization hides from its own users.
// They gate both the pickers the clients draw and which layer's stored mode
// is allowed to take effect.
hiddenForOrg map[string]bool
hiddenForUser map[string]bool
orgHidden map[string]bool // the organization layer's own list, for its editor
}
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
@@ -137,7 +197,8 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// pluginSettings blob (read from their user record).
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"],
ControlMode: g["controlMode"], ControlModesDisabled: g["controlModesDisabled"]}
var oStored ankerStored
if who.OrgID != "" {
@@ -166,6 +227,21 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
enabled: uStored.Enabled,
}
// What each layer below may still be offered. The global list reaches
// everyone; an organization's own list reaches only its users, so it narrows
// the set once more on the way down.
res.hiddenForOrg = parseControlModes(gc.ControlModesDisabled)
res.orgHidden = parseControlModes(oc.ControlModesDisabled)
res.hiddenForUser = map[string]bool{}
for m := range res.hiddenForOrg {
res.hiddenForUser[m] = true
}
if who.OrgID != "" {
for m := range res.orgHidden {
res.hiddenForUser[m] = true
}
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
@@ -189,13 +265,18 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
// Control mode resolves independently too, defaulting to off (monitoring
// only). The highest layer that sets a recognized value wins; an explicit
// "off" set above still wins (and locks lower layers to monitoring).
// A mode hidden by a layer above is not merely absent from the picker: a value
// stored below before it was hidden stops taking effect too, so switching a
// mode off really does switch it off everywhere underneath.
res.eff.ControlMode = ankerControlOff
res.source["controlMode"] = "unset"
for _, l := range layers {
if v := normalizeControlMode(l.c.ControlMode); v != "" {
res.eff.ControlMode, res.source["controlMode"] = v, l.name
break
v := normalizeControlMode(l.c.ControlMode)
if v == "" || res.hiddenAt(l.name)[v] {
continue
}
res.eff.ControlMode, res.source["controlMode"] = v, l.name
break
}
// Credentials resolve as a pair from the highest layer with an email, so the
@@ -212,6 +293,19 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
return res
}
// hiddenAt is the set of modes hidden from one cascade layer by the layers above
// it. The global layer answers to nobody, so nothing is hidden from it.
func (r ankerResolution) hiddenAt(layer string) map[string]bool {
switch layer {
case "org":
return r.hiddenForOrg
case "user":
return r.hiddenForUser
default:
return map[string]bool{}
}
}
// ankerLockedFor reports whether a field whose value comes from source is locked
// for a caller whose editable layer is editable (i.e. the value is set above them).
func ankerLockedFor(source, editable string) bool {
@@ -301,7 +395,13 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
}
return fv
}
return map[string]any{
// The modes this scope may pick from: everything the layers above it left
// standing. A superadmin's read-only view answers as the user layer they are.
hidden := res.hiddenForUser
if editable == "org" {
hidden = res.hiddenForOrg
}
out := map[string]any{
"editableLayer": editable,
"fields": map[string]ankerFieldView{
"email": field("email", res.eff.Email, own.Email, false),
@@ -309,7 +409,15 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
"country": field("country", res.eff.Country, own.Country, false),
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
},
"controlModes": controlModesOffered(hidden),
}
if editable == "org" {
// The organization's own hide-list, which its admin edits here. Modes the
// global layer already hid are not in controlModes above, so they simply
// never come up.
out["controlModesDisabled"] = controlModeList(res.orgHidden)
}
return out
}
// ankerView builds the masked, client-safe response body from a resolution.
@@ -319,6 +427,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
"controlModes": controlModesOffered(res.hiddenForUser), // modes still offered to this caller
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
@@ -430,7 +539,21 @@ func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })
applyField("controlMode", func(c *ankerConfig, v string) {
m := normalizeControlMode(v)
// A mode hidden above is not a choice this layer can make; keep whatever it
// had rather than storing something that would never take effect.
if m != "" && res.hiddenAt(editable)[m] {
return
}
c.ControlMode = m
})
if editable == "org" {
// Only a layer with users under it has anything to hide from them.
applyField("controlModesDisabled", func(c *ankerConfig, v string) {
c.ControlModesDisabled = joinControlModes(parseControlModes(v))
})
}
// Persist the organization layer (admins) via the service account.
if editable == "org" {
@@ -584,3 +707,129 @@ func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Reques
}
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// The two card writes. They are the only calls in the Anker connector that
// change anything on the account, so both are gated exactly like the reads,
// rate-limited beside the control commands — a card is who may start a charge,
// which is the same actuator asked a slower question — and audited by serial and
// card, with the number kept out of the log line: it is the credential itself.
// Both answer with the card list as it stands after the write, so the caller
// sees what the account holds rather than what an undocumented endpoint claimed.
// ankerCardBody is what a card write is asked for.
type ankerCardBody struct {
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
}
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards — add a card to
// the charger, or rename one already on it.
func (s *Server) handleAnkerCardSave(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
var body ankerCardBody
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
if strings.TrimSpace(body.CardNumber) == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-save", sn, map[string]any{
"sn": sn, "cardNumber": body.CardNumber, "cardName": body.CardName,
})
}
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} —
// remove one card, named in full.
func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
number := strings.TrimSpace(r.PathValue("number"))
if number == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-delete", sn, map[string]any{
"sn": sn, "cardNumber": number,
})
}
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan — open the
// charger's card reader and answer with the card someone taps on it. Takes as
// long as the window does, about twenty seconds, and answers either way: a
// window that closed with nothing tapped is an answer, not a timeout.
func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn})
}
// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger — the list
// of cards the charger itself holds, asked of the device with 0104 rather than
// of the account.
//
// The two lists are written together and can still come apart: a card the
// account has forgotten still opens the charger until the device is told
// otherwise, and the account's copy is the only one every other view here
// draws. Asking the device is the only way to see the difference. It answers
// with UIDs and nothing else — the charger has no field for a card's name.
func (s *Server) handleAnkerChargerCards(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-cards-charger", sn, map[string]any{"sn": sn})
}
// ankerCardGate is everything a card call needs before it may run: a caller, a
// serial, an integration that is on and has credentials, and a rate limit. A
// gate that is off answers 409 rather than the reads' 200-with-a-reason: a write
// that did not happen is not a state to render, it is a request that failed.
func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, string, map[string]string, bool) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, "", nil, false
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "charger serial required")
return nil, "", nil, false
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
if reason := ankerGate(res, true); reason != "" {
writeError(w, http.StatusConflict, reason)
return nil, "", nil, false
}
if !s.ctlRL.allow(who.ID + "|" + sn) {
writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down")
return nil, "", nil, false
}
return who, sn, map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}, true
}
// ankerCardWrite runs one card capability and relays its document. The audit
// line names the charger and how the write went, never the card number.
func (s *Server) ankerCardWrite(w http.ResponseWriter, r *http.Request, who *callerIdentity,
cfg map[string]string, action, sn string, params map[string]any) {
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, action, mustJSON(params))
if err != nil {
s.auditControl(who, sn, action, nil, "failed", err)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
s.auditControl(who, sn, action, nil, "ok", nil)
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
@@ -3,10 +3,12 @@ package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
)
@@ -217,3 +219,105 @@ func TestOCPPEndpoint(t *testing.T) {
t.Errorf("tls endpoint = %q", got)
}
}
// The four card routes are reachable at all. A capability the plugin implements
// and the action catalogue advertises is still unusable if nothing routes to it,
// and that is not a failure any other test here would notice: the plugin's own
// tests pass, and the panel simply has no button. Each route is asked for
// without a token, so what is being checked is that the request reached the
// authentication middleware rather than a 404.
func TestAnkerCardRoutesAreRegistered(t *testing.T) {
h := New(config.Config{}, nil).Handler()
for _, tc := range []struct{ method, path string }{
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards"},
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/scan"},
{"GET", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/charger"},
{"DELETE", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/AABBCCDD"},
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
if rr.Code == http.StatusNotFound {
t.Errorf("%s %s is not routed", tc.method, tc.path)
}
}
}
func TestParseControlModes(t *testing.T) {
got := parseControlModes(" Proxy , own ,bogus,, off ")
// off is never hideable: monitoring only is what a charger falls back to.
want := map[string]bool{"proxy": true, "own": true}
if len(got) != len(want) {
t.Fatalf("parseControlModes = %v, want %v", got, want)
}
for m := range want {
if !got[m] {
t.Errorf("parseControlModes is missing %q", m)
}
}
if s := joinControlModes(got); s != "own,proxy" {
t.Errorf("joinControlModes = %q, want own,proxy (canonical order)", s)
}
}
func TestControlModesOffered(t *testing.T) {
got := controlModesOffered(map[string]bool{"own": true, "proxy": true})
want := []string{"off", "mqtt", "modbus"}
if len(got) != len(want) {
t.Fatalf("controlModesOffered = %v, want %v", got, want)
}
for i, m := range want {
if got[i] != m {
t.Errorf("controlModesOffered[%d] = %q, want %q", i, got[i], m)
}
}
}
// A mode the global layer hides is not merely absent from a lower picker: a
// value stored below before it was hidden stops taking effect too.
func TestHiddenControlModeDoesNotTakeEffect(t *testing.T) {
res := ankerResolution{
hiddenForOrg: map[string]bool{"own": true, "proxy": true},
hiddenForUser: map[string]bool{"own": true, "proxy": true},
}
if !res.hiddenAt("user")["proxy"] {
t.Error("proxy should be hidden from the user layer")
}
if res.hiddenAt("global")["proxy"] {
t.Error("nothing is hidden from the global layer — it is the one hiding")
}
view := (&Server{}).ankerScopeView(res, "user")
for _, m := range view["controlModes"].([]string) {
if m == "own" || m == "proxy" {
t.Errorf("hidden mode %q is still offered to the user scope", m)
}
}
if _, ok := view["controlModesDisabled"]; ok {
t.Error("the user scope has nobody below it and must carry no hide-list")
}
}
// An organization narrows the set once more for its own users, and its admin
// edits that list in the org scope.
func TestOrgScopeCarriesItsOwnHideList(t *testing.T) {
res := ankerResolution{
hiddenForOrg: map[string]bool{"proxy": true},
hiddenForUser: map[string]bool{"proxy": true, "own": true},
orgHidden: map[string]bool{"own": true},
}
org := (&Server{}).ankerScopeView(res, "org")
offered := org["controlModes"].([]string)
var sawOwn, sawProxy bool
for _, m := range offered {
sawOwn = sawOwn || m == "own"
sawProxy = sawProxy || m == "proxy"
}
if !sawOwn {
t.Error("an org may still choose a mode it only hides from its users")
}
if sawProxy {
t.Error("a mode the global layer hid must not reach the org picker")
}
if got := org["controlModesDisabled"].([]string); len(got) != 1 || got[0] != "own" {
t.Errorf("org hide-list = %v, want [own]", got)
}
}
+31
View File
@@ -29,6 +29,8 @@ type userRecord struct {
Theme string `json:"theme"`
Locale string `json:"locale"`
DateFormat string `json:"date_format"`
TimeFormat string `json:"time_format"`
WeekStart string `json:"week_start"`
Currency string `json:"currency"`
FontSize string `json:"font_size"`
DragLocked bool `json:"drag_locked"`
@@ -97,6 +99,8 @@ func (rec userRecord) toModel() models.User {
Theme: orDefault(rec.Theme, "system"),
Locale: orDefault(rec.Locale, "en-US"),
DateFormat: orDefault(rec.DateFormat, "YMD"),
TimeFormat: orDefault(rec.TimeFormat, "auto"),
WeekStart: orDefault(rec.WeekStart, "auto"),
Currency: orDefault(rec.Currency, "USD"),
FontSize: orDefault(rec.FontSize, "medium"),
DragLocked: rec.DragLocked,
@@ -161,6 +165,8 @@ type updateMeRequest struct {
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
TimeFormat *string `json:"timeFormat"`
WeekStart *string `json:"weekStart"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
@@ -256,6 +262,17 @@ func normalizeOrder(field string, in []string, max int) ([]string, error) {
var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}
// "auto" reads the clock the way the chosen region writes it, which is what
// the app did before there was a setting; the other two say it outright, for
// the people whose region and habit disagree.
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
// Which day a week is drawn as starting on. "auto" is the chosen region's own
// convention — Monday across most of Europe, Sunday in the US — and is what
// every weekday row read before there was a setting; the other two say it
// outright, for the people whose region and habit disagree.
var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": true}
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
@@ -318,6 +335,20 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["date_format"] = *in.DateFormat
}
if in.TimeFormat != nil {
if !validTimeFormats[*in.TimeFormat] {
writeError(w, http.StatusBadRequest, "timeFormat must be auto, 24, or 12")
return
}
payload["time_format"] = *in.TimeFormat
}
if in.WeekStart != nil {
if !validWeekStarts[*in.WeekStart] {
writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday")
return
}
payload["week_start"] = *in.WeekStart
}
if in.Currency != nil {
if !validCurrencies[*in.Currency] {
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
+24
View File
@@ -52,6 +52,10 @@
// POST /api/integrations/anker-solix/health
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -155,6 +159,7 @@ const (
colDocuments = "car_documents"
colReminders = "reminders"
colHomeChargers = "home_chargers"
colChargingTasks = "charging_tasks"
colControlAudit = "control_audit"
colAppSettings = "app_settings"
)
@@ -178,6 +183,10 @@ type Server struct {
ocpp *ocpp.CSMS
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
ctlRL *rateLimiter // per user+charger control-command rate limit
// schedulerStop ends the ticker that fires the home-charger scheduler's
// tasks, started by StartScheduler (see chargingtasks_run.go).
schedulerStop context.CancelFunc
}
// New constructs a Server around an already-built PocketBase client.
@@ -281,6 +290,9 @@ func (s *Server) Stop(ctx context.Context) {
if s.pluginsStop != nil {
s.pluginsStop()
}
if s.schedulerStop != nil {
s.schedulerStop()
}
s.ocpp.Shutdown(ctx)
s.plugins.Shutdown(ctx)
}
@@ -441,6 +453,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan", s.handleAnkerCardScan)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger", s.handleAnkerChargerCards)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
@@ -480,6 +496,14 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger)
mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger)
// The home-charger scheduler: one list of charging tasks per user, covering
// every charger they own. See chargingtasks.go.
mux.HandleFunc("GET /api/charging-tasks", s.listChargingTasks)
mux.HandleFunc("POST /api/charging-tasks", s.createChargingTask)
mux.HandleFunc("PATCH /api/charging-tasks/{id}", s.updateChargingTask)
mux.HandleFunc("DELETE /api/charging-tasks/{id}", s.deleteChargingTask)
mux.HandleFunc("POST /api/charging-tasks/{id}/steps/{step}/run", s.runChargingStepNow)
// Cars + sharing.
mux.HandleFunc("GET /api/cars", s.listCars)
mux.HandleFunc("POST /api/cars", s.createCar)
+118
View File
@@ -29,6 +29,24 @@ type Options struct {
SuperAdminEmail string
SuperAdminPassword string
SuperAdminName string
// S3, when non-nil and Enabled, points PocketBase's record-file storage at
// a bucket. Nil is the normal case — a stack started without one of the
// SeaweedFS compose overlays keeps its uploads on the pb_data volume.
S3 *S3Options
}
// S3Options describes the bucket PocketBase should keep record files in. It
// mirrors PocketBase's own settings block one field at a time, so there is
// nothing to translate at the wire.
type S3Options struct {
Enabled bool
Bucket string
Region string
Endpoint string
AccessKey string
Secret string
ForcePathStyle bool
}
// fieldDef is a schema field normalized to a single shape; it is rendered into
@@ -190,6 +208,10 @@ func Run(ctx context.Context, client *pb.Client, opts Options) error {
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
return fmt.Errorf("super-admin: %w", err)
}
if err := ensureFileStorage(ctx, client, opts.S3); err != nil {
return fmt.Errorf("file storage: %w", err)
}
return nil
}
@@ -433,6 +455,102 @@ func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) erro
return nil
}
// --- file storage ----------------------------------------------------------
// storageSettings is the slice of PocketBase's settings this step owns. The
// json names are PocketBase's own (core.S3Config), so the PATCH body is just
// this type marshalled back out.
type storageSettings struct {
Enabled bool `json:"enabled"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
AccessKey string `json:"accessKey"`
Secret string `json:"secret,omitempty"`
ForcePathStyle bool `json:"forcePathStyle"`
}
// sameExceptSecret compares everything a read of the settings can be trusted
// on. PocketBase masks the stored secret, so a rotation of the secret alone is
// invisible from here — changing any other PB_S3_* value forces the write, and
// so does editing it in the admin UI.
func (s storageSettings) sameExceptSecret(other storageSettings) bool {
s.Secret, other.Secret = "", ""
return s == other
}
// ensureFileStorage points PocketBase's record-file storage at the configured
// bucket, and does nothing at all when no bucket was asked for.
//
// It never turns S3 *off*: files already written to a bucket are only reachable
// while PocketBase is still pointed at it, so dropping the overlay leaves the
// setting where it is rather than stranding every existing attachment. Moving
// back to local storage is a deliberate act in the admin UI.
func ensureFileStorage(ctx context.Context, client *pb.Client, opts *S3Options) error {
if opts == nil || !opts.Enabled {
return nil // not requested
}
want := storageSettings{
Enabled: true,
Bucket: opts.Bucket,
Region: opts.Region,
Endpoint: opts.Endpoint,
AccessKey: opts.AccessKey,
Secret: opts.Secret,
ForcePathStyle: opts.ForcePathStyle,
}
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/settings", nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("read settings: status %d: %s", status, raw)
}
var current struct {
S3 storageSettings `json:"s3"`
}
if err := json.Unmarshal(raw, &current); err != nil {
return err
}
if current.S3.sameExceptSecret(want) {
log.Printf("bootstrap: • file storage already on S3 (%s)", want.Bucket)
} else {
// Only the s3 block is sent: everything else in the settings — mail,
// backups, rate limits — belongs to whoever set it.
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/settings",
map[string]any{"s3": want})
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("apply settings: status %d: %s", status, raw)
}
log.Printf("bootstrap: ✓ file storage → S3 (%s at %s)", want.Bucket, want.Endpoint)
}
testFileStorage(ctx, client)
return nil
}
// testFileStorage asks PocketBase to prove it can actually reach the bucket,
// and only says so in the log. A failure here means uploads will fail, but the
// server still has to come up — the endpoint is fixable from the panel, and a
// stack that refuses to boot cannot be fixed from anywhere.
func testFileStorage(ctx context.Context, client *pb.Client) {
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/settings/test/s3",
map[string]any{"filesystem": "storage"})
switch {
case err != nil:
log.Printf("bootstrap: WARNING: S3 storage test failed: %v", err)
case status < 200 || status >= 300:
log.Printf("bootstrap: WARNING: S3 storage unreachable (status %d): %s", status, raw)
default:
log.Printf("bootstrap: ✓ S3 storage reachable")
}
}
// --- helpers ---------------------------------------------------------------
func asBool(v any) bool {
@@ -94,3 +94,39 @@ func TestSchemaConsistency(t *testing.T) {
}
}
}
// TestSameExceptSecret covers the decision ensureFileStorage makes on every
// boot: write the settings, or leave them alone. The secret is excluded because
// PocketBase masks it on read — comparing it would make every boot a write.
func TestSameExceptSecret(t *testing.T) {
want := storageSettings{
Enabled: true,
Bucket: "drivervault",
Region: "us-east-1",
Endpoint: "http://seaweedfs:8333",
AccessKey: "key",
Secret: "secret",
ForcePathStyle: true,
}
cases := []struct {
name string
current storageSettings
same bool
}{
{"identical", want, true},
{"masked secret", func() storageSettings { s := want; s.Secret = ""; return s }(), true},
{"rotated secret only", func() storageSettings { s := want; s.Secret = "other"; return s }(), true},
{"changed endpoint", func() storageSettings { s := want; s.Endpoint = "http://elsewhere:8333"; return s }(), false},
{"changed bucket", func() storageSettings { s := want; s.Bucket = "other"; return s }(), false},
{"changed access key", func() storageSettings { s := want; s.AccessKey = "other"; return s }(), false},
{"still disabled", func() storageSettings { s := want; s.Enabled = false; return s }(), false},
{"path style off", func() storageSettings { s := want; s.ForcePathStyle = false; return s }(), false},
{"untouched settings", storageSettings{}, false},
}
for _, tc := range cases {
if got := tc.current.sameExceptSecret(want); got != tc.same {
t.Errorf("%s: sameExceptSecret = %v, want %v", tc.name, got, tc.same)
}
}
}
+41
View File
@@ -225,12 +225,48 @@ var collectionsSchema = map[string][]fieldDef{
// audit trail's.
fAutodate("created", true, false),
},
// The home-charger scheduler: the user's own list of charging tasks, one list
// covering every charger they own. The charger's own cloud schedule holds one
// window per box; this holds as many tasks as they like, each naming its own
// chargers, days and action. Run by the ticker in internal/api/chargingtasks_run.go.
"charging_tasks": {
fText("name", true),
// The home_chargers rows this task acts on. Stored as a list of ids rather
// than a relation because empty has to mean "every charger I own" — a
// standing wish that keeps covering chargers imported later — and a
// multi-relation would have to be rewritten on every import to say it.
fJSON("chargers", 2000),
// The flow: [{action, amps, time}, …] in the order it runs. A whole
// charging window is one task rather than the two that would otherwise
// open and close it, so it is named once and switched off once. Times are
// 24-hour "HH:MM" read in the zone below — the server's clock is not the
// one the user set 23:00 by. Validated in internal/api/chargingtasks.go.
fJSON("steps", 4000),
fText("zone", false),
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
fBool("enabled"),
// The outcome of the last firing, so a task that has been failing quietly
// says so in the list. last_run is also the guard against firing twice in
// the same minute.
fText("last_run", false), // RFC3339, UTC
fText("last_result", false),
// Owner. Non-cascading, like a charger's.
fRelation("owner", "users", false, false),
fAutodate("created", true, false),
},
// Custom fields layered onto the built-in "users" auth collection.
"users": {
fText("bio", false),
fSelect("theme", []string{"light", "dark", "system"}, false),
fText("locale", false),
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
// "auto" is the region's own convention, which is what every clock in the
// app read before this field existed.
fSelect("time_format", []string{"auto", "24", "12"}, false),
// The day a week is drawn as starting on, wherever weekdays are laid out
// in a row. "auto" is the region's own convention, which is what the
// scheduler's day picker read before this field existed.
fSelect("week_start", []string{"auto", "monday", "sunday"}, false),
fSelect("currency", []string{
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
@@ -277,6 +313,7 @@ var createOrder = []string{
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
}
// reconcileOrder additionally includes "users" so its custom fields (role,
@@ -297,6 +334,7 @@ var reconcileOrder = []string{
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
}
// indexes are extra SQL indexes applied at collection-create time.
@@ -313,6 +351,9 @@ var indexes = map[string][]string{
// A charger is looked up by its owner, and by serial when checking whether
// the account it came from has already been imported.
"home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"},
// The runner sweeps every enabled task on every tick, and the page reads one
// owner's; both go through these two columns.
"charging_tasks": {"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)"},
"control_audit": {
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
+36
View File
@@ -51,6 +51,26 @@ type Config struct {
SuperAdminEmail string
SuperAdminPassword string
SuperAdminName string
// PocketBase file storage. With S3Enabled set, bootstrap points PocketBase's
// "Files storage" at this bucket instead of the pb_data volume; left unset,
// uploads stay on disk exactly as they always have. Only *record files* move
// — backups are deliberately not touched.
//
// Nothing here reaches a container unless one of the SeaweedFS compose
// overlays is layered on, so an existing stack is unaffected by an upgrade.
// S3Endpoint has no default: an in-stack SeaweedFS and one outside it are
// different addresses, and guessing either would be worse than not starting.
S3Enabled bool
S3Bucket string
S3Region string
S3Endpoint string
S3AccessKey string
S3Secret string
// S3ForcePathStyle keeps bucket names in the path rather than the hostname.
// True by default because that is what a self-hosted gateway serves —
// virtual-host style would need a DNS entry per bucket.
S3ForcePathStyle bool
}
// EnvFile is the .env path (relative to the working directory) that Load reads
@@ -66,6 +86,15 @@ func (c Config) AdminConfigured() bool {
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
}
// StorageConfigured reports whether S3 file storage has been fully specified.
// Anything less than all of it counts as "not asked for": a half-filled .env
// leaves uploads on the local volume rather than pointing PocketBase at a
// bucket it has no way to reach.
func (c Config) StorageConfigured() bool {
return c.S3Enabled && c.S3Bucket != "" && c.S3Endpoint != "" &&
c.S3AccessKey != "" && c.S3Secret != ""
}
// Load reads configuration from environment variables, applying sensible
// defaults. A .env file, if present in the working directory, is loaded first.
func Load() Config {
@@ -86,6 +115,13 @@ func Load() Config {
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
S3Enabled: boolEnv("PB_S3_ENABLED", false),
S3Bucket: getenv("PB_S3_BUCKET", "drivervault"),
S3Region: getenv("PB_S3_REGION", "us-east-1"),
S3Endpoint: strings.TrimRight(getenv("PB_S3_ENDPOINT", ""), "/"),
S3AccessKey: getenv("PB_S3_ACCESS_KEY", ""),
S3Secret: getenv("PB_S3_SECRET", ""),
S3ForcePathStyle: boolEnv("PB_S3_FORCE_PATH_STYLE", true),
}
}
+60 -3
View File
@@ -152,6 +152,59 @@ type HomeCharger struct {
Created string `json:"created,omitempty"`
}
// ChargingStep is one command in a task's flow: what to do, and at what time
// of day. A step is the smallest thing the scheduler sends.
type ChargingStep struct {
// start, stop, limit (to Amps) or boost.
Action string `json:"action"`
Amps float64 `json:"amps,omitempty"`
// A 24-hour "HH:MM", read in the task's zone.
Time string `json:"time"`
}
// ChargingTask is one entry in the home-charger scheduler: a flow of steps, the
// days it repeats on, and the chargers it acts on. It belongs to the person,
// like the chargers themselves — one list covering every charger they own,
// rather than a separate schedule inside each one.
//
// The charger's own cloud schedule can only say "charge between these hours,
// every day, on this one box". This says "start at 23:00, cap to 10 A at 01:00,
// stop at 06:30 — on weeknights, on these two chargers", as one named thing that
// is switched on and off as one.
type ChargingTask struct {
ID string `json:"id"`
Name string `json:"name"`
// The home-charger records this task acts on. Empty means every charger the
// owner has, including ones imported after the task was written — "all of
// them" is a standing wish, not the list that happened to exist that day.
Chargers []string `json:"chargers"`
// The flow, in the order it runs. A whole charging window lives in one task
// rather than in the two that used to open and close it: it is named once,
// switched off once, and reads as the one intention it is.
Steps []ChargingStep `json:"steps"`
// The IANA zone the steps' times are read in — the one the browser was in
// when the task was written. The server's own clock is not the one the user
// set 23:00 by, and a laptop that travels must not move the schedule.
Zone string `json:"zone,omitempty"`
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
Days []int `json:"days"`
Enabled bool `json:"enabled"`
// What happened the last time a step of it fired, so a task that has been
// failing quietly for a week says so in the list rather than in a log nobody
// reads.
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
LastResult string `json:"lastResult,omitempty"`
Created string `json:"created,omitempty"`
}
// ServiceRecord is one row of the Service log for a car.
type ServiceRecord struct {
ID string `json:"id"`
@@ -476,9 +529,13 @@ type User struct {
Theme string `json:"theme"` // light | dark | system
Locale string `json:"locale"` // e.g. "en-US"
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
// The day a week is drawn as starting on, wherever a client lays weekdays
// out in a row — the scheduler's day picker today.
WeekStart string `json:"weekStart"` // auto (the region's own) | monday | sunday
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
// DragLocked holds every arrangement on this account's pages still: the
// garage, a car's tabs, its Information rows, the provider's readings. A
+56 -3
View File
@@ -6,12 +6,14 @@ package pb
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -29,6 +31,7 @@ type Client struct {
email string
password string
token string
tokenExp time.Time
}
func New(baseURL, email, password string) *Client {
@@ -69,6 +72,7 @@ func (c *Client) Reconfigure(baseURL, email, password string) {
c.email = email
c.password = password
c.token = ""
c.tokenExp = time.Time{}
c.mu.Unlock()
}
@@ -120,6 +124,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
}
c.mu.Lock()
c.token = out.Token
c.tokenExp = jwtExpiry(out.Token)
c.mu.Unlock()
return nil
}
@@ -134,8 +139,46 @@ func (c *Client) currentToken() string {
return c.token
}
// ensureToken acquires a superuser token when none is cached yet, so that a
// superuser call never goes out unauthenticated.
// tokenSkew is how long before its stated expiry a cached token is treated as
// spent. It covers clock drift between this server and PocketBase, and the
// flight time of a request that passes the check and then arrives just late.
const tokenSkew = 60 * time.Second
// tokenLive reports whether the cached token can still be used. A token with no
// readable expiry is taken at face value — the 401 retry remains the backstop.
func (c *Client) tokenLive() bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.token == "" {
return false
}
return c.tokenExp.IsZero() || time.Now().Add(tokenSkew).Before(c.tokenExp)
}
// jwtExpiry reads the exp claim out of a PocketBase auth token. The signature is
// PocketBase's business — this only needs the expiry the server itself stamped,
// so the payload is decoded without verification. Anything unreadable comes back
// as the zero time, which tokenLive treats as "no expiry known".
func jwtExpiry(token string) time.Time {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
return time.Time{}
}
return time.Unix(claims.Exp, 0)
}
// ensureToken acquires a superuser token when the cached one is missing or
// spent, so that a superuser call never goes out unauthenticated.
//
// It matters because PocketBase answers a record read it will not allow with
// 404, not 401 — it hides the record rather than refusing the credentials. So a
@@ -149,8 +192,18 @@ func (c *Client) currentToken() string {
// With no service account configured there is nothing to acquire, so the call
// proceeds as before — the endpoints that need superuser access answer 503 on
// their own.
//
// Expiry is checked here rather than left to the 401 retry below, because that
// retry never fires for this client: PocketBase does not reject a stale token on
// a record call, it ignores the header and serves the request as a guest. The
// collection rules then answer instead of the transport — a superuser-only
// collection 403s, a rule-guarded record 404s, and a rule-filtered list comes
// back 200 with nothing in it. None of those are a 401, so a server whose token
// has lapsed keeps sending it and keeps being treated as a stranger to its own
// database until it is restarted. Superuser tokens are long-lived, which only
// means the failure waits weeks and then arrives as "the app forgot my account".
func (c *Client) ensureToken(ctx context.Context) error {
if c.currentToken() != "" || !c.Configured() {
if c.tokenLive() || !c.Configured() {
return nil
}
return c.Authenticate(ctx)
+118 -3
View File
@@ -2,11 +2,13 @@ package pb
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// PocketBase hides a record it will not let you read behind a 404 rather than a
@@ -31,9 +33,14 @@ type fakePB struct {
email string
password string
// When set, tokens are minted as JWTs carrying this lifetime in their exp
// claim, the way PocketBase issues them. Zero keeps the opaque test token.
jwtTTL time.Duration
authCalls int
lastIdentity string
guestAttempts int // record reads that arrived without the superuser token
issued string // the token most recently handed out
guestAttempts int // record reads that arrived without the superuser token
}
func (f *fakePB) handler() http.Handler {
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
return
}
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
token := fakeSvcToken
if f.jwtTTL > 0 {
token = testJWT(time.Now().Add(f.jwtTTL))
}
f.mu.Lock()
f.issued = token
f.mu.Unlock()
writeTestJSON(w, 200, map[string]any{"token": token})
})
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != fakeSvcToken {
f.mu.Lock()
live := f.issued
f.mu.Unlock()
if got := r.Header.Get("Authorization"); got == "" || got != live {
f.mu.Lock()
f.guestAttempts++
f.mu.Unlock()
@@ -190,3 +207,101 @@ func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
}
}
// testJWT builds a token shaped like PocketBase's: three base64url segments,
// the middle one carrying the exp claim. Only that claim is ever read, so the
// header and signature are filler.
func testJWT(exp time.Time) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." +
enc(map[string]int64{"exp": exp.Unix()}) + ".sig"
}
func TestJWTExpiry(t *testing.T) {
want := time.Now().Add(time.Hour).Truncate(time.Second)
if got := jwtExpiry(testJWT(want)); !got.Equal(want) {
t.Errorf("jwtExpiry = %v, want %v", got, want)
}
// An opaque (non-JWT) token has no readable expiry, and must not be mistaken
// for one that expired at the zero time — tokenLive takes it at face value.
for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} {
if got := jwtExpiry(tok); !got.IsZero() {
t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got)
}
}
}
// The failure this whole mechanism exists for: a superuser token that has run
// out. PocketBase does not answer 401 for one — it ignores the header and serves
// the request as a guest, so the 401 retry never fires and the client would go
// on presenting a dead token forever. The expiry check has to catch it first.
func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne: %v", err)
}
// Age the cached token past its expiry, as an uptime longer than the token's
// lifetime would.
c.mu.Lock()
c.tokenExp = time.Now().Add(-time.Minute)
c.mu.Unlock()
var rec struct{ ID string }
if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
t.Fatalf("GetOne with an expired token: %v", err)
}
if rec.ID != fakeRecordID {
t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
}
auth, guest := f.counts()
if guest != 0 {
t.Errorf("%d record read(s) went out on a dead token, want 0", guest)
}
if auth != 2 {
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
}
}
// A token close enough to its expiry that it could lapse mid-flight is renewed
// rather than spent, so a call cannot land just after the token dies.
func TestTokenNearingExpiryIsRenewed(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne: %v", err)
}
c.mu.Lock()
c.tokenExp = time.Now().Add(tokenSkew / 2)
c.mu.Unlock()
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne near expiry: %v", err)
}
if auth, _ := f.counts(); auth != 2 {
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
}
}
// A token with a real lifetime still ahead of it is reused — the expiry check
// must not turn every call into a fresh sign-in.
func TestLiveJWTIsReused(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
for i := 0; i < 3; i++ {
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne #%d: %v", i, err)
}
}
if auth, _ := f.counts(); auth != 1 {
t.Errorf("authenticated %d time(s), want 1", auth)
}
}
@@ -253,6 +253,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
{ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."},
{ID: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."},
{ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."},
{ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card, at the charger over MQTT and on the account over REST, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the charger's half is the app's own message, the account's is inferred (see rfidcards.go, mqttcards.go)."},
{ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card, both places it is held, and answer with the list afterwards (needs sn, cardNumber). WRITES; same two halves."},
{ID: "rfid-card-scan", Method: "MQTT", Endpoint: "0108 a2=7", Description: "Open the charger's card reader for twenty seconds and answer with the card tapped, or with the fact that none was (needs sn). This is what \"add through the charger\" in the Anker app does."},
{ID: "rfid-cards-charger", Method: "MQTT", Endpoint: "0104", Description: "The cards the charger itself holds, asked of the device rather than of the account (needs sn)."},
{ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."},
{ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."},
{ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."},
@@ -330,6 +334,19 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
{Value: "own", Label: "Own CSMS (full control)"},
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
}},
// controlModesDisabled hides modes from the layers *below* this one. It
// is how a superadmin takes a mode that is broken or unwanted in this
// deployment (OCPP, say) out of every organization's and user's picker
// without touching the mode this layer itself runs. Organizations carry
// the same field for their own users; see integrations_ankersolix.go.
{Key: "controlModesDisabled", Label: "Hidden control modes", Type: "multiselect",
Help: "Control modes to hide from organizations and users. A hidden mode disappears from their picker and stops taking effect for them; the mode chosen above, which is this layer's own, is unaffected. Off (monitoring only) can never be hidden — it is what a charger falls back to.",
Options: []plugins.SelectOption{
{Value: "mqtt", Label: "Anker cloud (works anywhere)"},
{Value: "modbus", Label: "Modbus TCP (local network)"},
{Value: "own", Label: "Own CSMS (full control)"},
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
}},
},
}
}
@@ -421,6 +438,10 @@ type invokeParams struct {
Company string `json:"company"`
Date string `json:"date"`
// The two RFID card writes: which card, and what to call it.
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
// The cloud MQTT actions: which command to send, the current ceiling "limit"
// carries, and the settings "mqtt-settings" writes, by the names the snapshot
// reports them under.
@@ -429,9 +450,11 @@ type invokeParams struct {
Settings map[string]any `json:"settings"`
}
// Invoke runs a named read-only capability. The upstream response body is
// returned verbatim, except for "charger-state", which is derived (see
// chargerState).
// Invoke runs a named capability. Every one of them reads, bar the two RFID
// card writes, which are the only calls in this connector that change anything
// on the account (see rfidcards.go). The upstream response body is returned
// verbatim, except for "charger-state" and those two, which are derived (see
// chargerState, rfidAfterWrite).
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
var pp invokeParams
if len(params) > 0 {
@@ -460,6 +483,28 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
}
return p.chargerState(ctx, pp.SiteID, pp.SN)
}
// Reading a card at the charger and asking the charger for its list are the
// device's own messages, not endpoints (see mqttcards.go).
if action == "rfid-card-scan" || action == "rfid-cards-charger" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
if action == "rfid-card-scan" {
return p.mqttReadCard(ctx, pp.SN)
}
return p.mqttChargerCards(ctx, pp.SN)
}
// The two card writes are a write followed by the read that checks it, so
// neither fits the single-endpoint dispatch below either.
if action == "rfid-card-save" || action == "rfid-card-delete" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
if action == "rfid-card-save" {
return p.rfidSaveCard(ctx, pp.SN, pp.CardNumber, pp.CardName)
}
return p.rfidDeleteCard(ctx, pp.SN, pp.CardNumber)
}
// The cloud MQTT actions address the charger itself over the account's broker
// rather than a REST endpoint, so they route to that transport instead of the
// single-endpoint dispatch below.
@@ -66,6 +66,31 @@ func TestDescriptor(t *testing.T) {
t.Errorf("controlMode is missing option %q", v)
}
}
// controlModesDisabled is the superadmin's hide-list: which of the modes the
// organizations and users below are offered at all. off is not among them —
// monitoring only is the fallback, so no layer may take it away.
hide, ok := fields["controlModesDisabled"]
if !ok {
t.Fatal("controlModesDisabled config field should be present")
}
if hide.Type != "multiselect" {
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
}
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
for _, o := range hide.Options {
if o.Value == "off" {
t.Error("off must not be hideable — it is what a charger falls back to")
}
if _, known := hideable[o.Value]; known {
hideable[o.Value] = true
}
}
for v, seen := range hideable {
if !seen {
t.Errorf("controlModesDisabled is missing option %q", v)
}
}
}
func TestRegistered(t *testing.T) {
@@ -44,11 +44,14 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net"
"os"
"strings"
"sync"
"time"
@@ -94,7 +97,9 @@ const (
// asks for it again; settingsWait is how long that read then waits for the
// answer, and statusReqTries how many unanswered requests it takes before a
// charger is treated as one whose firmware ignores the message — after which
// the request still goes out, but no read pays the wait for it.
// the request still goes out, but no read pays the wait for it. That verdict
// is undone by the charger answering (see ingest): it describes a spell of
// silence, not a permanent property of the firmware.
settingsMaxAge = 10 * time.Minute
settingsWait = 4 * time.Second
statusReqTries = 3
@@ -272,6 +277,16 @@ type deviceState struct {
settingsAt time.Time
triggeredUntil time.Time
// The card half, kept beside the readings rather than in them: when the
// reader last reported a card and which, and when the charger last published
// its card list and what was in it. Each carries its own time because both
// are waited on — a value that was already there before the question was
// asked is not an answer to it.
cardReadAt time.Time
cardRead string
cardsAt time.Time
cards []string
// How many status requests this charger has been asked and not answered.
// The request is cheap to send and is sent regardless; what it buys is the
// right to wait a few seconds for the reply, and a charger whose firmware
@@ -469,15 +484,46 @@ func (c *mqttConn) idleLoop() {
}
}
// mqttFrameLog writes a line for every inbound frame — the ones this package can
// read and, the whole point, the ones it cannot. Off unless ANKER_MQTT_FRAME_LOG
// is set on the server: it exists to put a name to a frame nobody has named yet
// (a card held against the reader, say, or one of the two types the map lists as
// unnamed), not to write a line per telemetry message on a running server.
var mqttFrameLog = os.Getenv("ANKER_MQTT_FRAME_LOG") != ""
// frameLogLine is that line. The bytes are the part that matters — a frame this
// package drops is unreadable here but perfectly readable afterwards — so they
// are always in it, capped so one long frame cannot fill a log.
func frameLogLine(sn, topic string, data []byte, msgType string, values map[string]any, err error) string {
hexed := hex.EncodeToString(data)
if len(hexed) > 1024 {
hexed = hexed[:1024] + "..."
}
if err != nil {
return fmt.Sprintf("ANKER-MQTT FRAME sn=%s topic=%s len=%d unreadable=%q hex=%s",
sn, topic, len(data), err.Error(), hexed)
}
return fmt.Sprintf("ANKER-MQTT FRAME sn=%s topic=%s len=%d type=%s fields=%d hex=%s values=%v",
sn, topic, len(data), msgType, len(values), hexed, values)
}
// ingest decodes one inbound message and folds it into the sending charger's
// state. Anything it cannot read is dropped: these frames come from a cloud
// connection, and a malformed one must not be recorded as a reading.
func (c *mqttConn) ingest(msg mqtt.Message) {
sn, data, ok := parseEnvelope(msg)
if !ok {
if mqttFrameLog {
log.Printf("ANKER-MQTT ENVELOPE topic=%s unreadable payload=%s", msg.Topic, msg.Payload)
}
return
}
msgType, values, err := decodeFrame(data)
// Logged before the drop below, because the frames worth naming are exactly
// the ones this package throws away.
if mqttFrameLog {
log.Print(frameLogLine(sn, msg.Topic, data, msgType, values, err))
}
if err != nil || len(values) == 0 {
return
}
@@ -493,6 +539,18 @@ func (c *mqttConn) ingest(msg mqtt.Message) {
st.values[k] = v
}
now := time.Now()
// The card frames are stamped separately: a tap and a card list are answers
// to questions this package asks one at a time, and each waits for its own.
if s, ok := values["rfidCardRead"].(string); ok && s != "" {
st.cardRead, st.cardReadAt = s, now
} else if msgType == msgEVCardRead {
// The window closed with nothing tapped. That is an answer too, and the
// wait must end on it rather than run to its own timeout.
st.cardRead, st.cardReadAt = "", now
}
if cards, ok := values["rfidCards"].([]string); ok {
st.cards, st.cardsAt = cards, now
}
// Only a message this package can name counts as a report. An unnamed one
// still leaves its fields behind, but it must not stamp settingsAt: that
// timestamp is what a control command waits on to say the charger
@@ -502,6 +560,12 @@ func (c *mqttConn) ingest(msg mqtt.Message) {
st.telemetryAt = now
case evMessages[msgType] != nil:
st.settingsAt = now
// It does report its settings after all, so whatever made it look like a
// firmware that ignores the request has passed. Without this the verdict
// was permanent — the counter only ever went up — and a charger that
// missed three requests early on was never waited for again, however
// freely it answered afterwards.
st.statusReqMisses = 0
}
c.wakeLocked()
}
@@ -584,6 +648,27 @@ func (c *mqttConn) listen(ctx context.Context, model, sn string) error {
c.mu.Lock()
c.subs[topic] = true
c.mu.Unlock()
// With the frame log on, listen to the charger's command topic as well. The
// data topic carries only what the charger says; the commands the Anker app
// sends it — the half no capture here has ever seen — go to this one, and a
// command nobody has a name for cannot be named without reading it first.
// Off by default: it is a diagnostic subscription, not part of control.
if mqttFrameLog {
cmd := commandTopic(c.creds, model, sn)
c.mu.Lock()
seen := c.subs[cmd]
c.mu.Unlock()
if !seen {
if err := c.client.Subscribe(ctx, cmd); err == nil {
c.mu.Lock()
c.subs[cmd] = true
c.mu.Unlock()
log.Printf("ANKER-MQTT listening to the command topic %s (frame log)", cmd)
} else {
log.Printf("ANKER-MQTT cannot listen to the command topic %s: %v", cmd, err)
}
}
}
return nil
}
@@ -265,3 +265,27 @@ func TestClientIDDoesNotCollideWithTheApp(t *testing.T) {
t.Errorf("client id %q does not fall back to the user id", got)
}
}
// A charger that goes quiet long enough to be written off is not written off for
// good. The miss counter decides whether a status read waits for the settings
// frame at all, so a counter that only ever rose meant one early patch of
// silence cost every later read its settings — which is the half the settings
// card is drawn from.
func TestAnsweringClearsTheStatusRequestMisses(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
// Three unanswered requests: the reads stop paying the wait.
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))))
for i := 0; i < statusReqTries; i++ {
c.noteStatusMiss("SN1")
}
if c.statusReqAnswered("SN1") {
t.Fatalf("after %d misses the charger should not be waited for", statusReqTries)
}
// Then it answers one.
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01))))
if !c.statusReqAnswered("SN1") {
t.Error("a charger that reported its settings is still being written off")
}
}
@@ -0,0 +1,36 @@
package ankersolix
import (
"errors"
"strings"
"testing"
)
// The frame nobody can read is the one worth logging, so its bytes have to be in
// the line — that is the whole of what a capture is.
func TestFrameLogLineCarriesTheBytes(t *testing.T) {
data := []byte{0xff, 0x09, 0x01, 0x02}
line := frameLogLine("EVSN1", "dt/app/A5191/EVSN1/x", data, "", nil, errors.New("bad marker"))
if !strings.Contains(line, "hex=ff090102") {
t.Errorf("unreadable frame: %q, want the bytes in it", line)
}
if !strings.Contains(line, "unreadable=") || !strings.Contains(line, "EVSN1") {
t.Errorf("unreadable frame: %q, want the reason and the serial", line)
}
line = frameLogLine("EVSN1", "t", data, "0857", map[string]any{"a": 1}, nil)
if !strings.Contains(line, "type=0857") || !strings.Contains(line, "fields=1") {
t.Errorf("readable frame: %q, want its type and field count", line)
}
}
// One long frame must not fill a log file.
func TestFrameLogLineCapsTheHex(t *testing.T) {
line := frameLogLine("EVSN1", "t", make([]byte, 4096), "0410", nil, nil)
if len(line) > 1400 {
t.Errorf("line is %d chars, want it capped", len(line))
}
if !strings.Contains(line, "...") {
t.Error("a truncated frame should say so")
}
}
@@ -0,0 +1,270 @@
package ankersolix
// Cards, at the charger rather than at the account.
//
// The Anker app offers two ways to add an RFID card: type its number, which is
// the account write in rfidcards.go, or hold the card against the reader inside
// a twenty-second window. The second one never touches the REST API. It is three
// messages on the charger's own MQTT topics, captured from the app's traffic:
//
// 0108 a2=7 open the reader -> 0908, with the UID when a card is
// tapped and without one when the
// window closes empty
// 0103 a2=1 write the card -> 0903, then 0904
// 0103 a2=2 remove it -> the same pair
// 0104 ask for the card list -> 0904
//
// Nothing here is inferred: every frame above is one this connector watched the
// app send and the charger answer. What is inferred is the account write those
// three replace, which is why a card written here is checked by reading the
// charger's own list back rather than by trusting the write.
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
const (
// cardReadWindow is how long the reader stays open. The app counts twenty
// seconds down; a couple more allow for the answer's trip back through the
// cloud, and the charger closes the window on its own either way.
cardReadWindow = 24 * time.Second
// cardWriteWait is how long a write waits for the charger to republish its
// list. The answer arrived within a second in every capture.
cardWriteWait = 8 * time.Second
// cardWriteAdd and cardWriteRemove are the two values the write takes.
cardWriteAdd uint8 = 1
cardWriteRemove uint8 = 2
)
// cardReadResult is what a scan answers with: the card that was tapped, or the
// plain fact that nothing was.
type cardReadResult struct {
SN string `json:"sn"`
Card string `json:"card,omitempty"`
Tapped bool `json:"tapped"`
Seconds int `json:"windowSeconds"`
}
// mqttReadCard opens the charger's card reader and waits for a card. The
// charger answers either way — with a UID when one is tapped, without when the
// window closes — so a scan that finds nothing is an answer rather than a
// timeout, and says so.
func (p *Plugin) mqttReadCard(ctx context.Context, sn string) (json.RawMessage, error) {
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
// Listen before asking: the charger answers in under a second, and a
// subscription made afterwards would miss it.
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardReadAt(sn)
// Field for field what the app sends, timestamp included — which is to say
// not included: the app's own reader-open frame carries none, and this
// command is copied rather than composed.
frame, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeReadCard),
})
if err != nil {
return nil, err
}
// The same encoding the other power-mode command carries; this is that
// command with a different value, and the charger expects the field on it.
if err := conn.publishFrame(ctx, model, sn, frame, mqttEncodingMode); err != nil {
return nil, fmt.Errorf("anker-solix: opening the card reader on %s: %w", sn, err)
}
ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardReadAt.After(since)
}, cardReadWindow)
if err != nil {
return nil, err
}
out := cardReadResult{SN: sn, Seconds: int(cardReadWindow / time.Second)}
if ok {
out.Card = conn.cardRead(sn)
out.Tapped = out.Card != ""
}
return json.Marshal(out)
}
// cardWriteResult is what a device write answers with: what was asked, and the
// charger's own list afterwards. Present is read from that list — the charger
// says 0903 to everything, and a write is judged by what it changed.
type cardWriteResult struct {
SN string `json:"sn"`
Action string `json:"action"` // save | delete
Number string `json:"cardNumber"`
Present bool `json:"present"`
Cards []string `json:"cards"`
Via string `json:"via"` // charger
Detail string `json:"detail,omitempty"`
}
// mqttWriteCard adds a card to the charger or removes one, and then reads the
// charger's list back. Both halves are the app's own messages.
func (p *Plugin) mqttWriteCard(ctx context.Context, sn, number string, add bool) (*cardWriteResult, error) {
number = normalizeCardNumber(number)
uid, err := hex.DecodeString(number)
if err != nil || len(uid) == 0 {
return nil, fmt.Errorf("anker-solix: %q is not a card number: the charger takes the UID as hex", number)
}
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardsAt(sn)
action := cardWriteAdd
name := "save"
if !add {
action, name = cardWriteRemove, "delete"
}
frame, err := encodeFrame(msgEVCardWrite, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, action),
uintField(0xa3, 1),
bytesField(0xa4, uid),
})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, fmt.Errorf("anker-solix: writing card %s to %s: %w", number, sn, err)
}
out := &cardWriteResult{SN: sn, Action: name, Number: number, Via: "charger"}
cards, err := p.mqttCardList(ctx, conn, model, sn, since)
if err != nil {
// The write went out; what it did is simply unknown, which is not the
// same as it having failed and must not be reported as either.
out.Detail = err.Error()
return out, nil
}
out.Cards = cards
for _, c := range cards {
if normalizeCardNumber(c) == number {
out.Present = true
break
}
}
return out, nil
}
// mqttCardList is the charger's own list of the cards it will open for. A write
// republishes it unasked; when it does not, it is asked for.
func (p *Plugin) mqttCardList(ctx context.Context, conn *mqttConn, model, sn string, since time.Time) ([]string, error) {
ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardsAt.After(since)
}, cardWriteWait)
if err != nil {
return nil, err
}
if !ok {
// Nothing came unasked, so ask.
frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, err
}
ok, err = conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardsAt.After(since)
}, cardWriteWait)
if err != nil {
return nil, err
}
}
if !ok {
return nil, fmt.Errorf("anker-solix: charger %s did not answer with its card list", sn)
}
return conn.cards(sn), nil
}
// mqttChargerCards asks the charger for its list on its own, for a caller that
// wants to know what the device holds rather than what the account does.
func (p *Plugin) mqttChargerCards(ctx context.Context, sn string) (json.RawMessage, error) {
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardsAt(sn)
frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, err
}
cards, err := p.mqttCardList(ctx, conn, model, sn, since)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"sn": sn, "cards": cards, "via": "charger"})
}
// ---- the card half of a device's state, read out from under the lock --------
func (c *mqttConn) cardReadAt(sn string) time.Time {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardReadAt
}
return time.Time{}
}
func (c *mqttConn) cardRead(sn string) string {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardRead
}
return ""
}
func (c *mqttConn) cardsAt(sn string) time.Time {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardsAt
}
return time.Time{}
}
func (c *mqttConn) cards(sn string) []string {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return append([]string(nil), st.cards...)
}
return nil
}
@@ -0,0 +1,202 @@
package ankersolix
import (
"encoding/hex"
"testing"
)
// The card-list frame, captured from a live A5191 on firmware 1.0.6.1. The nine
// UIDs in it are the nine the account's own card list answered with, which is
// what named this frame in the first place.
const capturedCardFrame = "ff09540003010f090400a10132a2020109a3080404dfe672151a90a4050457c4df2a" +
"a505046745bb4ea6050477dfd24ea70504d7b1c44ea80504778acd4ea905048a3cbc3f" +
"aa0504da7cb93fab05044754dc2a00"
func TestDecodeCardListFrame(t *testing.T) {
data, err := hex.DecodeString(capturedCardFrame)
if err != nil {
t.Fatal(err)
}
msgType, values, err := decodeFrame(data)
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if msgType != msgEVCards {
t.Fatalf("msgType = %q, want %q", msgType, msgEVCards)
}
if n, _ := values["rfidCardCount"].(float64); n != 9 {
t.Errorf("rfidCardCount = %v, want 9", values["rfidCardCount"])
}
cards, ok := values["rfidCards"].([]string)
if !ok {
t.Fatalf("rfidCards = %T, want []string", values["rfidCards"])
}
// In the charger's own order: newest first, which is the reverse of the way
// the account lists them.
want := []string{
"04DFE672151A90", "57C4DF2A", "6745BB4E", "77DFD24E", "D7B1C44E",
"778ACD4E", "8A3CBC3F", "DA7CB93F", "4754DC2A",
}
if len(cards) != len(want) {
t.Fatalf("got %d cards, want %d: %v", len(cards), len(want), cards)
}
for i, w := range want {
if cards[i] != w {
t.Errorf("card %d = %q, want %q", i, cards[i], w)
}
}
}
// A UID is an identifier, not a quantity: read as a big-endian number, 57C4DF2A
// becomes 1472519978 and matches nothing the account ever prints.
func TestCardUIDsAreHexNotNumbers(t *testing.T) {
v, ok := decodeValue(typeBytes, []byte{0x57, 0xc4, 0xdf, 0x2a}, mqttField{})
if !ok || v != "57C4DF2A" {
t.Fatalf("decodeValue = %v (%T), want \"57C4DF2A\"", v, v)
}
}
// The charger's own view of its OCPP backend, from the same capture.
func TestDecodeOcppInfoFrame(t *testing.T) {
data, err := hex.DecodeString("ff096a0003010f091100a10132a20600416e6b6572a331007773733a2f2f" +
"6368617267696e672d6f63707070726f78792d65752d70726f642e616e6b65722e636f6d2f6f6370706a" +
"a4020100a51200415432444b325a31463438333030313134a60100a70100a80100a7")
if err != nil {
t.Fatal(err)
}
msgType, values, err := decodeFrame(data)
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if msgType != msgEVOcppInfo {
t.Fatalf("msgType = %q, want %q", msgType, msgEVOcppInfo)
}
if got := values["ocppBackendName"]; got != "Anker" {
t.Errorf("ocppBackendName = %v, want Anker", got)
}
if got := values["ocppBackendUrl"]; got != "wss://charging-ocppproxy-eu-prod.anker.com/ocppj" {
t.Errorf("ocppBackendUrl = %v", got)
}
if got, _ := values["ocppSource"].(float64); got != 0 {
t.Errorf("ocppSource = %v, want 0 — the number the account's OCPP view leaves bare", values["ocppSource"])
}
if got := values["ocppChargePointId"]; got != "AT2DK2Z1F48300114" {
t.Errorf("ocppChargePointId = %v", got)
}
}
// The tap itself, captured while a card was enrolled at the charger: the reader
// reports the UID the moment the card touches it.
func TestDecodeCardReadFrame(t *testing.T) {
data, err := hex.DecodeString("ff091b0003010f090800a10132a20107a3080404dfe672151a90a8")
if err != nil {
t.Fatal(err)
}
msgType, values, err := decodeFrame(data)
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if msgType != msgEVCardRead {
t.Fatalf("msgType = %q, want %q", msgType, msgEVCardRead)
}
if got := values["rfidCardRead"]; got != "04DFE672151A90" {
t.Errorf("rfidCardRead = %v, want 04DFE672151A90", got)
}
}
// The same frame when the window closed with nothing tapped: no UID, and the
// absence is the answer.
func TestCardReadWithoutACard(t *testing.T) {
data, err := hex.DecodeString("ff09110003010f090801a10132a20107dc")
if err != nil {
t.Fatal(err)
}
msgType, values, err := decodeFrame(data)
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if msgType != msgEVCardRead {
t.Fatalf("msgType = %q, want %q", msgType, msgEVCardRead)
}
if _, ok := values["rfidCardRead"]; ok {
t.Errorf("rfidCardRead = %v, want it absent", values["rfidCardRead"])
}
}
// The two writes the Anker app sent, caught on the charger's own command topic:
// the same card removed and then added back, the action the only difference.
func TestDecodeCardWriteCommands(t *testing.T) {
for _, tc := range []struct {
name, hex string
action float64
}{
{"delete", "ff091f0003000f0103a10122a2020102a3020101a4080404dfe672151a901f", 2},
{"add", "ff091f0003000f0103a10122a2020101a3020101a4080404dfe672151a901c", 1},
} {
data, err := hex.DecodeString(tc.hex)
if err != nil {
t.Fatal(err)
}
msgType, values, err := decodeFrame(data)
if err != nil {
t.Fatalf("%s: decodeFrame: %v", tc.name, err)
}
if msgType != msgEVCardWrite {
t.Fatalf("%s: msgType = %q, want %q", tc.name, msgType, msgEVCardWrite)
}
if got, _ := values["cardWriteAction"].(float64); got != tc.action {
t.Errorf("%s: cardWriteAction = %v, want %v", tc.name, values["cardWriteAction"], tc.action)
}
if got := values["cardWritten"]; got != "04DFE672151A90" {
t.Errorf("%s: cardWritten = %v, want the UID", tc.name, got)
}
}
}
// The frames this connector sends to enrol a card, against the ones the Anker
// app was captured sending. They are compared byte for byte: neither carries a
// timestamp, so there is nothing in them that can differ between two senders,
// and anything that does differ is a difference the charger would see.
func TestCardCommandsMatchTheApp(t *testing.T) {
open, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeReadCard),
})
if err != nil {
t.Fatal(err)
}
if got, want := hex.EncodeToString(open), "ff09110003000f0108a10122a2020107c6"; got != want {
t.Errorf("reader-open frame:\n got %s\nwant %s (the app's own)", got, want)
}
list, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
t.Fatal(err)
}
if got, want := hex.EncodeToString(list), "ff090d0003000f0104a1012270"; got != want {
t.Errorf("list-request frame:\n got %s\nwant %s (the app's own)", got, want)
}
uid, _ := hex.DecodeString("04DFE672151A90")
for _, tc := range []struct {
name string
action uint8
want string
}{
{"add", cardWriteAdd, "ff091f0003000f0103a10122a2020101a3020101a4080404dfe672151a901c"},
{"delete", cardWriteRemove, "ff091f0003000f0103a10122a2020102a3020101a4080404dfe672151a901f"},
} {
frame, err := encodeFrame(msgEVCardWrite, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, tc.action),
uintField(0xa3, 1),
bytesField(0xa4, uid),
})
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got := hex.EncodeToString(frame); got != tc.want {
t.Errorf("%s frame:\n got %s\nwant %s (the app's own)", tc.name, got, tc.want)
}
}
}
@@ -55,6 +55,7 @@ const (
typeUint8 byte = 0x01
typeInt16LE byte = 0x02
typeInt32LE byte = 0x03 // "var": four bytes, though not always one value
typeBytes byte = 0x04 // bytes that are an identifier, not a number — an RFID UID
typeFloat32 byte = 0x05
typeNone byte = 0xff // this package's marker for "no type byte"
)
@@ -81,8 +82,45 @@ const (
msgEVParamsAlt = "0840" // the same fields, in answer to a status request
msgEVConfirm = "0900" // the same fields again, confirming a control change
msgEVCharging = "0403" // a couple of charging parameters
// The charger's own copy of the RFID card list, published when the account
// asks it for one. Named from a live capture: nine UIDs in one frame, the
// same nine the cloud's get_device_cards answered with, in reverse order —
// newest first. The count is a field; the cards are one field each, so they
// are collected rather than mapped (see decodeFrame).
msgEVCards = "0904"
// Which OCPP backend the charger is pointed at, from the charger rather than
// from the account: the same address get_ocpp_info reports, and the source
// number that view leaves as a bare integer.
msgEVOcppInfo = "0911"
// The three commands the app sends about cards, all captured from its own
// traffic on the charger's command topic. The pattern is the connector's
// everywhere: a command 01xx is answered by the data frame 09xx.
//
// 0103 write one card: a2 = 1 add, 2 delete; a4 = the UID -> 0903, then 0904
// 0104 ask for the list -> 0904
// 0111 ask which OCPP backend -> 0911
//
// The fourth is not its own message: opening the reader is the power-mode
// command with powerModeReadCard, which is answered by 0908.
msgEVCardWrite = "0103"
msgEVCardListReq = "0104"
msgEVOcppInfoReq = "0111"
// powerModeRestart is the only value the power-mode command is known to take.
// The reader, reporting a card held against it. Captured during an
// enrol-at-the-charger: the frame arrives the moment the card is tapped,
// carrying the UID, and the charger publishes its updated card list a second
// later. The same frame arrives without a UID when the twenty-second window
// closes with nothing tapped, which is what makes the UID field the event.
msgEVCardRead = "0908"
// powerModeReadCard opens the card reader for a tap: the app sends the
// power-mode command with this value and the charger answers 0908 — with the
// UID if a card was held against it inside the twenty seconds, and without one
// if the window closed empty. Captured from the app on an A5191; the same 7
// comes back in the answer.
powerModeReadCard uint8 = 7
// powerModeRestart is the only other value the power-mode command is known to take.
// The map documents 5 and nothing else, so nothing else is sent.
powerModeRestart uint8 = 5
)
@@ -202,6 +240,44 @@ var evCharging = map[byte]mqttField{
0xa6: {name: "solarMinCurrentA"},
}
// evCards names what is not a card in the card-list frame. The cards themselves
// start at 0xa3 and run one per field for as many as the charger holds, so they
// cannot be a map — decodeFrame collects them.
var evCards = map[byte]mqttField{
0xa2: {name: "rfidCardCount"},
}
// evCardRead names the reader's own event. a2 was 7 in every capture, with and
// without a card, so it is relayed under its own key rather than guessed at.
var evCardRead = map[byte]mqttField{
0xa3: {name: "rfidCardRead"},
}
// evCardWrite names the write the app sends. a3 was 1 on both the add and the
// delete, so it keeps its own key rather than a guessed name.
var evCardWrite = map[byte]mqttField{
0xa2: {name: "cardWriteAction"}, // 1 add, 2 delete
0xa4: {name: "cardWritten"},
}
// evOcppInfo names the charger's own view of its OCPP backend.
var evOcppInfo = map[byte]mqttField{
0xa2: {name: "ocppBackendName"},
0xa3: {name: "ocppBackendUrl"},
0xa4: {name: "ocppSource"},
0xa5: {name: "ocppChargePointId"},
}
// evInfoMessages are messages this package can name but must not treat as a
// settings report: evMessages is what stamps the timestamp a control command
// waits on, and a card list is not an acknowledgement of anything.
var evInfoMessages = map[string]map[byte]mqttField{
msgEVCards: evCards,
msgEVOcppInfo: evOcppInfo,
msgEVCardRead: evCardRead,
msgEVCardWrite: evCardWrite,
}
// evMessages selects a field map by message type. A type absent from here is one
// we have no map for; its frame is still parsed, but nothing is named.
var evMessages = map[string]map[byte]mqttField{
@@ -247,6 +323,12 @@ func varField(name byte, v uint32) cmdField {
return cmdField{name: name, typ: typeInt32LE, value: b}
}
// bytesField builds a field holding bytes that are an identifier rather than a
// number — an RFID UID, four bytes or seven, exactly as the reader reported it.
func bytesField(name byte, b []byte) cmdField {
return cmdField{name: name, typ: typeBytes, value: append([]byte(nil), b...)}
}
// timestampField is the `fe` field every command ends with: the sender's clock,
// in whole seconds.
func timestampField(now time.Time) cmdField {
@@ -365,6 +447,9 @@ func decodeFrame(data []byte) (string, map[string]any, error) {
}
fields := evMessages[msgType]
if fields == nil {
fields = evInfoMessages[msgType]
}
values := map[string]any{}
for _, r := range raw {
f, known := fields[r.name]
@@ -380,9 +465,28 @@ func decodeFrame(data []byte) (string, map[string]any, error) {
values[f.name] = v
}
}
if msgType == msgEVCards {
values["rfidCards"] = cardsFromFields(raw)
}
return msgType, values, nil
}
// cardsFromFields reads the card UIDs out of a card-list frame. Every field from
// 0xa3 up is one card, in the order the charger sent them, and a UID is however
// many bytes the card has — four for the older tags, seven for the newer ones —
// so the bytes are taken as they are and read as hex, which is how the account
// prints them and how a person reads one off a card.
func cardsFromFields(raw []rawFieldBytes) []string {
out := []string{}
for _, r := range raw {
if r.name < 0xa3 || len(r.value) == 0 {
continue
}
out = append(out, strings.ToUpper(encodeHex(r.value)))
}
return out
}
// rawFieldName keys a field no map names, by the message it arrived in and the
// name byte the charger gave it — "0410.b6". The message type belongs in the key
// because a name byte means whatever its message says it means: the same b6 is a
@@ -474,6 +578,11 @@ func decodeValue(typ byte, b []byte, f mqttField) (any, bool) {
return scale(int64(binary.LittleEndian.Uint32(b))), true
}
return scale(int64(int32(binary.LittleEndian.Uint32(b)))), true
case typeBytes:
// An identifier rather than a quantity: a 4- or 7-byte card UID, which is
// the same hex the account prints on the card list. Read as a number it
// would be a different string on every screen it reached.
return strings.ToUpper(encodeHex(b)), true
case typeFloat32:
if len(b) < 4 {
return nil, false
@@ -224,9 +224,22 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
// carrying an Anker bug, so a firmware that ignores it must not tax every read
// with the same wait forever.
if askedSettings && conn.statusReqAnswered(sn) {
// A charger that has never reported its settings is a different case from
// one whose settings have merely gone stale. Stale has something to fall
// back on — the values are still there, and a read that misses costs the
// caller nothing but their age. Never-reported has nothing: the settings
// are absent from the answer entirely, and the card that reads them draws
// almost nothing. So the first ones are given the same wait a settings
// write gives them rather than the short one that only has to catch a
// refresh. It is still bounded by statusReqTries, so a charger that truly
// never answers costs that wait three times and then stops being asked to.
wait := settingsWait
if settingsAt.IsZero() {
wait = statusWait
}
settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.settingsAt.After(cutoff)
}, settingsWait)
}, wait)
if werr == nil && !settled {
conn.noteStatusMiss(sn)
}
@@ -0,0 +1,192 @@
package ankersolix
// The cards that open the charger, and the two writes that change them.
//
// get_device_cards is well behaved: every card comes back as alias_name,
// card_number and create_time, and nothing else. There is no card id anywhere in
// that payload, which is why both writes below address a card by its number —
// it is the only handle the account ever gives out.
//
// save_device_card and delete_device_card are a different matter. Neither is
// documented, by Anker or by the reference implementation: both names were read
// out of the app package, and the bodies here are inferred from the field names
// the read view answers with. That is a guess, and it is treated as one:
//
// - a write never reports its own success. After the call the card list is
// read again and the answer says whether the card is on the charger now, so
// a caller never has to take an ack's word for what happened;
// - the cloud's own response is relayed alongside it, because an endpoint
// nobody has documented is one whose reply is worth reading;
// - nothing here deletes by pattern, by index, or in bulk. One card, named in
// full, per call.
import (
"context"
"encoding/json"
"fmt"
"strings"
)
const (
epRfidSaveCard = "power_service/v1/rfid/save_device_card" // add or rename one card — payload inferred, see above
epRfidDeleteCard = "power_service/v1/rfid/delete_device_card" // remove one card — payload inferred, see above
)
// rfidCard is one authorised card, under the names get_device_cards uses for it.
// create_time is relayed rather than parsed: it is upstream's own epoch and the
// UI already knows how to read one.
type rfidCard struct {
Name string `json:"alias_name,omitempty"`
Number string `json:"card_number,omitempty"`
Added json.RawMessage `json:"create_time,omitempty"`
}
// rfidWriteResult is what a write answers with: what was asked, what the cloud
// said, and — the part that matters — the list as it stands afterwards.
type rfidWriteResult struct {
SN string `json:"sn"`
Action string `json:"action"` // save | delete
Number string `json:"cardNumber"` // as it was sent, normalized
Present bool `json:"present"` // whether the account holds that card now
Cards []rfidCard `json:"cards"`
// What the charger itself did, when it could be reached: the write the Anker
// app makes, and the only one of the two that is not inferred.
Charger *cardWriteResult `json:"charger,omitempty"`
Response json.RawMessage `json:"response,omitempty"`
Detail string `json:"detail,omitempty"` // why the list could not be read back
}
// normalizeCardNumber puts a card number in the form the account stores it in.
// The numbers arrive as bare uppercase hex; people type them with spaces, dashes
// or colons between the bytes, and a number that differs from the stored one
// only in punctuation would delete nothing and add a duplicate.
func normalizeCardNumber(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= 'a' && r <= 'z':
b.WriteRune(r - 'a' + 'A')
}
}
return b.String()
}
// rfidCardLabel is the name a card gets when it is added without one — the same
// shape the Anker app writes, so a card added here does not stand out in it.
func rfidCardLabel(number string) string {
if len(number) > 4 {
number = number[len(number)-4:]
}
return "RFID " + number
}
// parseRfidCards reads the card list out of get_device_cards' envelope. A
// response that carries no list is an empty charger, not an error: an account
// with no cards answers exactly that way.
func parseRfidCards(body []byte) ([]rfidCard, error) {
var env struct {
Data struct {
List []rfidCard `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data.List, nil
}
// cardPresent says whether a number is among the cards, comparing them the way
// normalizeCardNumber writes them so punctuation cannot answer for the account.
func cardPresent(cards []rfidCard, number string) bool {
want := normalizeCardNumber(number)
for _, c := range cards {
if normalizeCardNumber(c.Number) == want {
return true
}
}
return false
}
// rfidList is the cards authorised on one charger, as the account holds them.
func (p *Plugin) rfidList(ctx context.Context, sn string) ([]rfidCard, error) {
body, err := p.apiRequest(ctx, epRfidCards, map[string]any{"device_sn": sn})
if err != nil {
return nil, err
}
return parseRfidCards(body)
}
// rfidSaveCard adds a card, both places it has to be added.
//
// The charger is written first, with the app's own message: it is the device
// that decides who may start a charge, and that write is the one this connector
// watched the app make rather than inferred. The account write follows because
// it is the half that carries a name — the charger's message has no name field —
// and because the list people read is the account's. Either may fail on its own
// and the answer says which; only both failing is an error.
func (p *Plugin) rfidSaveCard(ctx context.Context, sn, number, name string) (json.RawMessage, error) {
number = normalizeCardNumber(number)
if number == "" {
return nil, fmt.Errorf("anker-solix: a card number is required")
}
name = strings.TrimSpace(name)
if name == "" {
name = rfidCardLabel(number)
}
dev, devErr := p.mqttWriteCard(ctx, sn, number, true)
body, err := p.apiRequest(ctx, epRfidSaveCard, map[string]any{
"device_sn": sn,
"card_number": number,
"alias_name": name,
})
if err != nil && devErr != nil {
return nil, fmt.Errorf("anker-solix: saving card %s: charger: %v; account: %v", number, devErr, err)
}
return p.rfidAfterWrite(ctx, sn, "save", number, body, dev, devErr)
}
// rfidDeleteCard removes one card from the charger. The caller names the whole
// number: there is no "delete the third one" here, because an index into a list
// that was read a minute ago is not a card.
func (p *Plugin) rfidDeleteCard(ctx context.Context, sn, number string) (json.RawMessage, error) {
number = normalizeCardNumber(number)
if number == "" {
return nil, fmt.Errorf("anker-solix: a card number is required")
}
dev, devErr := p.mqttWriteCard(ctx, sn, number, false)
body, err := p.apiRequest(ctx, epRfidDeleteCard, map[string]any{
"device_sn": sn,
"card_number": number,
})
if err != nil && devErr != nil {
return nil, fmt.Errorf("anker-solix: deleting card %s: charger: %v; account: %v", number, devErr, err)
}
return p.rfidAfterWrite(ctx, sn, "delete", number, body, dev, devErr)
}
// rfidAfterWrite reads the list back and answers with it. A list that cannot be
// read is not a failed write — the write already happened — so it is reported as
// the detail beside an answer that says nothing about presence rather than
// guessing at one.
func (p *Plugin) rfidAfterWrite(ctx context.Context, sn, action, number string, response []byte,
dev *cardWriteResult, devErr error) (json.RawMessage, error) {
out := rfidWriteResult{SN: sn, Action: action, Number: number, Response: json.RawMessage(response)}
if dev != nil {
out.Charger = dev
} else if devErr != nil {
// The charger could not be reached or would not answer. The account write
// may still have landed, so this is said beside the answer rather than
// instead of it.
out.Charger = &cardWriteResult{SN: sn, Action: action, Number: number, Via: "charger", Detail: devErr.Error()}
}
cards, err := p.rfidList(ctx, sn)
if err != nil {
out.Detail = err.Error()
} else {
out.Cards = cards
out.Present = cardPresent(cards, number)
}
return json.Marshal(out)
}
@@ -0,0 +1,99 @@
package ankersolix
import (
"context"
"encoding/json"
"strings"
"testing"
)
// A card number typed with punctuation is the same card as the one the account
// stores bare — anything else deletes nothing and adds a duplicate.
func TestNormalizeCardNumber(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"4754DC2A", "4754DC2A"},
{"47:54:dc:2a", "4754DC2A"},
{" 47-54 dc 2a ", "4754DC2A"},
{"04dfe672151a90", "04DFE672151A90"},
{"", ""},
{" :- ", ""},
} {
if got := normalizeCardNumber(tc.in); got != tc.want {
t.Errorf("normalizeCardNumber(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The name a card gets when it is added without one, in the shape the Anker app
// writes so a card added here does not stand out in it.
func TestRfidCardLabel(t *testing.T) {
if got := rfidCardLabel("4754DC2A"); got != "RFID DC2A" {
t.Errorf("rfidCardLabel = %q, want %q", got, "RFID DC2A")
}
if got := rfidCardLabel("2A"); got != "RFID 2A" {
t.Errorf("short number: rfidCardLabel = %q, want %q", got, "RFID 2A")
}
}
func TestParseRfidCards(t *testing.T) {
body := []byte(`{"code":0,"data":{"list":[
{"alias_name":"RFID DC2A","card_number":"4754DC2A","create_time":1788187299},
{"alias_name":"RFID B93F","card_number":"DA7CB93F","create_time":1788187315}]}}`)
cards, err := parseRfidCards(body)
if err != nil {
t.Fatalf("parseRfidCards: %v", err)
}
if len(cards) != 2 {
t.Fatalf("got %d cards, want 2", len(cards))
}
if cards[0].Name != "RFID DC2A" || cards[0].Number != "4754DC2A" {
t.Fatalf("first card = %+v", cards[0])
}
if string(cards[0].Added) != "1788187299" {
t.Fatalf("create_time = %q, want it relayed as it arrived", cards[0].Added)
}
// A charger with no cards answers with an empty document, which is an answer
// and not a failure.
cards, err = parseRfidCards([]byte(`{"code":0,"data":{}}`))
if err != nil || len(cards) != 0 {
t.Fatalf("empty list: got %d cards, err %v", len(cards), err)
}
}
// Presence is what a write is judged by, and it is judged on the number rather
// than on how either side punctuated it.
func TestCardPresent(t *testing.T) {
cards := []rfidCard{{Number: "4754DC2A"}, {Number: "DA7CB93F"}}
if !cardPresent(cards, "47:54:dc:2a") {
t.Error("punctuated number: want present")
}
if cardPresent(cards, "DEADBEEF") {
t.Error("unknown number: want absent")
}
if cardPresent(nil, "4754DC2A") {
t.Error("empty charger: want absent")
}
}
// Neither write may reach the cloud without knowing which charger and which
// card: an endpoint nobody has documented is not one to send half a request to.
func TestCardWritesRefuseIncompleteRequests(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
for _, action := range []string{"rfid-card-save", "rfid-card-delete"} {
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
!strings.Contains(err.Error(), "requires an sn") {
t.Errorf("%s without a serial: got %v, want an sn-required error", action, err)
}
// A serial but no card, and no card number to be found in punctuation
// either: both stop before anything is sent.
for _, params := range []string{`{"sn":"EVSN1"}`, `{"sn":"EVSN1","cardNumber":" :- "}`} {
_, err := p.Invoke(context.Background(), action, json.RawMessage(params))
if err == nil || !strings.Contains(err.Error(), "card number is required") {
t.Errorf("%s with %s: got %v, want a card-number-required error", action, params, err)
}
}
}
}
+4 -3
View File
@@ -44,7 +44,8 @@ const (
StatusDown = "down"
)
// SelectOption is one choice for a ConfigField of Type "select".
// SelectOption is one choice for a ConfigField of Type "select" (pick one) or
// "multiselect" (pick any number; stored as a comma-separated list of values).
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
@@ -55,12 +56,12 @@ type SelectOption struct {
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // "text" | "password" | "number" | "select"
Type string `json:"type"` // "text" | "password" | "number" | "select" | "multiselect"
Required bool `json:"required"`
Secret bool `json:"secret"` // never echoed back to clients in clear
Help string `json:"help,omitempty"`
Default string `json:"default,omitempty"` // effective default when unset
Options []SelectOption `json:"options,omitempty"` // for Type "select"
Options []SelectOption `json:"options,omitempty"` // for Type "select" and "multiselect"
}
// Capability is one operation a plugin exposes. It maps a stable id to the
@@ -62,6 +62,31 @@ function expand(p) {
open.value = p.name;
}
// A multiselect field is a comma-separated list of option values in the same
// flat string map every other field uses, so the PUT body stays unchanged.
function multiValues(name, key) {
return String(drafts[name]?.[key] ?? "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
function hasMulti(name, key, value) {
return multiValues(name, key).includes(value);
}
function toggleMulti(name, key, value, on) {
const set = new Set(multiValues(name, key));
if (on) set.add(value);
else set.delete(value);
// Keep the declared option order rather than click order, so the stored value
// is stable across edits.
const p = plugins.value.find((x) => x.name === name);
const order = (p?.configFields || []).find((f) => f.key === key)?.options || [];
drafts[name][key] = order
.map((o) => o.value)
.filter((v) => set.has(v))
.join(",");
}
async function save(p, enabled) {
busy.value = true;
rowNotice[p.name] = "";
@@ -224,7 +249,25 @@ const healthClass = (s) =>
<label class="dh-label">
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
</label>
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- multiselect: any number of the declared options, stored as a
comma-separated list. Nothing checked means the field imposes
nothing, which is the unset state for a select too. -->
<div v-if="f.type === 'multiselect'" class="flex flex-col gap-1.5 pt-1">
<label
v-for="o in f.options || []"
:key="o.value"
class="flex items-center gap-2 text-sm text-body"
>
<input
type="checkbox"
class="dh-checkbox"
:checked="hasMulti(p.name, f.key, o.value)"
@change="toggleMulti(p.name, f.key, o.value, $event.target.checked)"
/>
<span>{{ o.label || o.value }}</span>
</label>
</div>
<select v-else-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- A non-required select can be left unset (empty), so the global
layer abstains and lower layers (org / user) may choose. -->
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
+14
View File
@@ -293,6 +293,20 @@ body {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* Checkbox for multiselect config fields. accent-color keeps the native control
(and its keyboard behaviour) while tinting it to the panel's accent. */
.dh-checkbox {
width: 15px;
height: 15px;
flex: none;
accent-color: var(--accent);
cursor: pointer;
}
.dh-checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
border-radius: 3px;
}
.dh-label {
display: block;
margin-bottom: 5px;
+41 -1
View File
@@ -462,6 +462,31 @@ const DESIRED = {
// defined through the API, so it is declared here like the audit trail's.
F.autodate("created", true, false),
],
// The home-charger scheduler: a user's own list of charging tasks, one list
// covering every charger they own. The charger's own cloud schedule holds one
// window per box; this holds as many tasks as they like, each naming its own
// chargers, days and action. Run by the ticker in the API Server
// (internal/api/chargingtasks_run.go).
charging_tasks: [
F.text("name", true),
// The home_chargers rows this task acts on. A list of ids rather than a
// relation because empty has to mean "every charger I own" — a standing wish
// that keeps covering chargers imported later.
F.json("chargers", 2000),
// The flow: [{action, amps, time}, …] in the order it runs. A whole charging
// window is one task rather than the two that would otherwise open and close
// it, so it is named once and switched off once. Times are 24-hour "HH:MM"
// read in the zone below.
F.json("steps", 4000),
F.text("zone"),
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
F.bool("enabled"),
F.text("last_run"), // RFC3339, UTC — also the guard against a double firing
F.text("last_result"),
// Owner. Non-cascading, like a charger's.
F.relation("owner", "users", false, false),
F.autodate("created", true, false),
],
// Server-wide settings as a single record, keyed "global". Today it holds
// pluginSettings: the top (L1) layer of the integration cascade — every
// plugin's enable state, its global config, and the registration of any
@@ -490,6 +515,13 @@ const DESIRED = {
F.select("theme", ["light", "dark", "system"]),
F.text("locale"),
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
// "auto" is the region's own convention. Kept in step with
// validTimeFormats in internal/api/me.go.
F.select("time_format", ["auto", "24", "12"]),
// The day a week is drawn as starting on, wherever a client lays weekdays
// out in a row. "auto" is the region's own convention. Kept in step with
// validWeekStarts in internal/api/me.go.
F.select("week_start", ["auto", "monday", "sunday"]),
// European currencies plus the non-European ones the panel already offered.
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
// in the web app's Settings.vue.
@@ -546,6 +578,11 @@ const INDEXES = {
// A charger is looked up by its owner, and by serial when checking whether the
// account it came from has already been imported.
home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"],
// The runner sweeps every enabled task on every tick, and the page reads one
// owner's; both go through these two columns.
charging_tasks: [
"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)",
],
// Audit is queried "this charger's events, newest first" and "this user's events".
control_audit: [
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
@@ -583,6 +620,7 @@ async function main() {
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
@@ -611,6 +649,7 @@ async function main() {
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
@@ -619,7 +658,8 @@ async function main() {
"\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
"service_records,\n" +
"technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" +
"maintenance_entries, car_documents, reminders, control_audit, home_chargers.",
"maintenance_entries, car_documents, reminders, control_audit, home_chargers,\n" +
"charging_tasks.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
+85
View File
@@ -0,0 +1,85 @@
# DriverVault all-in-one — production config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.s3.yml pull
# docker compose -f docker-compose.prod.s3.yml up -d
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP, so
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at. Turning the check off is
# for trusted networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
API_PORT=8080
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. Set it to an absolute host path
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
# image: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- File storage: external S3 -----------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
# already exist. For a gateway on this Docker host use
# http://host.docker.internal:8333 — the compose file adds the host entry that
# makes that name resolve inside the containers.
PB_S3_ENDPOINT=http://10.2.1.10:8333
PB_S3_BUCKET=drivervault
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE=true
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+95
View File
@@ -0,0 +1,95 @@
# DriverVault all-in-one — production config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.yml pull
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP, so
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at. Turning the check off is
# for trusted networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
API_PORT=8080
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. Set it to an absolute host path
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
# image: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: they configure the SeaweedFS gateway's single
# identity *and* are what PocketBase authenticates with. There are no safe
# defaults, and the stack refuses to start without them.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
# by default; set an absolute host path for a bind mount, the same way PB_DATA
# works above. Back it up alongside PB_DATA: from here on the attachments live
# here, not in the database volume.
SEAWEED_DATA=seaweed_data
# The gateway image, pinned so a redeploy months from now brings up the same one.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# The S3 port is published on loopback only — the stack reaches the gateway over
# the compose network, and this is for tools like aws-cli. Set
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
# SEAWEED_S3_BIND=127.0.0.1
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
@@ -0,0 +1,129 @@
# DriverVault all-in-one — production config, SeaweedFS split into its four roles.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP, so
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at. Turning the check off is
# for trusted networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
API_PORT=8080
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. Set it to an absolute host path
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
# state on disk, so everything it owns (plugin settings included) is backed up
# by backing up this one path — together with SEAWEED_DATA below.
PB_DATA=pb_data
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: seaweedfs-init writes them into the filer's
# IAM store as the identity named "drivervault" *and* they are what PocketBase
# authenticates with. There are no safe defaults, and the stack refuses to start
# without them. Change them here and restart to rotate: the seed updates the
# identity in place rather than adding a second one.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
# by default; set an absolute host path for a bind mount, the same way PB_DATA
# works above. Back it up alongside PB_DATA: from here on the attachments live
# here, not in the database volume.
#
# The master, volume and filer containers all mount it at /data, which is the
# layout `weed server -dir=/data` writes — so this file and
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
# nothing to migrate either way.
SEAWEED_DATA=seaweed_data
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
# one. All five SeaweedFS containers run it.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# The S3 port is published on loopback only — the stack reaches the gateway over
# the compose network, and this is for tools like aws-cli. Set
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
# SEAWEED_S3_BIND=127.0.0.1
# SEAWEED_S3_PORT=8333
#
# The master, volume and filer publish no host port at all. The admin UI below
# shows what they would: the volume server in particular serves file content by
# id with no authentication, so it stays on the compose network. Reach the
# others with `docker compose exec`.
# --- SeaweedFS admin UI ------------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
# Users, where further S3 identities are created and revoked. They land in the
# filer's IAM store, the same one seeded above, and the gateway picks them up
# without a restart.
#
# REQUIRED: weed disables authentication entirely when the password is empty,
# and this panel can mint credentials for the bucket.
SEAWEED_ADMIN_USER=admin
SEAWEED_ADMIN_PASSWORD=
# Optional view-only login.
SEAWEED_ADMIN_READONLY_USER=
SEAWEED_ADMIN_READONLY_PASSWORD=
# Bound to localhost by default, the same call SEAWEED_S3_BIND makes: storage
# plumbing, not one of the app's own panels. On a remote host that means
# unreachable — set 0.0.0.0 and put it behind a reverse proxy.
SEAWEED_ADMIN_BIND=127.0.0.1
SEAWEED_ADMIN_PORT=23646
# Its own small volume: session key and maintenance-task state, no object data.
SEAWEED_ADMIN_DATA=seaweed_admin
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+79
View File
@@ -0,0 +1,79 @@
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.s3.yml.
# --- Required (no defaults) --------------------------------------------------
# PocketBase superuser, also used by the API Server to authenticate.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP:
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
# API Server + its embedded web panel (served at the API root, http://host:8080/).
API_PORT=8080
# --- Build args (optional) ---------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by nginx).
VITE_API_BASE=
# PocketBase version. The Dockerfile already pins one; set this only to build a
# different version. Leaving it commented out keeps the pin (an empty value here
# is passed through as-is and would resolve the latest release at build time).
#PB_VERSION=0.39.11
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- File storage: external S3 -----------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
# already exist. For a gateway on this Docker host use
# http://host.docker.internal:8333 — the compose file adds the host entry that
# makes that name resolve inside the containers.
PB_S3_ENDPOINT=http://host.docker.internal:8333
PB_S3_BUCKET=drivervault
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE=true
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+80
View File
@@ -0,0 +1,80 @@
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.seaweedfs.yml.
# --- Required (no defaults) --------------------------------------------------
# PocketBase superuser, also used by the API Server to authenticate.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP:
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
# API Server + its embedded web panel (served at the API root, http://host:8080/).
API_PORT=8080
# --- Build args (optional) ---------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by nginx).
VITE_API_BASE=
# PocketBase version. The Dockerfile already pins one; set this only to build a
# different version. Leaving it commented out keeps the pin (an empty value here
# is passed through as-is and would resolve the latest release at build time).
#PB_VERSION=0.39.11
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: they configure the SeaweedFS gateway's single
# identity *and* are what PocketBase authenticates with. There are no safe
# defaults, and the stack refuses to start without them.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The gateway image, pinned so a rebuild months from now brings up the same one.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+107
View File
@@ -0,0 +1,107 @@
# Copy to .env and fill in. Used by the Docker-AIO
# docker-compose.seaweedfs.split.yml — the same stack as .env.seaweedfs.example,
# with SeaweedFS running as separate master / volume / filer / S3 containers
# plus the SeaweedFS admin UI.
# --- Required (no defaults) --------------------------------------------------
# PocketBase superuser, also used by the API Server to authenticate.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This image serves plain HTTP:
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
# API Server + its embedded web panel (served at the API root, http://host:8080/).
API_PORT=8080
# --- Build args (optional) ---------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by nginx).
VITE_API_BASE=
# PocketBase version. The Dockerfile already pins one; set this only to build a
# different version. Leaving it commented out keeps the pin (an empty value here
# is passed through as-is and would resolve the latest release at build time).
#PB_VERSION=0.39.11
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the value below is re-applied on every restart and wins. Set it
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
# own PocketBase and is not meant to be repointed.)
# WEBAPP_URL the Web App address the panel status page probes; nginx
# serves it on port 80 inside this container.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# WEBAPP_URL=http://127.0.0.1:80
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: seaweedfs-init writes them into the filer's
# IAM store as the identity named "drivervault" *and* they are what PocketBase
# authenticates with. There are no safe defaults, and the stack refuses to start
# without them. Change them here and restart to rotate: the seed updates the
# identity in place rather than adding a second one.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
# one. All five SeaweedFS containers run it.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# --- SeaweedFS admin UI ------------------------------------------------------
# http://localhost:23646 — cluster topology, volumes, buckets, and
# Object Store → Users, where further S3 identities are created and revoked.
# They land in the filer's IAM store, the same one seeded above, and the gateway
# picks them up without a restart.
#
# REQUIRED: weed disables authentication entirely when the password is empty,
# and this panel can mint credentials for the bucket.
SEAWEED_ADMIN_USER=admin
SEAWEED_ADMIN_PASSWORD=
# SEAWEED_ADMIN_PORT=23646
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
# Published for aws-cli, `weed shell` and poking around while developing. The
# stack itself reaches every one of these over the compose network.
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
# authentication, so do not carry this mapping over to a shared machine. It
# lands on 8081 because API_PORT already has 8080.
# SEAWEED_MASTER_PORT=9333
# SEAWEED_VOLUME_PORT=8081
# SEAWEED_FILER_PORT=8888
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+101
View File
@@ -88,6 +88,107 @@ volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount
> container. Set the corresponding environment variables to change them
> permanently.
## File storage (SeaweedFS / S3)
Uploaded files — document scans, service and refill receipts, workshop invoices,
part photos — live inside `pb_data` by default, next to the database. Two further
compose files put them in an S3 bucket instead, so the blobs and the database can
be sized, backed up and moved independently. Nothing else changes: an attachment has
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
never from a storage URL, so the Web App, the phone app and the Home Assistant
plugin cannot tell the difference.
Each shape is one self-contained compose file — nothing to layer, nothing to
remember — with an `.env` example of the same name:
| Shape | From the registry | From source |
|---|---|---|
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
| **SeaweedFS beside the image** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
| **An S3 endpoint elsewhere** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
So `docker-compose.prod.seaweedfs.yml` is configured from
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
and so on:
```sh
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
docker compose -f docker-compose.prod.seaweedfs.yml pull
docker compose -f docker-compose.prod.seaweedfs.yml up -d
```
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
start without them. The SeaweedFS ones run the gateway as a **second container**
(master, volume, filer and S3 in one process, on its own `seaweed_data` volume)
rather than a fourth process under supervisord: keeping the object store in this
image, on the volume the files are being moved off, would defeat the point and
would mean rebuilding. They also run a one-shot `seaweedfs-init` that creates the
bucket, because PocketBase never issues a `CreateBucket` of its own. The
external-S3 ones add no containers at all: set `PB_S3_ENDPOINT`, and create the
bucket yourself.
### Split SeaweedFS
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
one process. The `.split.` files run them as four containers beside the
all-in-one, plus a fifth: the SeaweedFS **admin UI** on port 23646, where the
cluster can be inspected and — under *Object Store → Users* — further S3
identities minted and revoked. Split also gets you per-role restarts and
upgrades, per-role Prometheus metrics, and room to add a second volume server
later. Still none of them inside the image, for the reason above.
Identities work differently there, and it matters. SeaweedFS reads credentials
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces* a
lower one rather than adding to it. The single-gateway files use the env vars,
which is why nothing else may write identities there: the first user added in a
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
seeds PocketBase's identity into the filer's store instead — the same store the
admin UI writes — and the gateway runs with no config file at all. One source of
truth, PocketBase's key visible in the panel beside every other, and new keys
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
updates that identity in place.
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
authentication when it is empty, and a panel that can mint bucket credentials is
the bucket. In the prod file it is bound to loopback like `SEAWEED_S3_BIND` — it
is storage plumbing, not one of the app's own panels — so a remote host needs
`SEAWEED_ADMIN_BIND=0.0.0.0` behind a reverse proxy. That file publishes nothing
for master, volume and filer: the volume server serves file content by id with
no authentication of any kind, and the admin UI already shows what those ports
would.
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
migration: master, volume and filer share one `/data` mount, which is exactly
the layout `weed server -dir=/data` writes.
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
settings from those variables, then asks PocketBase to prove it can reach the
bucket. Watch for it in the log:
```
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
[api] bootstrap: ✓ S3 storage reachable
```
A boot that finds the settings already correct logs `• file storage already on S3`
and writes nothing.
Two things to know before turning it on:
- **Existing files are not migrated.** PocketBase copies nothing when the setting
flips, so attachments uploaded before the switch stop resolving. Copy
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
that layout, *before* enabling it — or start from a stack with no attachments.
- **Going back to the plain compose file is not an off switch.** It leaves
PocketBase pointed at
the bucket, deliberately: files already written there are reachable only while
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
alongside it, or set it in the admin UI.
## Charger control (OCPP)
Chargers in own/proxy mode dial in to `/ocpp/{serial}` on the **API Server port
+101
View File
@@ -0,0 +1,101 @@
name: drivervault-aio
# Production all-in-one, with external S3 — pulls the prebuilt image from the
# registry instead of building. Self-contained: one file, no overlays.
# Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
# 2. docker compose -f docker-compose.prod.s3.yml pull
# 3. docker compose -f docker-compose.prod.s3.yml up -d
#
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
# somewhere else — its own host, another compose project, or any S3-compatible
# service. PocketBase keeps its record files — document scans, service and
# refill receipts, workshop invoices, part photos — in that bucket instead of on
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
# Clients cannot tell the difference: an attachment has always been fetched
# through the API Server, never from a storage URL.
#
# The bucket must already exist, and nothing here runs the gateway. For a
# SeaweedFS that comes up with the container, use
# docker-compose.prod.seaweedfs.yml.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
# Server creates any missing collections and the DriverVault super-admin from
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
services:
drivervault:
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
container_name: drivervault-aio
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA. The bucket must already exist: nothing here
# creates it.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume — named by default; set PB_DATA to a host path in .env
# for a bind mount. The API Server keeps no state on disk, so everything
# it owns (plugin settings included) is in here.
- "${PB_DATA:-pb_data}:/pb/pb_data"
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
@@ -0,0 +1,330 @@
name: drivervault-aio
# Production all-in-one, with SeaweedFS split into its four roles — pulls the
# prebuilt image from the registry instead of building. Self-contained: one
# file, no overlays. Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
#
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
# process; here each is its own container, plus the SeaweedFS admin UI. What
# that buys:
#
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
# where S3 identities are created and revoked without touching a file;
# • per-role restart, upgrade and Prometheus metrics;
# • room to add a second volume server later, on this host or another.
#
# What it costs: five containers beside the all-in-one instead of one, five
# healthchecks to keep the boot order honest, and one more port worth binding
# carefully. If none of the above is wanted, use
# docker-compose.prod.seaweedfs.yml — the S3 behaviour is identical.
#
# None of them run inside the all-in-one image, for the same reason the single
# gateway does not: keeping the object store in that image, on the volume the
# files are being moved off, would defeat the point and would mean rebuilding.
#
# The on-disk layout is deliberately the same as the single-process file's:
# master, volume and filer share one /data mount, exactly as `weed server -dir`
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
# filename overlap). So the two files are interchangeable on the same
# SEAWEED_DATA, with no migration either way. A *second* volume server would
# need its own.
#
# Only the S3 gateway and the admin UI publish a host port, both on loopback,
# the way the single-gateway file publishes the S3 port. Master, volume and
# filer are reachable over the compose network, through the admin UI, or with
# `docker compose exec` — the volume server in particular serves file content by
# id with no authentication at all, so it has no business on a public interface.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
# Server creates any missing collections and the DriverVault super-admin from
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
services:
# --- SeaweedFS: master -----------------------------------------------------
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
# the other roles are told to reach it by, so it must be the service name and
# not the container IP the process would otherwise detect.
seaweedfs-master:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-master
restart: unless-stopped
command: >
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
-volumeSizeLimitMB=1024 -metricsPort=9324
volumes:
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
# from here on the attachments live here, not in the database volume.
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port: the master UI is one of the pages the admin UI serves.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: volume server ---------------------------------------------
# Where the bytes actually land. -max=0 lets it size itself from free disk
# rather than the default cap of 8 volumes.
seaweedfs-volume:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-volume
restart: unless-stopped
command: >
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
-port=8080 -dir=/data -max=0 -metricsPort=9325
depends_on:
seaweedfs-master:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port, and this one is not an oversight: 8080 serves file
# content by file id with NO authentication — the S3 credentials do not
# apply to it. Publishing it would publish every attachment in the stack.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: filer ------------------------------------------------------
# Gives the flat volume store a directory tree — buckets, object keys — and
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
# embedded leveldb goes; without it that would be the container's working
# directory, and the identities would not survive a recreate.
seaweedfs-filer:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-filer
restart: unless-stopped
command: >
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
-port=8888 -defaultStoreDir=/data -metricsPort=9326
depends_on:
seaweedfs-volume:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port. The filer's gRPC side (8888 + 10000) carries the IAM
# service that mints S3 credentials; keep both ends of it on the compose
# network.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: bucket and identity seed ----------------------------------
# Runs once and exits, before the gateway starts. Two jobs:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
# docker-compose.prod.seaweedfs.yml does. Those env vars are the *lowest*
# priority credential source in SeaweedFS: they are read only while the filer's
# store is empty, so the first identity added in the admin UI would silently
# displace them and lock PocketBase out. Seeding the store the admin UI itself
# writes leaves one source of truth, and the key PocketBase uses appears under
# Object Store → Users like any other.
#
# Both commands update in place, so every later boot re-applies the values from
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
# reaches the gateway.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# below never starts. No `|| true` here, deliberately.
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-init
restart: "no"
depends_on:
seaweedfs-filer:
condition: service_healthy
environment:
# Passed as env and expanded by the shell inside the container, so the
# secret stays out of the container's argv.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
printf '%s\n' \
"s3.bucket.create -name $$PB_S3_BUCKET" \
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
echo "s3.configure" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
| grep -q "$$PB_S3_ACCESS_KEY"
# --- SeaweedFS: S3 gateway -------------------------------------------------
# The endpoint PocketBase talks to. No -config file: with only -filer given,
# credentials come from the filer's IAM store, which is what lets the admin UI
# add and revoke identities without a restart. A config file would take
# priority over that store and make the admin UI's users inert.
seaweedfs-s3:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-s3
restart: unless-stopped
command: >
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
-metricsPort=9327
depends_on:
seaweedfs-filer:
condition: service_healthy
# Never serve before an identity exists — see seaweedfs-init above.
seaweedfs-init:
condition: service_completed_successfully
ports:
# Loopback only: the stack reaches the gateway over the compose network,
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: admin UI ---------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
# Users, where S3 access keys are minted and revoked. It finds the filer
# through the master, so -master is all it needs.
#
# Bound to loopback by default — the same call SEAWEED_S3_BIND makes above,
# for the same reason: this is storage plumbing, not one of the app's own
# panels. On a remote host that means unreachable, so set
# SEAWEED_ADMIN_BIND=0.0.0.0 and put it behind a reverse proxy.
#
# An unauthenticated panel that can mint credentials for the bucket *is* the
# bucket, so the password is required rather than defaulted — weed leaves auth
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
# flag, which keeps it off the process command line. -dataDir persists the
# session key and the maintenance-task settings.
seaweedfs-admin:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-admin
restart: unless-stopped
command: >
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
-metricsPort=9328
depends_on:
seaweedfs-master:
condition: service_healthy
environment:
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
# Optional view-only login. weed ignores it unless the admin password
# above is set, which it is.
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
volumes:
# Its own small volume: session key and maintenance state, no object data.
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/data"
ports:
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
drivervault:
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
container_name: drivervault-aio
restart: unless-stopped
depends_on:
# PocketBase — inside this container — is the process that reads and
# writes the objects, so the gateway has to be serving first, and the
# bucket has to exist before the bootstrap points PocketBase at it.
seaweedfs-s3:
condition: service_healthy
seaweedfs-init:
condition: service_completed_successfully
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The gateway's service name: a server-to-server call inside the compose
# network.
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume — named by default; set PB_DATA to a host path in .env
# for a bind mount. The API Server keeps no state on disk, so everything
# it owns (plugin settings included) is in here.
- "${PB_DATA:-pb_data}:/pb/pb_data"
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
# Shared by master, volume and filer — the same layout `weed server -dir`
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
# on it.
seaweed_data:
# The admin UI's own session key and maintenance-task state. Small, and no
# part of the object store.
seaweed_admin:
@@ -0,0 +1,155 @@
name: drivervault-aio
# Production all-in-one, with SeaweedFS — pulls the prebuilt image from the
# registry instead of building. Self-contained: one file, no overlays.
# Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
#
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
# record files — document scans, service and refill receipts, workshop invoices,
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
# the database. The database and PocketBase's own backups stay on PB_DATA.
# Clients cannot tell the difference: an attachment has always been fetched
# through the API Server, never from a storage URL.
#
# SeaweedFS runs as a second container beside the all-in-one, not as a fourth
# process inside it: keeping the object store in that image, on the volume the
# files are being moved off, would defeat the point and would mean rebuilding.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
# Server creates any missing collections and the DriverVault super-admin from
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
services:
seaweedfs:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs
restart: unless-stopped
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
# the only state it keeps.
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
environment:
# SeaweedFS falls back to these when started without an -s3.config file,
# and configuring one identity is what takes the S3 gateway out of its
# default allow-anyone mode. The same credentials PocketBase authenticates
# with below — one pair to set, in .env.
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
volumes:
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
# from here on the attachments live here, not in the database volume.
- "${SEAWEED_DATA:-seaweed_data}:/data"
ports:
# Loopback only: the stack reaches the gateway over the compose network,
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-init
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
# SeaweedFS will not conjure one on first upload, so something has to.
# Creating a bucket that already exists is a no-op, so every later boot
# passes straight through.
restart: "no"
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
# `|| true` so a restart is never blocked by the shell's exit status: this
# step is best-effort, and a gateway that is genuinely unreachable is
# reported by the API Server's own S3 check at boot, with the reason.
command:
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
drivervault:
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
container_name: drivervault-aio
restart: unless-stopped
depends_on:
# PocketBase — inside this container — is the process that reads and
# writes the objects, so the gateway has to be serving first, and the
# bucket has to exist before the bootstrap points PocketBase at it.
seaweedfs:
condition: service_healthy
seaweedfs-init:
condition: service_completed_successfully
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The service name: a server-to-server call inside the compose network.
PB_S3_ENDPOINT: "http://seaweedfs:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume — named by default; set PB_DATA to a host path in .env
# for a bind mount. The API Server keeps no state on disk, so everything
# it owns (plugin settings included) is in here.
- "${PB_DATA:-pb_data}:/pb/pb_data"
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
seaweed_data:
+107
View File
@@ -0,0 +1,107 @@
name: drivervault-aio
# Single all-in-one container, with external S3: PocketBase + API Server + Web
# App (nginx) in one image, storing files in an S3 endpoint that already exists
# somewhere else. Self-contained — one file, nothing to layer.
#
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
# docker compose -f docker-compose.s3.yml up -d --build
#
# The build context is the project root so the Dockerfile can reach both
# "API Server/" and "Web App/".
#
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
# document scans, service and refill receipts, workshop invoices, part photos —
# in that bucket instead of on the pb_data volume next to the database. The
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
# difference: an attachment has always been fetched through the API Server,
# never from a storage URL.
#
# The bucket must already exist, and nothing here runs the gateway. For a
# SeaweedFS that comes up with the container, use docker-compose.seaweedfs.yml.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
drivervault:
build:
# Project root (one level up from this compose file).
context: ..
dockerfile: Docker-AIO/Dockerfile
args:
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
- VITE_API_BASE=${VITE_API_BASE:-}
# Bare name = pass through only when set in the environment, so an unset
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
# with an empty string (which would resolve "latest" at build time).
- PB_VERSION
image: drivervault-aio
container_name: drivervault-aio
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data. The bucket must already exist: nothing here
# creates it.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume: the API Server keeps no state on disk, so everything
# it owns — plugin settings included — lives in the database.
- pb_data:/pb/pb_data
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
@@ -0,0 +1,319 @@
name: drivervault-aio
# Single all-in-one container, with SeaweedFS split into its four roles:
# PocketBase + API Server + Web App (nginx) in one image, beside master, volume,
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
# Self-contained — one file, nothing to layer.
#
# cp .env.seaweedfs.split.example .env (then edit it)
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
#
# The build context is the project root so the Dockerfile can reach both
# "API Server/" and "Web App/".
#
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
# process; here each is its own container. What that buys:
#
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
# where S3 identities are created and revoked without touching a file;
# • per-role restart, upgrade and Prometheus metrics;
# • room to add a second volume server later, on this host or another.
#
# What it costs: five containers beside the all-in-one instead of one, and five
# healthchecks to keep the boot order honest. If none of the above is wanted,
# use docker-compose.seaweedfs.yml — the S3 behaviour is identical.
#
# None of them run inside the all-in-one image, for the same reason the single
# gateway does not: keeping the object store in that image, on the volume the
# files are being moved off, would defeat the point and would mean rebuilding.
#
# The on-disk layout is deliberately the same as the single-process file's:
# master, volume and filer share one /data mount, exactly as `weed server -dir`
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
# filename overlap). So the two files are interchangeable on the same volume,
# with no migration either way. A *second* volume server would need its own.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
# --- SeaweedFS: master -----------------------------------------------------
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
# the other roles are told to reach it by, so it must be the service name and
# not the container IP the process would otherwise detect.
seaweedfs-master:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-master
restart: unless-stopped
command: >
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
-volumeSizeLimitMB=1024 -metricsPort=9324
volumes:
- seaweed_data:/data
ports:
# Master UI / API. Useful while developing; the admin UI below covers the
# same ground with a nicer face.
- "${SEAWEED_MASTER_PORT:-9333}:9333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: volume server ---------------------------------------------
# Where the bytes actually land. -max=0 lets it size itself from free disk
# rather than the default cap of 8 volumes.
seaweedfs-volume:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-volume
restart: unless-stopped
command: >
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
-port=8080 -dir=/data -max=0 -metricsPort=9325
depends_on:
seaweedfs-master:
condition: service_healthy
volumes:
- seaweed_data:/data
ports:
# This port serves file content by file id with NO authentication — the S3
# credentials do not apply to it. Publish it only where you would be
# willing to publish the bucket itself.
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: filer ------------------------------------------------------
# Gives the flat volume store a directory tree — buckets, object keys — and
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
# embedded leveldb goes; without it that would be the container's working
# directory, and the identities would not survive a recreate.
seaweedfs-filer:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-filer
restart: unless-stopped
command: >
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
-port=8888 -defaultStoreDir=/data -metricsPort=9326
depends_on:
seaweedfs-volume:
condition: service_healthy
volumes:
- seaweed_data:/data
ports:
- "${SEAWEED_FILER_PORT:-8888}:8888"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: bucket and identity seed ----------------------------------
# Runs once and exits, before the gateway starts. Two jobs:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
# credential source in SeaweedFS: they are read only while the filer's store is
# empty, so the first identity added in the admin UI would silently displace
# them and lock PocketBase out. Seeding the store the admin UI itself writes
# leaves one source of truth, and the key PocketBase uses appears under
# Object Store → Users like any other.
#
# Both commands update in place, so every later boot re-applies the values from
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
# reaches the gateway.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# below never starts. No `|| true` here, deliberately.
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-init
restart: "no"
depends_on:
seaweedfs-filer:
condition: service_healthy
environment:
# Passed as env and expanded by the shell inside the container, so the
# secret stays out of the container's argv.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
printf '%s\n' \
"s3.bucket.create -name $$PB_S3_BUCKET" \
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
echo "s3.configure" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
| grep -q "$$PB_S3_ACCESS_KEY"
# --- SeaweedFS: S3 gateway -------------------------------------------------
# The endpoint PocketBase talks to. No -config file: with only -filer given,
# credentials come from the filer's IAM store, which is what lets the admin UI
# add and revoke identities without a restart. A config file would take
# priority over that store and make the admin UI's users inert.
seaweedfs-s3:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-s3
restart: unless-stopped
command: >
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
-metricsPort=9327
depends_on:
seaweedfs-filer:
condition: service_healthy
# Never serve before an identity exists — see seaweedfs-init above.
seaweedfs-init:
condition: service_completed_successfully
ports:
# The stack reaches the gateway over the compose network; this is here so
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
- "${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: admin UI ---------------------------------------------------
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
# It finds the filer through the master, so -master is all it needs.
#
# An unauthenticated panel that can mint credentials for the bucket *is* the
# bucket, so the password is required rather than defaulted — weed leaves auth
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
# flag, which keeps it off the process command line. -dataDir persists the
# session key and the maintenance-task settings.
seaweedfs-admin:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-admin
restart: unless-stopped
command: >
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
-metricsPort=9328
depends_on:
seaweedfs-master:
condition: service_healthy
environment:
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
volumes:
- seaweed_admin:/data
ports:
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
drivervault:
build:
# Project root (one level up from this compose file).
context: ..
dockerfile: Docker-AIO/Dockerfile
args:
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
- VITE_API_BASE=${VITE_API_BASE:-}
# Bare name = pass through only when set in the environment, so an unset
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
# with an empty string (which would resolve "latest" at build time).
- PB_VERSION
image: drivervault-aio
container_name: drivervault-aio
restart: unless-stopped
depends_on:
# PocketBase — inside this container — is the process that reads and
# writes the objects, so the gateway has to be serving first, and the
# bucket has to exist before the bootstrap points PocketBase at it.
seaweedfs-s3:
condition: service_healthy
seaweedfs-init:
condition: service_completed_successfully
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The gateway's service name: a server-to-server call inside the compose
# network.
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume: the API Server keeps no state on disk, so everything
# it owns — plugin settings included — lives in the database.
- pb_data:/pb/pb_data
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
# Shared by master, volume and filer — the same layout `weed server -dir`
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
seaweed_data:
# The admin UI's own session key and maintenance-task state. Small, and no
# part of the object store.
seaweed_admin:
+160
View File
@@ -0,0 +1,160 @@
name: drivervault-aio
# Single all-in-one container, with SeaweedFS: PocketBase + API Server + Web App
# (nginx) in one image, plus an S3 object store beside it. Self-contained — one
# file, nothing to layer.
#
# cp .env.seaweedfs.example .env (then edit it)
# docker compose -f docker-compose.seaweedfs.yml up -d --build
#
# The build context is the project root so the Dockerfile can reach both
# "API Server/" and "Web App/".
#
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
# document scans, service and refill receipts, workshop invoices, part photos —
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
# the difference: an attachment has always been fetched through the API Server,
# never from a storage URL.
#
# SeaweedFS runs as a second container beside the all-in-one, not as a fourth
# process inside it: keeping the object store in that image, on the volume the
# files are being moved off, would defeat the point and would mean rebuilding.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
seaweedfs:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs
restart: unless-stopped
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
# the only state it keeps.
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
environment:
# SeaweedFS falls back to these when started without an -s3.config file,
# and configuring one identity is what takes the S3 gateway out of its
# default allow-anyone mode. The same credentials PocketBase authenticates
# with below — one pair to set, in .env.
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
volumes:
# From here on the attachments live here, not on pb_data.
- seaweed_data:/data
ports:
# The stack reaches the gateway over the compose network; this is here so
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
- "${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-init
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
# SeaweedFS will not conjure one on first upload, so something has to.
# Creating a bucket that already exists is a no-op, so every later boot
# passes straight through.
restart: "no"
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
# `|| true` so a restart is never blocked by the shell's exit status: this
# step is best-effort, and a gateway that is genuinely unreachable is
# reported by the API Server's own S3 check at boot, with the reason.
command:
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
drivervault:
build:
# Project root (one level up from this compose file).
context: ..
dockerfile: Docker-AIO/Dockerfile
args:
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
- VITE_API_BASE=${VITE_API_BASE:-}
# Bare name = pass through only when set in the environment, so an unset
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
# with an empty string (which would resolve "latest" at build time).
- PB_VERSION
image: drivervault-aio
container_name: drivervault-aio
restart: unless-stopped
depends_on:
# PocketBase — inside this container — is the process that reads and
# writes the objects, so the gateway has to be serving first, and the
# bucket has to exist before the bootstrap points PocketBase at it.
seaweedfs:
condition: service_healthy
seaweedfs-init:
condition: service_completed_successfully
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Probed by the panel status page. nginx serves the Web App on port 80
# inside this container, so plain localhost:8090 would never answer.
# Override WEBAPP_URL in .env to make a change from the panel's Web App
# screen permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
# charger can only connect when TLS is terminated in front of it (set
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
# with OCPP_REQUIRE_TLS=false.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# --- File storage --------------------------------------------------
# supervisord passes these through to the API Server, whose bootstrap
# writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The service name: a server-to-server call inside the compose network.
PB_S3_ENDPOINT: "http://seaweedfs:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
volumes:
# The only volume: the API Server keeps no state on disk, so everything
# it owns — plugin settings included — lives in the database.
- pb_data:/pb/pb_data
healthcheck:
# All three processes must answer. Declared here as well as in the image so
# the check is visible, and works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
volumes:
pb_data:
seaweed_data:
+9 -8
View File
@@ -34,6 +34,15 @@ AUTH_USERS_COLLECTION=users
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
@@ -66,11 +75,3 @@ VITE_API_BASE=
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
# host from the internet, with ports 80 and 443 reaching it; the certificate is
# issued automatically on first boot. Setting it also points chargers at
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
DV_DOMAIN=
DV_ACME_EMAIL=
+9 -8
View File
@@ -47,6 +47,15 @@ AUTH_USERS_COLLECTION=users
# networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
@@ -88,11 +97,3 @@ API_BIND=127.0.0.1
# stack: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
# host from the internet, with ports 80 and 443 reaching it; the certificate is
# issued automatically on first boot. Setting it also points chargers at
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
DV_DOMAIN=
DV_ACME_EMAIL=
+121
View File
@@ -0,0 +1,121 @@
# DriverVault — production stack config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.s3.yml pull
# docker compose -f docker-compose.prod.s3.yml up -d
# --- Registry images ---------------------------------------------------------
# Defaults point at the internal registry; override to pin a tag or use a mirror.
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
# --- PocketBase superuser ----------------------------------------------------
# Created/updated on the PocketBase container's first boot. The API Server uses
# these same credentials to manage the database. REQUIRED.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip and
# create the first user by hand. This is the account you log in to the web app
# with — distinct from the PocketBase superuser above.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server --------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. Keep the default and terminate
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at (deriving it from request
# headers is unreliable behind a proxy). Turning the check off is for trusted
# networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Ports -------------------------------------------------------------------
# WEB_PORT is the public front door (bound on all interfaces).
WEB_PORT=8090
# PocketBase admin UI and the API panel are bound to localhost only by default.
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
PB_PORT=8070
PB_BIND=127.0.0.1
API_PORT=8080
API_BIND=127.0.0.1
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. To store it on a host path
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
# stack: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- File storage: external S3 -----------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
# already exist. For a gateway on this Docker host use
# http://host.docker.internal:8333 — the compose file adds the host entry that
# makes that name resolve inside the containers.
PB_S3_ENDPOINT=http://10.2.1.10:8333
PB_S3_BUCKET=drivervault
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE=true
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+131
View File
@@ -0,0 +1,131 @@
# DriverVault — production stack config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.yml pull
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
# --- Registry images ---------------------------------------------------------
# Defaults point at the internal registry; override to pin a tag or use a mirror.
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
# --- PocketBase superuser ----------------------------------------------------
# Created/updated on the PocketBase container's first boot. The API Server uses
# these same credentials to manage the database. REQUIRED.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip and
# create the first user by hand. This is the account you log in to the web app
# with — distinct from the PocketBase superuser above.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server --------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. Keep the default and terminate
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at (deriving it from request
# headers is unreliable behind a proxy). Turning the check off is for trusted
# networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Ports -------------------------------------------------------------------
# WEB_PORT is the public front door (bound on all interfaces).
WEB_PORT=8090
# PocketBase admin UI and the API panel are bound to localhost only by default.
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
PB_PORT=8070
PB_BIND=127.0.0.1
API_PORT=8080
API_BIND=127.0.0.1
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. To store it on a host path
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
# stack: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: they configure the SeaweedFS gateway's single
# identity *and* are what PocketBase authenticates with. There are no safe
# defaults, and the stack refuses to start without them.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
# by default; set an absolute host path for a bind mount, the same way PB_DATA
# works above. Back it up alongside PB_DATA: from here on the attachments live
# here, not in the database volume.
SEAWEED_DATA=seaweed_data
# The gateway image, pinned so a redeploy months from now brings up the same one.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# The S3 port is published on loopback only — the stack reaches the gateway over
# the compose network, and this is for tools like aws-cli. Set
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
# SEAWEED_S3_BIND=127.0.0.1
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+166
View File
@@ -0,0 +1,166 @@
# DriverVault — production stack config, SeaweedFS split into its four roles.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
# --- Registry images ---------------------------------------------------------
# Defaults point at the internal registry; override to pin a tag or use a mirror.
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
# --- PocketBase superuser ----------------------------------------------------
# Created/updated on the PocketBase container's first boot. The API Server uses
# these same credentials to manage the database. REQUIRED.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip and
# create the first user by hand. This is the account you log in to the web app
# with — distinct from the PocketBase superuser above.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server --------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. Keep the default and terminate
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
# public wss:// base the charger should be pointed at (deriving it from request
# headers is unreliable behind a proxy). Turning the check off is for trusted
# networks only.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Ports -------------------------------------------------------------------
# WEB_PORT is the public front door (bound on all interfaces).
WEB_PORT=8090
# PocketBase admin UI and the API panel are bound to localhost only by default.
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network — which is
# what a stack running on a remote host needs, behind a reverse proxy.
PB_PORT=8070
PB_BIND=127.0.0.1
API_PORT=8080
API_BIND=127.0.0.1
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- Storage -----------------------------------------------------------------
# One Docker-managed named volume by default. To store it on a host path
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
# state on disk, so everything it owns (plugin settings included) is backed up
# by backing up this one path — together with SEAWEED_DATA below.
PB_DATA=pb_data
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# PB_DATA. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: seaweedfs-init writes them into the filer's
# IAM store as the identity named "drivervault" *and* they are what PocketBase
# authenticates with. There are no safe defaults, and the stack refuses to start
# without them. Change them here and restart to rotate: the seed updates the
# identity in place rather than adding a second one.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
# by default; set an absolute host path for a bind mount, the same way PB_DATA
# works above. Back it up alongside PB_DATA: from here on the attachments live
# here, not in the database volume.
#
# The master, volume and filer containers all mount it at /data, which is the
# layout `weed server -dir=/data` writes — so this file and
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
# nothing to migrate either way.
SEAWEED_DATA=seaweed_data
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
# one. All five SeaweedFS containers run it.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# The S3 port is published on loopback only — the stack reaches the gateway over
# the compose network, and this is for tools like aws-cli. Set
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
# SEAWEED_S3_BIND=127.0.0.1
# SEAWEED_S3_PORT=8333
#
# The master, volume and filer publish no host port at all. The admin UI below
# shows what they would: the volume server in particular serves file content by
# id with no authentication, so it stays on the compose network. Reach the
# others with `docker compose exec`.
# --- SeaweedFS admin UI ------------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
# Users, where further S3 identities are created and revoked. They land in the
# filer's IAM store, the same one seeded above, and the gateway picks them up
# without a restart.
#
# REQUIRED: weed disables authentication entirely when the password is empty,
# and this panel can mint credentials for the bucket.
SEAWEED_ADMIN_USER=admin
SEAWEED_ADMIN_PASSWORD=
# Optional view-only login.
SEAWEED_ADMIN_READONLY_USER=
SEAWEED_ADMIN_READONLY_PASSWORD=
# Bound to localhost by default, like PB_BIND and API_BIND. On a remote host
# that means unreachable — set 0.0.0.0 and put it behind the same reverse proxy
# as the other panels.
SEAWEED_ADMIN_BIND=127.0.0.1
SEAWEED_ADMIN_PORT=23646
# Its own small volume: session key and maintenance-task state, no object data.
SEAWEED_ADMIN_DATA=seaweed_admin
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+99
View File
@@ -0,0 +1,99 @@
# Copy to .env and fill in. Used by the root docker-compose.s3.yml.
# --- PocketBase superuser (also used by the API Server to authenticate) ------
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave these blank and the
# schema is still created but no user is, leaving a stack you cannot log into.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server -------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
# Native mobile apps are not subject to CORS.
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This dev stack serves plain
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Host port mappings (optional; defaults shown) --------------------------
PB_PORT=8070
API_PORT=8080
WEB_PORT=8090
# --- Web App build -----------------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
VITE_API_BASE=
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- File storage: external S3 -----------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
# already exist. For a gateway on this Docker host use
# http://host.docker.internal:8333 — the compose file adds the host entry that
# makes that name resolve inside the containers.
PB_S3_ENDPOINT=http://host.docker.internal:8333
PB_S3_BUCKET=drivervault
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE=true
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+100
View File
@@ -0,0 +1,100 @@
# Copy to .env and fill in. Used by the root docker-compose.seaweedfs.yml.
# --- PocketBase superuser (also used by the API Server to authenticate) ------
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave these blank and the
# schema is still created but no user is, leaving a stack you cannot log into.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server -------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
# Native mobile apps are not subject to CORS.
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This dev stack serves plain
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Host port mappings (optional; defaults shown) --------------------------
PB_PORT=8070
API_PORT=8080
WEB_PORT=8090
# --- Web App build -----------------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
VITE_API_BASE=
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: they configure the SeaweedFS gateway's single
# identity *and* are what PocketBase authenticates with. There are no safe
# defaults, and the stack refuses to start without them.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The gateway image, pinned so a rebuild months from now brings up the same one.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
+126
View File
@@ -0,0 +1,126 @@
# Copy to .env and fill in. Used by docker-compose.seaweedfs.split.yml — the
# same stack as .env.seaweedfs.example, with SeaweedFS running as separate
# master / volume / filer / S3 containers plus the SeaweedFS admin UI.
# --- PocketBase superuser (also used by the API Server to authenticate) ------
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave these blank and the
# schema is still created but no user is, leaving a stack you cannot log into.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Schema creation/reconcile on boot. Leave this true: a release can add
# collections or fields the server needs, and a stack that skips the bootstrap
# never gets them. (The API Server creates app_settings, which holds the plugin
# settings, on demand — but only that one.) Set false only for a database you
# know already matches the release.
PB_BOOTSTRAP=true
# --- API Server -------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
# Native mobile apps are not subject to CORS.
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
# Only relevant when a charger is set to own/proxy control mode. The charger
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
# non-TLS connections are rejected by default. This dev stack serves plain
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
OCPP_REQUIRE_TLS=true
OCPP_PUBLIC_URL=
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
# thing in the Anker app, then read the frames back out of the container log.
# Leave blank on a normal stack; a triggered charger writes a line every few
# seconds.
ANKER_MQTT_FRAME_LOG=
# The charger can dial either door: the API Server port directly, or the Web
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
# is whichever host the panel itself was reached on, which is the Web App.
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
# API Server: needed when TLS ends at a proxy in front of the stack and
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
# whatever the client said it was.
TRUST_FORWARDED_PROTO=false
# --- Host port mappings (optional; defaults shown) --------------------------
PB_PORT=8070
API_PORT=8080
WEB_PORT=8090
# --- Web App build -----------------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
VITE_API_BASE=
# --- Settings the API Server panel can also change ---------------------------
# The panel's Settings screens apply these immediately, but only for the life of
# the container — the values below are re-applied on every restart and win. Set
# them here to make a change permanent.
# POCKETBASE_URL where the API Server looks for the database. Defaults to
# the bundled pocketbase service; set it to reach one
# outside this stack.
# WEBAPP_URL the Web App address the panel status page probes. It is
# a container-to-container call, so it must be reachable
# from the API Server, not from your browser.
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- File storage: SeaweedFS -------------------------------------------------
# PocketBase keeps its record files — document scans, service and refill
# receipts, workshop invoices, part photos — in the bucket below instead of on
# pb_data. The database and PocketBase's own backups stay where they are.
#
# The credentials do double duty: seaweedfs-init writes them into the filer's
# IAM store as the identity named "drivervault" *and* they are what PocketBase
# authenticates with. There are no safe defaults, and the stack refuses to start
# without them. Change them here and restart to rotate: the seed updates the
# identity in place rather than adding a second one.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
# one. All five SeaweedFS containers run it.
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
# --- SeaweedFS admin UI ------------------------------------------------------
# http://localhost:23646 — cluster topology, volumes, buckets, and
# Object Store → Users, where further S3 identities are created and revoked.
# They land in the filer's IAM store, the same one seeded above, and the gateway
# picks them up without a restart.
#
# REQUIRED: weed disables authentication entirely when the password is empty,
# and this panel can mint credentials for the bucket.
SEAWEED_ADMIN_USER=admin
SEAWEED_ADMIN_PASSWORD=
# SEAWEED_ADMIN_PORT=23646
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
# Published for aws-cli, `weed shell` and poking around while developing. The
# stack itself reaches every one of these over the compose network.
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
# authentication, so do not carry this mapping over to a shared machine. It
# lands on 8081 because API_PORT already has 8080.
# SEAWEED_MASTER_PORT=9333
# SEAWEED_VOLUME_PORT=8081
# SEAWEED_FILER_PORT=8888
# SEAWEED_S3_PORT=8333
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
# nothing, so attachments made before the switch stop resolving. Read the file
# storage section of README.md first.
-24
View File
@@ -1,24 +0,0 @@
# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything
# behind it spoken to over the compose network in plain HTTP.
#
# Both doors are the same door here. Browsers get the Web App; chargers dial
# /ocpp/{serial} on the same hostname, and the BFF carries that through to the
# API Server. Caddy proxies WebSocket upgrades without being told to, so there
# is nothing to configure for the charger case.
#
# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml.
{
email {$DV_ACME_EMAIL}
}
{$DV_DOMAIN} {
encode zstd gzip
# X-Forwarded-Proto is set by Caddy to the scheme the client used. The API
# Server reads it to decide a charger arrived over TLS, which is why the
# web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF
# would overwrite this header with its own plaintext hop and a charger would
# be rejected under OCPP_REQUIRE_TLS.
reverse_proxy web-app:8090
}
+110 -22
View File
@@ -78,6 +78,102 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
> in this stack the compose environment wins over anything the panel writes.
## File storage (SeaweedFS / S3)
Uploaded files — document scans, service and refill receipts, workshop invoices,
part photos — live inside `pb_data` by default, next to the database. Two further
compose files put them in an S3 bucket instead, so the blobs and the database can
be sized, backed up and moved independently. Nothing else changes: an attachment has
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
never from a storage URL, so the Web App, the phone app and the Home Assistant
plugin cannot tell the difference.
Each shape is one self-contained compose file — nothing to layer, nothing to
remember — with an `.env` example of the same name:
| Shape | From the registry | From source |
|---|---|---|
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
| **SeaweedFS in this stack** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
| **An S3 endpoint outside it** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
So `docker-compose.prod.seaweedfs.yml` is configured from
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
and so on:
```sh
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
docker compose -f docker-compose.prod.seaweedfs.yml pull
docker compose -f docker-compose.prod.seaweedfs.yml up -d
```
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
start without them. The SeaweedFS ones add a `seaweedfs` container (master,
volume, filer and S3 gateway in one process, on its own `seaweed_data` volume)
plus a one-shot `seaweedfs-init` that creates the bucket, because PocketBase never
issues a `CreateBucket` of its own. The external-S3 ones add no containers at
all: set `PB_S3_ENDPOINT`, and create the bucket yourself.
### Split SeaweedFS
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
one process. The `.split.` files run them as four containers, plus a fifth: the
SeaweedFS **admin UI** on port 23646, where the cluster can be inspected and —
under *Object Store → Users* — further S3 identities minted and revoked. Split
also gets you per-role restarts and upgrades, per-role Prometheus metrics, and
room to add a second volume server later.
Identities work differently there, and it matters. SeaweedFS reads credentials
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces*
a lower one rather than adding to it. The single-process files use the env vars,
which is why nothing else may write identities there: the first user added in a
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
seeds PocketBase's identity into the filer's store instead — the same store the
admin UI writes — and the gateway runs with no config file at all. One source of
truth, PocketBase's key visible in the panel beside every other, and new keys
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
updates that identity in place.
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
authentication when it is empty, and a panel that can mint bucket credentials is
the bucket. In the prod file it is bound to loopback like `PB_BIND` and
`API_BIND`, so a remote host needs `SEAWEED_ADMIN_BIND=0.0.0.0` behind the same
reverse proxy. That file publishes nothing for master, volume and filer — the
volume server serves file content by id with no authentication of any kind, and
the admin UI already shows what those ports would.
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
migration: master, volume and filer share one `/data` mount, which is exactly
the layout `weed server -dir=/data` writes.
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
settings from those variables, then asks PocketBase to prove it can reach the
bucket. Watch for it in the log:
```
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
[api] bootstrap: ✓ S3 storage reachable
```
A boot that finds the settings already correct logs `• file storage already on S3`
and writes nothing.
Two things to know before turning it on:
- **Existing files are not migrated.** PocketBase copies nothing when the setting
flips, so attachments uploaded before the switch stop resolving. Copy
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
that layout, *before* enabling it — or start from a stack with no attachments.
- **Going back to the plain compose file is not an off switch.** It leaves
PocketBase pointed at
the bucket, deliberately: files already written there are reachable only while
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
alongside it, or set it in the admin UI.
## Charger control (OCPP)
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
@@ -93,34 +189,26 @@ A plaintext `ws://` puts the control token on the wire in the clear, so
### With a public hostname and TLS
`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a
certificate issued on first boot, and everything behind it spoken to over the
compose network. Browsers and chargers arrive at the same name.
Nothing in this stack terminates TLS. Put your own reverse proxy in front of the
Web App port, give it a certificate and a hostname, and set four things by hand:
```sh
# in .env
DV_DOMAIN=drivervault.example.com
DV_ACME_EMAIL=you@example.com
docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
OCPP_PUBLIC_URL=wss://drivervault.example.com
OCPP_REQUIRE_TLS=true
CORS_ALLOW_ORIGINS=https://drivervault.example.com
TRUST_FORWARDED_PROTO=true
```
The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`,
`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and
`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's
`X-Forwarded-Proto` to the API Server instead of overwriting it with its own
plaintext hop. Point the charger's OCPP backend at the endpoint the panel then
shows, with the control token as its authorization key.
`TRUST_FORWARDED_PROTO` is the easy one to miss: without it the Web App's BFF
overwrites the proxy's `X-Forwarded-Proto` with its own plaintext hop and every
charger is rejected as insecure. Only set it when that proxy really is the only
way in — otherwise a charger could claim `wss` over a plaintext connection.
Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from
the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME
challenge), and the charger must be able to resolve that name too — behind NAT
that usually means hairpin NAT or a split-DNS entry pointing it at the LAN
address.
Using a proxy you already run instead? Terminate TLS there, forward to the Web
App port, and set the same four variables by hand — the `X-Forwarded-Proto` one
included, or chargers will be rejected as insecure.
Point the charger's OCPP backend at the endpoint the panel then shows, with the
control token as its authorization key. The charger has to resolve that hostname
too: behind NAT that usually means hairpin NAT, or a split-DNS entry pointing the
name at the LAN address.
## Notes
+174
View File
@@ -0,0 +1,174 @@
name: drivervault
# Production DriverVault stack, with external S3 — pulls prebuilt images from
# the registry instead of building from source. Self-contained: one file, no
# overlays. Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
# 2. docker compose -f docker-compose.prod.s3.yml pull
# 3. docker compose -f docker-compose.prod.s3.yml up -d
#
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
# somewhere else — its own host, another compose project, or any S3-compatible
# service. PocketBase keeps its record files — document scans, service and
# refill receipts, workshop invoices, part photos — in that bucket instead of on
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
# Clients cannot tell the difference: an attachment has always been fetched
# through the API Server, never from a storage URL.
#
# The bucket must already exist, and nothing here runs the gateway. For a
# SeaweedFS that comes up with the stack, use docker-compose.prod.seaweedfs.yml.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# On first boot:
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
# • the API Server creates any missing collections, reconciles existing ones,
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
# Both steps are idempotent, so restarts and upgrades are safe.
services:
pocketbase:
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
container_name: drivervault-pocketbase
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
environment:
# The superuser is created/updated on boot (the API Server authenticates
# with it). This is the only place the first superuser can be created — the
# REST API cannot bootstrap it.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
- "${PB_DATA:-pb_data}:/pb/pb_data"
ports:
# Bound to localhost by default — the admin UI (/_/) is reachable only on
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
container_name: drivervault-api
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
depends_on:
pocketbase:
condition: service_healthy
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA. The bucket must already exist: nothing here
# creates it.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
ports:
# Localhost-only by default (the Web App reaches it over the internal
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
# /ocpp/{serial} endpoint chargers dial into — on the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under PB_DATA.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
# The public front door. Bound on all interfaces so browsers can reach it.
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
@@ -0,0 +1,394 @@
name: drivervault
# Production DriverVault stack, with SeaweedFS split into its four roles —
# pulls prebuilt images from the registry instead of building from source.
# Self-contained: one file, no overlays. Everything an operator needs to set
# lives in .env.
#
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
#
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
# process; here each is its own container, plus the SeaweedFS admin UI. What
# that buys:
#
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
# where S3 identities are created and revoked without touching a file;
# • per-role restart, upgrade and Prometheus metrics;
# • room to add a second volume server later, on this host or another.
#
# What it costs: five containers instead of one, five healthchecks to keep the
# boot order honest, and one more port worth binding carefully. If none of the
# above is wanted, use docker-compose.prod.seaweedfs.yml — the S3 behaviour is
# identical.
#
# The on-disk layout is deliberately the same as the single-process file's:
# master, volume and filer share one /data mount, exactly as `weed server -dir`
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
# filename overlap). So the two files are interchangeable on the same SEAWEED_DATA,
# with no migration either way. A *second* volume server would need its own.
#
# Only the S3 gateway and the admin UI publish a host port. Master, volume and
# filer are reachable over the compose network, through the admin UI, or with
# `docker compose exec` — the volume server in particular serves file content by
# id with no authentication at all, so it has no business on a public interface.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# On first boot:
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
# • the API Server creates any missing collections, reconciles existing ones,
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
# Both steps are idempotent, so restarts and upgrades are safe.
services:
# --- SeaweedFS: master -----------------------------------------------------
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
# the other roles are told to reach it by, so it must be the service name and
# not the container IP the process would otherwise detect.
seaweedfs-master:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-master
restart: unless-stopped
command: >
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
-volumeSizeLimitMB=1024 -metricsPort=9324
volumes:
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
# from here on the attachments live here, not in the database volume.
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port: the master UI is one of the pages the admin UI serves.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: volume server ---------------------------------------------
# Where the bytes actually land. -max=0 lets it size itself from free disk
# rather than the default cap of 8 volumes.
seaweedfs-volume:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-volume
restart: unless-stopped
command: >
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
-port=8080 -dir=/data -max=0 -metricsPort=9325
depends_on:
seaweedfs-master:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port, and this one is not an oversight: 8080 serves file
# content by file id with NO authentication — the S3 credentials do not
# apply to it. Publishing it would publish every attachment in the stack.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: filer ------------------------------------------------------
# Gives the flat volume store a directory tree — buckets, object keys — and
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
# embedded leveldb goes; without it that would be the container's working
# directory, and the identities would not survive a recreate.
seaweedfs-filer:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-filer
restart: unless-stopped
command: >
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
-port=8888 -defaultStoreDir=/data -metricsPort=9326
depends_on:
seaweedfs-volume:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port. The filer's gRPC side (8888 + 10000) carries the IAM
# service that mints S3 credentials; keep both ends of it on the compose
# network.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: bucket and identity seed ----------------------------------
# Runs once and exits, before the gateway starts. Two jobs:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
# docker-compose.prod.seaweedfs.yml does. Those env vars are the *lowest*
# priority credential source in SeaweedFS: they are read only while the filer's
# store is empty, so the first identity added in the admin UI would silently
# displace them and lock PocketBase out. Seeding the store the admin UI itself
# writes leaves one source of truth, and the key PocketBase uses appears under
# Object Store → Users like any other.
#
# Both commands update in place, so every later boot re-applies the values from
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
# reaches the gateway.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# below never starts. No `|| true` here, deliberately.
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-init
restart: "no"
depends_on:
seaweedfs-filer:
condition: service_healthy
environment:
# Passed as env and expanded by the shell inside the container, so the
# secret stays out of the container's argv.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
printf '%s\n' \
"s3.bucket.create -name $$PB_S3_BUCKET" \
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
echo "s3.configure" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
| grep -q "$$PB_S3_ACCESS_KEY"
# --- SeaweedFS: S3 gateway -------------------------------------------------
# The endpoint PocketBase talks to. No -config file: with only -filer given,
# credentials come from the filer's IAM store, which is what lets the admin UI
# add and revoke identities without a restart. A config file would take
# priority over that store and make the admin UI's users inert.
seaweedfs-s3:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-s3
restart: unless-stopped
command: >
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
-metricsPort=9327
depends_on:
seaweedfs-filer:
condition: service_healthy
# Never serve before an identity exists — see seaweedfs-init above.
seaweedfs-init:
condition: service_completed_successfully
ports:
# Loopback only: the stack reaches the gateway over the compose network,
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: admin UI ---------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
# Users, where S3 access keys are minted and revoked. It finds the filer
# through the master, so -master is all it needs.
#
# Bound to loopback by default, like the PocketBase and API panels: on a remote
# host that means unreachable, so set SEAWEED_ADMIN_BIND=0.0.0.0 the same way
# PB_BIND and API_BIND get set — and put it behind the same reverse proxy.
#
# An unauthenticated panel that can mint credentials for the bucket *is* the
# bucket, so the password is required rather than defaulted — weed leaves auth
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
# flag, which keeps it off the process command line. -dataDir persists the
# session key and the maintenance-task settings.
seaweedfs-admin:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-admin
restart: unless-stopped
command: >
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
-metricsPort=9328
depends_on:
seaweedfs-master:
condition: service_healthy
environment:
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
# Optional view-only login. weed ignores it unless the admin password
# above is set, which it is.
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
volumes:
# Its own small volume: session key and maintenance state, no object data.
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/data"
ports:
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
pocketbase:
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
container_name: drivervault-pocketbase
restart: unless-stopped
depends_on:
# PocketBase is the process that reads and writes the objects, so the
# gateway has to be serving before it is asked to store anything.
seaweedfs-s3:
condition: service_healthy
environment:
# The superuser is created/updated on boot (the API Server authenticates
# with it). This is the only place the first superuser can be created — the
# REST API cannot bootstrap it.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
- "${PB_DATA:-pb_data}:/pb/pb_data"
ports:
# Bound to localhost by default — the admin UI (/_/) is reachable only on
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
container_name: drivervault-api
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
# The bucket must exist before the bootstrap points PocketBase at it.
seaweedfs-init:
condition: service_completed_successfully
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The gateway's service name: a server-to-server call inside the compose
# network.
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
# Localhost-only by default (the Web App reaches it over the internal
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
# /ocpp/{serial} endpoint chargers dial into — on the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under PB_DATA.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
# The public front door. Bound on all interfaces so browsers can reach it.
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
# Shared by master, volume and filer — the same layout `weed server -dir`
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
# on it.
seaweed_data:
# The admin UI's own session key and maintenance-task state. Small, and no
# part of the object store.
seaweed_admin:
+221
View File
@@ -0,0 +1,221 @@
name: drivervault
# Production DriverVault stack, with SeaweedFS — pulls prebuilt images from the
# registry instead of building from source. Self-contained: one file, no
# overlays. Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
#
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
# record files — document scans, service and refill receipts, workshop invoices,
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
# the database. The database and PocketBase's own backups stay on PB_DATA.
# Clients cannot tell the difference: an attachment has always been fetched
# through the API Server, never from a storage URL.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# On first boot:
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
# • the API Server creates any missing collections, reconciles existing ones,
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
# Both steps are idempotent, so restarts and upgrades are safe.
services:
seaweedfs:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs
restart: unless-stopped
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
# the only state it keeps.
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
environment:
# SeaweedFS falls back to these when started without an -s3.config file,
# and configuring one identity is what takes the S3 gateway out of its
# default allow-anyone mode. The same credentials PocketBase authenticates
# with below — one pair to set, in .env.
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
volumes:
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
# from here on the attachments live here, not in the database volume.
- "${SEAWEED_DATA:-seaweed_data}:/data"
ports:
# Loopback only: the stack reaches the gateway over the compose network,
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-init
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
# SeaweedFS will not conjure one on first upload, so something has to.
# Creating a bucket that already exists is a no-op, so every later boot
# passes straight through.
restart: "no"
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
# `|| true` so a restart is never blocked by the shell's exit status: this
# step is best-effort, and a gateway that is genuinely unreachable is
# reported by the API Server's own S3 check at boot, with the reason.
command:
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
pocketbase:
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
container_name: drivervault-pocketbase
restart: unless-stopped
depends_on:
# PocketBase is the process that reads and writes the objects, so the
# gateway has to be serving before it is asked to store anything.
seaweedfs:
condition: service_healthy
environment:
# The superuser is created/updated on boot (the API Server authenticates
# with it). This is the only place the first superuser can be created — the
# REST API cannot bootstrap it.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
- "${PB_DATA:-pb_data}:/pb/pb_data"
ports:
# Bound to localhost by default — the admin UI (/_/) is reachable only on
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
container_name: drivervault-api
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
# The bucket must exist before the bootstrap points PocketBase at it.
seaweedfs-init:
condition: service_completed_successfully
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
# add collections or fields the server needs, and a stack that skips the
# bootstrap never gets them. The API Server self-heals exactly one thing —
# app_settings, the collection holding the plugin settings, which it
# creates on demand because it cannot serve the plugin panel without it.
# Every other schema change still depends on this flag. Turn it off only
# for a database you know already matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on PB_DATA.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The service name: a server-to-server call inside the compose network.
PB_S3_ENDPOINT: "http://seaweedfs:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
# Localhost-only by default (the Web App reaches it over the internal
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
# /ocpp/{serial} endpoint chargers dial into — on the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under PB_DATA.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
# The public front door. Bound on all interfaces so browsers can reach it.
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
seaweed_data:
+7
View File
@@ -81,6 +81,13 @@ services:
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
ports:
# Localhost-only by default (the Web App reaches it over the internal
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
+174
View File
@@ -0,0 +1,174 @@
name: drivervault
# Full DriverVault stack, with external S3: PocketBase (database) + API Server +
# Web App, built from source, storing files in an S3 endpoint that already
# exists somewhere else. Self-contained — one file, nothing to layer.
#
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
# docker compose -f docker-compose.s3.yml up -d --build
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
# document scans, service and refill receipts, workshop invoices, part photos —
# in that bucket instead of on the pb_data volume next to the database. The
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
# difference: an attachment has always been fetched through the API Server,
# never from a storage URL.
#
# The bucket must already exist, and nothing here runs the gateway. For a
# SeaweedFS that comes up with the stack, use docker-compose.seaweedfs.yml.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
pocketbase:
build:
context: ./pocketbase
image: drivervault-pocketbase
container_name: drivervault-pocketbase
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
environment:
# Superuser is created/updated on boot so the API Server can authenticate.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
- pb_data:/pb/pb_data
ports:
# Admin UI / API exposed on the host for management (http://host:8070/_/).
- "${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
build:
context: ../API Server
image: drivervault-api
container_name: drivervault-api
restart: unless-stopped
extra_hosts:
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
# Harmless when the endpoint is somewhere else entirely.
- "host.docker.internal:host-gateway"
depends_on:
pocketbase:
condition: service_healthy
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
# Same-origin requests go through the Web App BFF, so CORS is only needed
# if the browser ever calls the API Server directly. Default to the web origin.
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
# the collections are still created but no app user is, leaving a stack
# you cannot log into.
#
# Leave the bootstrap ON: a release can add collections or fields the
# server needs, and a stack that skips it never gets them. The API Server
# self-heals exactly one thing — app_settings, the collection holding the
# plugin settings, which it creates on demand because it cannot serve the
# plugin panel without it. Every other schema change still depends on this
# flag. Turn it off only for a database you know matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
# terminated in front of this stack or for local dev on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data. The bucket must already exist: nothing here
# creates it.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
# true for SeaweedFS and MinIO, false for AWS S3 proper.
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
ports:
# Optional direct access to the API Server (and its panel at /); the Web
# App reaches it over the internal network, not this host port. Chargers
# dialling /ocpp/{serial} also arrive here.
- "${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under pb_data.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
build:
context: ../Web App
args:
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
VITE_API_BASE: "${VITE_API_BASE:-}"
image: drivervault-web
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
+379
View File
@@ -0,0 +1,379 @@
name: drivervault
# Full DriverVault stack, with SeaweedFS split into its four roles: PocketBase
# (database) + API Server + Web App, built from source, plus master, volume,
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
# Self-contained — one file, nothing to layer.
#
# cp .env.seaweedfs.split.example .env (then edit it)
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
# process; here each is its own container. What that buys:
#
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
# where S3 identities are created and revoked without touching a file;
# • per-role restart, upgrade and Prometheus metrics;
# • room to add a second volume server later, on this host or another.
#
# What it costs: five containers instead of one, and five healthchecks to keep
# the boot order honest. If none of the above is wanted, use
# docker-compose.seaweedfs.yml — the S3 behaviour is identical.
#
# The on-disk layout is deliberately the same as the single-process file's:
# master, volume and filer share one /data mount, exactly as `weed server -dir`
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
# filename overlap). So the two files are interchangeable on the same volume,
# with no migration either way. A *second* volume server would need its own.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
# --- SeaweedFS: master -----------------------------------------------------
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
# the other roles are told to reach it by, so it must be the service name and
# not the container IP the process would otherwise detect.
seaweedfs-master:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-master
restart: unless-stopped
command: >
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
-volumeSizeLimitMB=1024 -metricsPort=9324
volumes:
- seaweed_data:/data
ports:
# Master UI / API. Useful while developing; the admin UI below covers the
# same ground with a nicer face.
- "${SEAWEED_MASTER_PORT:-9333}:9333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: volume server ---------------------------------------------
# Where the bytes actually land. -max=0 lets it size itself from free disk
# rather than the default cap of 8 volumes.
seaweedfs-volume:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-volume
restart: unless-stopped
command: >
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
-port=8080 -dir=/data -max=0 -metricsPort=9325
depends_on:
seaweedfs-master:
condition: service_healthy
volumes:
- seaweed_data:/data
ports:
# This port serves file content by file id with NO authentication — the S3
# credentials do not apply to it. Publish it only where you would be
# willing to publish the bucket itself. Mapped to 8081 on the host because
# 8080 there is the API Server.
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: filer ------------------------------------------------------
# Gives the flat volume store a directory tree — buckets, object keys — and
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
# embedded leveldb goes; without it that would be the container's working
# directory, and the identities would not survive a recreate.
seaweedfs-filer:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-filer
restart: unless-stopped
command: >
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
-port=8888 -defaultStoreDir=/data -metricsPort=9326
depends_on:
seaweedfs-volume:
condition: service_healthy
volumes:
- seaweed_data:/data
ports:
- "${SEAWEED_FILER_PORT:-8888}:8888"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: bucket and identity seed ----------------------------------
# Runs once and exits, before the gateway starts. Two jobs:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
# credential source in SeaweedFS: they are read only while the filer's store is
# empty, so the first identity added in the admin UI would silently displace
# them and lock PocketBase out. Seeding the store the admin UI itself writes
# leaves one source of truth, and the key PocketBase uses appears under
# Object Store → Users like any other.
#
# Both commands update in place, so every later boot re-applies the values from
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
# reaches the gateway.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# below never starts. No `|| true` here, deliberately.
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-init
restart: "no"
depends_on:
seaweedfs-filer:
condition: service_healthy
environment:
# Passed as env and expanded by the shell inside the container, so the
# secret stays out of the container's argv.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
printf '%s\n' \
"s3.bucket.create -name $$PB_S3_BUCKET" \
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
echo "s3.configure" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
| grep -q "$$PB_S3_ACCESS_KEY"
# --- SeaweedFS: S3 gateway -------------------------------------------------
# The endpoint PocketBase talks to. No -config file: with only -filer given,
# credentials come from the filer's IAM store, which is what lets the admin UI
# add and revoke identities without a restart. A config file would take
# priority over that store and make the admin UI's users inert.
seaweedfs-s3:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-s3
restart: unless-stopped
command: >
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
-metricsPort=9327
depends_on:
seaweedfs-filer:
condition: service_healthy
# Never serve before an identity exists — see seaweedfs-init above.
seaweedfs-init:
condition: service_completed_successfully
ports:
# The stack reaches the gateway over the compose network; this is here so
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
- "${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: admin UI ---------------------------------------------------
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
# It finds the filer through the master, so -master is all it needs.
#
# An unauthenticated panel that can mint credentials for the bucket *is* the
# bucket, so the password is required rather than defaulted — weed leaves auth
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
# flag, which keeps it off the process command line. -dataDir persists the
# session key and the maintenance-task settings.
seaweedfs-admin:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-admin
restart: unless-stopped
command: >
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
-metricsPort=9328
depends_on:
seaweedfs-master:
condition: service_healthy
environment:
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
volumes:
- seaweed_admin:/data
ports:
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
pocketbase:
build:
context: ./pocketbase
image: drivervault-pocketbase
container_name: drivervault-pocketbase
restart: unless-stopped
depends_on:
# PocketBase is the process that reads and writes the objects, so the
# gateway has to be serving before it is asked to store anything.
seaweedfs-s3:
condition: service_healthy
environment:
# Superuser is created/updated on boot so the API Server can authenticate.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
- pb_data:/pb/pb_data
ports:
# Admin UI / API exposed on the host for management (http://host:8070/_/).
- "${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
build:
context: ../API Server
image: drivervault-api
container_name: drivervault-api
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
# The bucket must exist before the bootstrap points PocketBase at it.
seaweedfs-init:
condition: service_completed_successfully
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
# Same-origin requests go through the Web App BFF, so CORS is only needed
# if the browser ever calls the API Server directly. Default to the web origin.
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
# the collections are still created but no app user is, leaving a stack
# you cannot log into.
#
# Leave the bootstrap ON: a release can add collections or fields the
# server needs, and a stack that skips it never gets them. The API Server
# self-heals exactly one thing — app_settings, the collection holding the
# plugin settings, which it creates on demand because it cannot serve the
# plugin panel without it. Every other schema change still depends on this
# flag. Turn it off only for a database you know matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
# terminated in front of this stack or for local dev on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The gateway's service name: a server-to-server call inside the compose
# network.
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
# Optional direct access to the API Server (and its panel at /); the Web
# App reaches it over the internal network, not this host port. Chargers
# dialling /ocpp/{serial} also arrive here.
- "${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under pb_data.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
build:
context: ../Web App
args:
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
VITE_API_BASE: "${VITE_API_BASE:-}"
image: drivervault-web
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
# Shared by master, volume and filer — the same layout `weed server -dir`
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
seaweed_data:
# The admin UI's own session key and maintenance-task state. Small, and no
# part of the object store.
seaweed_admin:
+219
View File
@@ -0,0 +1,219 @@
name: drivervault
# Full DriverVault stack, with SeaweedFS: PocketBase (database) + API Server +
# Web App, built from source, plus an S3 object store. Self-contained — one
# file, nothing to layer.
#
# cp .env.seaweedfs.example .env (then edit it)
# docker compose -f docker-compose.seaweedfs.yml up -d --build
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
# document scans, service and refill receipts, workshop invoices, part photos —
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
# the difference: an attachment has always been fetched through the API Server,
# never from a storage URL.
#
# Before turning this on for a stack that already has uploads: PocketBase does
# NOT copy existing files into the bucket. See README.md.
services:
seaweedfs:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs
restart: unless-stopped
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
# the only state it keeps.
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
environment:
# SeaweedFS falls back to these when started without an -s3.config file,
# and configuring one identity is what takes the S3 gateway out of its
# default allow-anyone mode. The same credentials PocketBase authenticates
# with below — one pair to set, in .env.
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
volumes:
# From here on the attachments live here, not on pb_data.
- seaweed_data:/data
ports:
# The stack reaches the gateway over the compose network; this is here so
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
- "${SEAWEED_S3_PORT:-8333}:8333"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-seaweedfs-init
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
# SeaweedFS will not conjure one on first upload, so something has to.
# Creating a bucket that already exists is a no-op, so every later boot
# passes straight through.
restart: "no"
depends_on:
seaweedfs:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
# `|| true` so a restart is never blocked by the shell's exit status: this
# step is best-effort, and a gateway that is genuinely unreachable is
# reported by the API Server's own S3 check at boot, with the reason.
command:
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
pocketbase:
build:
context: ./pocketbase
image: drivervault-pocketbase
container_name: drivervault-pocketbase
restart: unless-stopped
depends_on:
# PocketBase is the process that reads and writes the objects, so the
# gateway has to be serving before it is asked to store anything.
seaweedfs:
condition: service_healthy
environment:
# Superuser is created/updated on boot so the API Server can authenticate.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
- pb_data:/pb/pb_data
ports:
# Admin UI / API exposed on the host for management (http://host:8070/_/).
- "${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
build:
context: ../API Server
image: drivervault-api
container_name: drivervault-api
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
# The bucket must exist before the bootstrap points PocketBase at it.
seaweedfs-init:
condition: service_completed_successfully
environment:
API_ADDR: ":8080"
# Reach PocketBase by its service name on the internal network. Override
# POCKETBASE_URL in .env to point the API Server at a database outside
# this stack — that is also how you make a retarget done from the panel
# permanent, since the panel's change lasts only for the container's life.
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Probed by the panel status page. This is a server-to-server call inside
# the compose network, so the default is the service name — plain
# localhost:8090 would resolve to this container itself. Override
# WEBAPP_URL in .env to make a change from the panel's Web App screen
# permanent; the panel alone only holds it for the container's life.
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
# Same-origin requests go through the Web App BFF, so CORS is only needed
# if the browser ever calls the API Server directly. Default to the web origin.
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
# the collections are still created but no app user is, leaving a stack
# you cannot log into.
#
# Leave the bootstrap ON: a release can add collections or fields the
# server needs, and a stack that skips it never gets them. The API Server
# self-heals exactly one thing — app_settings, the collection holding the
# plugin settings, which it creates on demand because it cannot serve the
# plugin panel without it. Every other schema change still depends on this
# flag. Turn it off only for a database you know matches the release.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
# OCPP charger control (Anker Solix). Chargers are rejected unless they
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
# terminated in front of this stack or for local dev on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
# --- File storage --------------------------------------------------
# Read by the API Server's bootstrap, which writes them into PocketBase's
# settings on every boot, idempotently. Only record files move — scans,
# receipts, invoices, part photos. The database and PocketBase's own
# backups stay on pb_data.
PB_S3_ENABLED: "true"
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The service name: a server-to-server call inside the compose network.
PB_S3_ENDPOINT: "http://seaweedfs:8333"
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
PB_S3_SECRET: "${PB_S3_SECRET}"
# Path style, because a self-hosted gateway has no per-bucket DNS.
PB_S3_FORCE_PATH_STYLE: "true"
ports:
# Optional direct access to the API Server (and its panel at /); the Web
# App reaches it over the internal network, not this host port. Chargers
# dialling /ocpp/{serial} also arrive here.
- "${API_PORT:-8080}:8080"
# No volume: the API Server keeps no state on disk — every setting it owns,
# plugin settings included, lives in PocketBase under pb_data.
healthcheck:
# Declared here rather than relying only on the image's HEALTHCHECK, so the
# depends_on gate below still works against an older pulled image.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
web-app:
build:
context: ../Web App
args:
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
VITE_API_BASE: "${VITE_API_BASE:-}"
image: drivervault-web
container_name: drivervault-web
restart: unless-stopped
depends_on:
# The image now ships a HEALTHCHECK, so wait for the API Server to be
# serving rather than merely started.
api-server:
condition: service_healthy
environment:
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
# told to dial — to the API Server over the internal network.
API_BASE: "http://api-server:8080"
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
# charger arrived over TLS, so leave this off unless a TLS-terminating
# proxy in front of the stack is the only way in: otherwise a charger could
# claim wss over a plaintext connection. Set it to true when TLS ends at
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
ports:
- "${WEB_PORT:-8090}:8090"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
volumes:
pb_data:
seaweed_data:
-61
View File
@@ -1,61 +0,0 @@
name: drivervault
# TLS overlay — put a public hostname and a real certificate in front of the
# stack. Layer it on top of either base file:
#
# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
#
# What it changes:
# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF,
# which already carries /api/ and /ocpp/ through to the API Server. One
# hostname serves browsers and chargers alike.
# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…,
# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the
# network in the clear.
#
# Requirements, none of which this file can arrange for you:
# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and
# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the
# redirect afterwards).
# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually
# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 —
# otherwise the charger looks up a public address it cannot route to.
services:
caddy:
image: "${CADDY_IMAGE:-caddy:2-alpine}"
container_name: drivervault-caddy
restart: unless-stopped
depends_on:
web-app:
condition: service_healthy
environment:
DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}"
# Let's Encrypt sends expiry warnings here if renewal ever stops working.
DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}"
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Certificates live here. Keep the volume: wiping it re-issues on next
# boot and Let's Encrypt rate-limits that.
- caddy_data:/data
- caddy_config:/config
api-server:
environment:
# Derived from the one hostname, so there is a single thing to set.
OCPP_REQUIRE_TLS: "true"
OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}"
CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}"
web-app:
environment:
# Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it —
# that header is now set by the proxy in front, not by whoever dialed in.
TRUST_FORWARDED_PROTO: "true"
volumes:
caddy_data:
caddy_config:
+7
View File
@@ -74,6 +74,13 @@ services:
# terminated in front of this stack or for local dev on a trusted network.
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
# charger publishes over Anker's broker, decoded ones and unreadable ones
# alike, with their bytes. It is how a frame nobody has named gets named —
# do something in the Anker app while a control read holds the connection
# open, and read the frames back out of the log. Off by default: with it on
# a charger under a live trigger writes a line every few seconds.
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
ports:
# Optional direct access to the API Server (and its panel at /); the Web
# App reaches it over the internal network, not this host port. Chargers
+48 -18
View File
@@ -92,25 +92,55 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked
with `file_picker`, fetched back through the API Server — never a public URL —
and opened with the phone's own viewer via `open_filex`.
- **Charging** — mirrors the web `Charging.vue`, split into two tabs. **Public**
is a discovery map with a demo session and nearby stations: presentational
placeholders, because there is no public-charging API yet (same as the web).
**Home** is not a placeholder. It lists the chargers you own — imported from a
service you connected, the same move the garage makes for a car — and above
them four cards about the one you picked: **control** (start/stop, a current
limit, boost or reset depending on the transport), **connection** (which
charger, and either its serial or its address on your network), **readings**
(everything the charger reports over Modbus — per-phase power, its settings,
what it is, and any alarm), and **information** (everything the record holds,
with the service's live view of whether it is reachable). Control needs a mode
picked under Settings → Integrations — Modbus TCP over the local network, or
Own/Proxy CSMS over OCPP — but the information card stands without one.
Each card folds away, remembered per device; the tabs and the cards rearrange
from the ⇅ button in the app bar, and that arrangement is saved on your
profile, so it follows the account the way the garage order does.
- **Charging** — mirrors the web `Charging.vue`, split into three tabs.
**Public** is a discovery map with a demo session and nearby stations:
presentational placeholders, because there is no public-charging API yet (same
as the web). **Home** is not a placeholder. It lists the chargers you own —
imported from a service you connected, the same move the garage makes for a
car — and above them six cards about the one you picked:
- **control** — start/stop, boost, skip a start delay, reboot, and the current
limit on the one transport that has nowhere else to put it. It opens with
the charger it acts on, picture and name, because the buttons drive whichever
serial is in force and that is not always the record highlighted below.
- **RFID cards** — who may start a charge without a phone. The list the
account holds, a card added by number or by holding it against the charger's
own reader (the reader opens for twenty seconds and the number arrives on its
own), and the charger's own list read back from the device, which is the half
that actually decides whether a card opens it. When the two disagree the card
says which list each card is missing from.
- **charger settings** — what the charger is set to, written back. Over Modbus
that is four registers (the current limit, phase count, boost, the control
timeout); over the Anker cloud it is the charger's whole settings group, in
sections — charging, schedule, load balancing, solar, panel and light, local
network — one write per section, because the charger takes a command whole.
- **connection** — which charger, and either its serial or its address on your
network.
- **readings** — everything the charger reports: per-phase power, its
settings, what it is, and any alarm.
- **information** — everything the record holds, with the service's live view
of whether it is reachable, the fields the service sent under its own names,
and each of the account's per-charger views read a record at a time.
Control needs a mode picked under Settings → Integrations — Modbus TCP over
the local network, the Anker cloud, or Own/Proxy CSMS over OCPP — but the
information and RFID cards stand without one. Each card folds away, remembered
per device; the tabs and the cards rearrange from the ⇅ button in the app bar,
and that arrangement is saved on your profile, so it follows the account the
way the garage order does. A card added after you arranged yours appears beside
the neighbour it was written to sit under, rather than at the bottom.
**Scheduler** is the third tab: one list of charging tasks covering every
charger you own, where the charger's own cloud schedule is one window inside
one box. A task is a whole flow — start at 23:00, cap to 10 A at 01:00, stop at
06:30 — on the days you pick and the chargers you pick, named once and switched
on and off as one. Naming no charger means every charger you own, including the
ones you import later. The clock is the server's, so a task fires whether or
not the app is open; each row says how its last firing went, and any step can
be fired now to find out whether it will reach the charger before the night it
matters.
- **Settings** — account (name / email verification / password), appearance
(theme + dark mode, **language**, **region**, date format, **currency**, font
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
(theme + dark mode, **language**, **region**, date format, **time format**,
**first day of the week**, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
Anker Solix), **Security** (biometric toggle), **Organization** (create your
own — which makes you its admin — or rename/delete the one you administer),
**data export/import**, and the account-deletion state machine. Export writes
+151 -4
View File
@@ -112,7 +112,8 @@
"title": "Ladere i nærheden",
"tabs": {
"public": "Offentlige ladere",
"home": "Hjemmeladere"
"home": "Hjemmeladere",
"scheduler": "Planlægning af hjemmelader"
},
"arrange": {
"title": "Indret opladningssiden",
@@ -281,7 +282,29 @@
"ocpp1": "Forbinder",
"ocpp2": "Forbundet",
"mqtt0": "Ikke forbundet",
"mqtt1": "Forbundet"
"mqtt1": "Forbundet",
"settingsTitle": "Laderindstillinger",
"apply": "Anvend",
"turnOn": "Slå til",
"turnOff": "Slå fra",
"limitFloorHint": "{amps} A er bunden — derunder holder laderen pause i stedet for at lade langsomt.",
"boostHint": "Kun den aktuelle session; laderen rydder det, når sessionen slutter.",
"timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.",
"settingsReported": "Rapporteret, kan ikke indstilles",
"settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.",
"blockCharging": "Opladning",
"blockSchedule": "Tidsplan",
"blockBalancing": "Belastningsbalancering",
"blockSolar": "Sol",
"blockPanel": "Panel og lys",
"blockLocal": "Lokalt netværk",
"windowStart": "Start",
"windowEnd": "Slut",
"reset": "Fortryd",
"cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.",
"cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.",
"modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.",
"chargingStatus": "Ladestatus"
},
"info": {
"title": "Laderoplysninger",
@@ -346,7 +369,46 @@
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt.",
"nickname": "Kaldenavn",
"productCode": "Produktkode",
"deviceType": "Enhedstype",
"charging": "Oplader",
"statusCode": "Statuskode",
"ocppLink": "OCPP-forbindelse",
"wifiOnline": "Wi-Fi forbundet",
"bleId": "Bluetooth-id",
"blePassword": "Bluetooth-parringskode",
"ownerId": "Ejer-id",
"fields": {
"email": "E-mail",
"serial": "Serienummer",
"memberId": "Medlems-id",
"memberType": "Medlemstype",
"userId": "Bruger-id",
"status": "Status",
"inviteLimit": "Invitationsgrænse",
"sessions": "Sessioner",
"chargeTime": "Opladningstid",
"energy": "Opladet energi",
"co2Saved": "CO2 sparet",
"cost": "Omkostning",
"costSaved": "Sparet beløb",
"currency": "Valuta",
"mileage": "Kilometertal",
"page": "Side",
"perPage": "Pr. side",
"records": "Poster",
"from": "Fra",
"source": "Kilde",
"timeZone": "Tidszone",
"updated": "Opdateret",
"added": "Tilføjet",
"address": "Adresse",
"name": "Navn",
"cardName": "Kortnavn",
"cardNumber": "Kortnummer"
}
},
"home": {
"count": {
@@ -369,6 +431,60 @@
},
"free": "{avail} af {total} ledige",
"full": "Optaget"
},
"rfid": {
"title": "RFID-kortindstillinger",
"none": "Ingen kort er godkendt til denne lader.",
"unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.",
"add": "Tilføj kort",
"tap": "Hold kortet mod laderen",
"tapping": "Hold kortet mod læseren… {n}s",
"tapHint": "Læseren er åben. Hold kortet mod laderen.",
"tapSave": "Hold kortet mod laderen, og tilføj det",
"tapSaveHint": "Tilføjer kortet, så snart det holdes mod læseren, med navnet RFID og kortets sidste fire cifre.",
"tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.",
"addTitle": "Tilføj et kort",
"remove": "Fjern",
"removeConfirm": "Fjern {name} fra denne lader?",
"numberPlaceholder": "Kortnummer",
"namePlaceholder": "Navn (valgfrit)",
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
"readCharger": "Læs laderens egen liste",
"chargerTitle": "På selve laderen",
"chargerNone": "Laderen har ingen kort.",
"chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.",
"driftTitle": "De to lister er ikke enige",
"onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}",
"onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}",
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
},
"scheduler": {
"title": "Ladeopgaver",
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
"add": "Ny opgave",
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
"allChargers": "Alle ladere",
"missingChargers": "Ingen lader på kontoen længere",
"everyDay": "Hver dag",
"runNow": "Kør nu",
"running": "Sender…",
"lastRun": "Sidst kørt {when}",
"noResult": "intet resultat registreret",
"toggleHint": "Om uret udløser denne opgave.",
"removeConfirm": "Slet opgaven “{name}”?",
"taskCount": {
"one": "{n} opgave",
"other": "{n} opgaver"
},
"actions": {
"start": "Start opladning",
"stop": "Stop opladning",
"limit": "Sæt strømgrænse",
"boost": "Boost sessionen"
}
}
},
"settings": {
@@ -415,7 +531,17 @@
"fontSize": "Skriftstørrelse",
"fontSmall": "Lille",
"fontMedium": "Mellem",
"fontLarge": "Stor"
"fontLarge": "Stor",
"timeFormat": "Tidsformat",
"timeAuto": "Følg regionen",
"time24": "24-timers",
"time12": "12-timers",
"timeExample": "Eksempel: {example}",
"weekStart": "Første dag i ugen",
"weekAuto": "Følg regionen",
"weekMonday": "Mandag",
"weekSunday": "Søndag",
"weekExample": "Eksempel: {example}"
},
"profile": {
"title": "Profil",
@@ -535,6 +661,8 @@
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen.",
"controlModesHidden": "Skjul for dine brugere",
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
@@ -1167,6 +1295,25 @@
"alreadyImported": "Allerede importeret",
"name": "Ladernavn",
"submit": "Importer lader"
},
"chargingTask": {
"title": "Ny ladeopgave",
"editTitle": "Rediger ladeopgave",
"name": "Navn",
"namePlaceholder": "Nattakst",
"time": "Kl.",
"flow": "Forløbet",
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
"addStep": "+ Tilføj et trin",
"removeStep": "Fjern dette trin",
"amps": "Strømgrænse",
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
"chargers": "På disse ladere",
"allChargers": "Alle mine ladere",
"allChargersHint": "Inklusive ladere du importerer senere.",
"noChargers": "Ingen ladere på kontoen endnu.",
"days": "På disse dage",
"everyDay": "Hver dag"
}
},
"enums": {
+151 -4
View File
@@ -112,7 +112,8 @@
"title": "Nearby chargers",
"tabs": {
"public": "Public chargers",
"home": "Home chargers"
"home": "Home chargers",
"scheduler": "Home charger scheduler"
},
"arrange": {
"title": "Arrange the charging page",
@@ -281,7 +282,29 @@
"ocpp1": "Connecting",
"ocpp2": "Connected",
"mqtt0": "Not connected",
"mqtt1": "Connected"
"mqtt1": "Connected",
"settingsTitle": "Charger settings",
"apply": "Apply",
"turnOn": "Turn on",
"turnOff": "Turn off",
"limitFloorHint": "{amps} A is the floor — below it the charger pauses rather than charging slowly.",
"boostHint": "The current session only; the charger clears it when the session ends.",
"timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.",
"settingsReported": "Reported, not settable",
"settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.",
"blockCharging": "Charging",
"blockSchedule": "Schedule",
"blockBalancing": "Load balancing",
"blockSolar": "Solar",
"blockPanel": "Panel and light",
"blockLocal": "Local network",
"windowStart": "Start",
"windowEnd": "End",
"reset": "Undo",
"cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.",
"cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.",
"modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.",
"chargingStatus": "Charging status"
},
"info": {
"title": "Charger information",
@@ -346,7 +369,46 @@
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed."
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed.",
"nickname": "Nickname",
"productCode": "Product code",
"deviceType": "Device type",
"charging": "Charging",
"statusCode": "Status code",
"ocppLink": "OCPP connection",
"wifiOnline": "Wi-Fi connected",
"bleId": "Bluetooth id",
"blePassword": "Bluetooth pairing code",
"ownerId": "Owner id",
"fields": {
"email": "Email",
"serial": "Serial",
"memberId": "Member id",
"memberType": "Member type",
"userId": "User id",
"status": "Status",
"inviteLimit": "Invite limit",
"sessions": "Sessions",
"chargeTime": "Time charging",
"energy": "Energy charged",
"co2Saved": "CO2 saved",
"cost": "Cost",
"costSaved": "Cost saved",
"currency": "Currency",
"mileage": "Mileage",
"page": "Page",
"perPage": "Per page",
"records": "Records",
"from": "From",
"source": "Source",
"timeZone": "Time zone",
"updated": "Updated",
"added": "Added",
"address": "Address",
"name": "Name",
"cardName": "Card name",
"cardNumber": "Card number"
}
},
"home": {
"count": {
@@ -369,6 +431,60 @@
},
"free": "{avail} of {total} free",
"full": "Full"
},
"rfid": {
"title": "RFID cards settings",
"none": "No cards are authorised on this charger.",
"unsupported": "The service this charger came from does not report RFID cards.",
"add": "Add card",
"tap": "Tap card at the charger",
"tapping": "Hold the card against the reader… {n}s",
"tapHint": "The reader is open. Hold the card against the charger.",
"tapSave": "Tap card and add it",
"tapSaveHint": "Adds the card as soon as it is tapped, named RFID and its last four digits.",
"tapNone": "No card was tapped before the reader closed.",
"addTitle": "Add a card",
"remove": "Remove",
"removeConfirm": "Remove {name} from this charger?",
"numberPlaceholder": "Card number",
"namePlaceholder": "Name (optional)",
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
"notRemoved": "The service took the request, but the card is still on the charger.",
"readCharger": "Read the charger's own list",
"chargerTitle": "On the charger itself",
"chargerNone": "The charger holds no cards.",
"chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.",
"driftTitle": "The two lists disagree",
"onlyOnCharger": "Opens the charger but is not on the account: {cards}",
"onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}",
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
},
"scheduler": {
"title": "Charging tasks",
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
"add": "New task",
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
"allChargers": "All chargers",
"missingChargers": "No charger on your account any more",
"everyDay": "Every day",
"runNow": "Run now",
"running": "Sending…",
"lastRun": "Last run {when}",
"noResult": "no result recorded",
"toggleHint": "Whether the clock fires this task.",
"removeConfirm": "Delete the task “{name}”?",
"taskCount": {
"one": "{n} task",
"other": "{n} tasks"
},
"actions": {
"start": "Start charging",
"stop": "Stop charging",
"limit": "Set current limit",
"boost": "Boost the session"
}
}
},
"settings": {
@@ -415,6 +531,8 @@
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
"controlMode": "Control mode",
"controlModeHint": "How DriverVault controls the charger.",
"controlModesHidden": "Hide from your users",
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
"controlOff": "Off (monitoring only)",
"controlOwn": "Own CSMS (full control)",
"controlProxy": "Proxy CSMS (relay + control)",
@@ -528,7 +646,17 @@
"fontSize": "Font size",
"fontSmall": "Small",
"fontMedium": "Medium",
"fontLarge": "Large"
"fontLarge": "Large",
"timeFormat": "Time format",
"timeAuto": "Follow the region",
"time24": "24-hour",
"time12": "12-hour",
"timeExample": "Example: {example}",
"weekStart": "First day of the week",
"weekAuto": "Follow the region",
"weekMonday": "Monday",
"weekSunday": "Sunday",
"weekExample": "Example: {example}"
},
"profile": {
"title": "Profile",
@@ -1167,6 +1295,25 @@
"alreadyImported": "Already imported",
"name": "Charger name",
"submit": "Import charger"
},
"chargingTask": {
"title": "New charging task",
"editTitle": "Edit charging task",
"name": "Name",
"namePlaceholder": "Night rate",
"time": "At",
"flow": "The flow",
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
"addStep": "+ Add a step",
"removeStep": "Remove this step",
"amps": "Current limit",
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
"chargers": "On these chargers",
"allChargers": "All my chargers",
"allChargersHint": "Including any charger you import later.",
"noChargers": "No chargers on your account yet.",
"days": "On these days",
"everyDay": "Every day"
}
},
"enums": {
+153 -4
View File
@@ -114,7 +114,8 @@
"title": "Ładowarki w pobliżu",
"tabs": {
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
"home": "Ładowarki domowe",
"scheduler": "Harmonogram ładowarki"
},
"arrange": {
"title": "Ułóż stronę ładowania",
@@ -283,7 +284,29 @@
"ocpp1": "Łączenie",
"ocpp2": "Połączona",
"mqtt0": "Nierozłączona",
"mqtt1": "Połączona"
"mqtt1": "Połączona",
"settingsTitle": "Ustawienia ładowarki",
"apply": "Zastosuj",
"turnOn": "Włącz",
"turnOff": "Wyłącz",
"limitFloorHint": "{amps} A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"boostHint": "Tylko bieżąca sesja; ładowarka kasuje to po jej zakończeniu.",
"timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.",
"settingsReported": "Raportowane, nieustawialne",
"settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.",
"blockCharging": "Ładowanie",
"blockSchedule": "Harmonogram",
"blockBalancing": "Balansowanie obciążenia",
"blockSolar": "Fotowoltaika",
"blockPanel": "Panel i podświetlenie",
"blockLocal": "Sieć lokalna",
"windowStart": "Początek",
"windowEnd": "Koniec",
"reset": "Cofnij",
"cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.",
"cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.",
"modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.",
"chargingStatus": "Status ładowania"
},
"info": {
"title": "Informacje o ładowarce",
@@ -348,7 +371,46 @@
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw.",
"nickname": "Nazwa własna",
"productCode": "Kod produktu",
"deviceType": "Typ urządzenia",
"charging": "Ładowanie",
"statusCode": "Kod statusu",
"ocppLink": "Połączenie OCPP",
"wifiOnline": "Wi-Fi połączone",
"bleId": "Identyfikator Bluetooth",
"blePassword": "Kod parowania Bluetooth",
"ownerId": "Identyfikator właściciela",
"fields": {
"email": "E-mail",
"serial": "Numer seryjny",
"memberId": "Identyfikator członka",
"memberType": "Typ członka",
"userId": "Identyfikator użytkownika",
"status": "Status",
"inviteLimit": "Limit zaproszeń",
"sessions": "Sesje",
"chargeTime": "Czas ładowania",
"energy": "Naładowana energia",
"co2Saved": "Oszczędność CO2",
"cost": "Koszt",
"costSaved": "Oszczędność kosztów",
"currency": "Waluta",
"mileage": "Przebieg",
"page": "Strona",
"perPage": "Na stronę",
"records": "Rekordy",
"from": "Od",
"source": "Źródło",
"timeZone": "Strefa czasowa",
"updated": "Zaktualizowano",
"added": "Dodano",
"address": "Adres",
"name": "Nazwa",
"cardName": "Nazwa karty",
"cardNumber": "Numer karty"
}
},
"home": {
"count": {
@@ -375,6 +437,62 @@
},
"free": "{avail} z {total} wolnych",
"full": "Zajęte"
},
"rfid": {
"title": "Ustawienia kart RFID",
"none": "Na tej ładowarce nie autoryzowano żadnej karty.",
"unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.",
"add": "Dodaj kartę",
"tap": "Przyłóż kartę do ładowarki",
"tapping": "Przytrzymaj kartę przy czytniku… {n}s",
"tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.",
"tapSave": "Przyłóż kartę i dodaj ją",
"tapSaveHint": "Dodaje kartę zaraz po przyłożeniu, pod nazwą RFID i cztery ostatnie znaki numeru.",
"tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.",
"addTitle": "Dodaj kartę",
"remove": "Usuń",
"removeConfirm": "Usunąć {name} z tej ładowarki?",
"numberPlaceholder": "Numer karty",
"namePlaceholder": "Nazwa (opcjonalnie)",
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
"readCharger": "Odczytaj własną listę ładowarki",
"chargerTitle": "Na samej ładowarce",
"chargerNone": "Ładowarka nie ma żadnych kart.",
"chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.",
"driftTitle": "Obie listy się nie zgadzają",
"onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}",
"onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}",
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
},
"scheduler": {
"title": "Zadania ładowania",
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
"add": "Nowe zadanie",
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
"allChargers": "Wszystkie ładowarki",
"missingChargers": "Nie ma już takiej ładowarki na koncie",
"everyDay": "Codziennie",
"runNow": "Uruchom teraz",
"running": "Wysyłanie…",
"lastRun": "Ostatnio {when}",
"noResult": "brak zapisanego wyniku",
"toggleHint": "Czy zegar uruchamia to zadanie.",
"removeConfirm": "Usunąć zadanie „{name}”?",
"taskCount": {
"one": "{n} zadanie",
"few": "{n} zadania",
"many": "{n} zadań",
"other": "{n} zadania"
},
"actions": {
"start": "Rozpocznij ładowanie",
"stop": "Zatrzymaj ładowanie",
"limit": "Ustaw limit prądu",
"boost": "Przyspiesz sesję"
}
}
},
"settings": {
@@ -421,7 +539,17 @@
"fontSize": "Rozmiar czcionki",
"fontSmall": "Mała",
"fontMedium": "Średnia",
"fontLarge": "Duża"
"fontLarge": "Duża",
"timeFormat": "Format godziny",
"timeAuto": "Jak w regionie",
"time24": "24-godzinny",
"time12": "12-godzinny",
"timeExample": "Przykład: {example}",
"weekStart": "Pierwszy dzień tygodnia",
"weekAuto": "Zgodnie z regionem",
"weekMonday": "Poniedziałek",
"weekSunday": "Niedziela",
"weekExample": "Przykład: {example}"
},
"profile": {
"title": "Profil",
@@ -541,6 +669,8 @@
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
"controlModesHidden": "Ukryj przed użytkownikami",
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
@@ -1185,6 +1315,25 @@
"alreadyImported": "Już zaimportowana",
"name": "Nazwa ładowarki",
"submit": "Importuj ładowarkę"
},
"chargingTask": {
"title": "Nowe zadanie ładowania",
"editTitle": "Edytuj zadanie ładowania",
"name": "Nazwa",
"namePlaceholder": "Taryfa nocna",
"time": "O godzinie",
"flow": "Przebieg",
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
"addStep": "+ Dodaj krok",
"removeStep": "Usuń ten krok",
"amps": "Limit prądu",
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"chargers": "Na tych ładowarkach",
"allChargers": "Wszystkie moje ładowarki",
"allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.",
"noChargers": "Na koncie nie ma jeszcze ładowarek.",
"days": "W te dni",
"everyDay": "Codziennie"
}
},
"enums": {
+83
View File
@@ -652,6 +652,50 @@ class ApiClient {
return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
}
// The RFID cards on one charger — the only calls in this client that change
// anything on the Anker account. Anker documents neither endpoint, so the
// server infers the request and then reads the list back: both of these answer
// with {present, cards}, and it is the list that says what happened, not the
// status code.
Future<RfidCardWrite> saveAnkerRfidCard(String sn, String cardNumber, String cardName) async {
final data = await _send(
"POST",
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards",
body: {"cardNumber": cardNumber, "cardName": cardName},
);
if (data is! Map) return const RfidCardWrite();
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
}
/// Opens the charger's own card reader and waits for a tap — the request is in
/// flight for the whole twenty-second window, and answers whether or not a
/// card arrived.
Future<RfidScan> scanAnkerRfidCard(String sn) async {
final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/scan");
if (data is! Map) return const RfidScan();
return RfidScan.fromJson(Map<String, dynamic>.from(data));
}
Future<RfidCardWrite> deleteAnkerRfidCard(String sn, String cardNumber) async {
final data = await _send(
"DELETE",
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/${Uri.encodeComponent(cardNumber)}",
);
if (data is! Map) return const RfidCardWrite();
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
}
/// The list the charger itself holds, asked of the device rather than of the
/// account. Both are written by every add and remove, and they can still come
/// apart; this is the only call that says so. Answers with bare numbers,
/// because the device has no field for a card's name.
Future<List<String>> getAnkerChargerCards(String sn) async {
final data =
await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/charger");
final raw = data is Map ? data["cards"] : null;
return raw is List ? raw.map(_asString).where((c) => c.isNotEmpty).toList() : const [];
}
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
// broker rather than a cloud account — the charger publishes to a broker the
// owner runs and the server joins it. testGreencell connects to that broker and
@@ -772,5 +816,44 @@ class ApiClient {
Future<void> deleteHomeCharger(String id) =>
_send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");
// --- the charging scheduler ---
//
// One list of charging tasks per user, covering every charger they own. The
// server holds the clock — a schedule that only fires while the app is open
// would be a reminder, not a schedule — so the app only writes tasks and reads
// back how each one last went.
Future<List<ChargingTask>> listChargingTasks() async {
final data = await _send("GET", "/charging-tasks");
final items = (data is Map ? data["tasks"] : null) ?? [];
return (items as List)
.whereType<Map>()
.map((e) => ChargingTask.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<ChargingTask> createChargingTask(Map<String, dynamic> body) async {
final data = await _send("POST", "/charging-tasks", body: body);
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
}
Future<ChargingTask> updateChargingTask(String id, Map<String, dynamic> body) async {
final data =
await _send("PATCH", "/charging-tasks/${Uri.encodeComponent(id)}", body: body);
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
}
Future<void> deleteChargingTask(String id) =>
_send("DELETE", "/charging-tasks/${Uri.encodeComponent(id)}");
/// One step of a flow, fired now: running a start and the stop that closes it
/// back to back would leave the charger where it began and prove nothing.
/// Answers with the same summary the clock's own firing would record.
Future<String> runChargingStep(String id, int step) async {
final data = await _send(
"POST", "/charging-tasks/${Uri.encodeComponent(id)}/steps/$step/run");
return data is Map ? _asString(data["summary"]) : "";
}
static String _asString(dynamic v) => v == null ? "" : v.toString();
}
+23
View File
@@ -11,12 +11,25 @@ class AppSettings extends ChangeNotifier {
static const _kTheme = "cc_theme";
static const _kLocale = "cc_locale";
static const _kDateFormat = "cc_dateFormat";
static const _kTimeFormat = "cc_timeFormat";
static const _kWeekStart = "cc_weekStart";
static const _kCurrency = "cc_currency";
static const _kFontSize = "cc_fontSize";
String theme = "system"; // light | dark | system
String locale = "en-US"; // BCP-47 language-REGION
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
/// Which clock times are written on. "auto" is the chosen region's own
/// convention, which is what every time in the app read before there was a
/// setting; the other two are for the people whose region and habit disagree.
String timeFormat = "auto"; // auto | 24 | 12
/// The day a week is drawn as starting on, wherever weekdays are laid out in a
/// row — the charging scheduler's day picker today. See format.dart, which
/// owns the rule so every such row reads the same.
String weekStart = "auto"; // auto | monday | sunday
String currency = "USD"; // ISO 4217 code
String fontSize = "medium"; // small | medium | large
@@ -30,6 +43,8 @@ class AppSettings extends ChangeNotifier {
theme = prefs.getString(_kTheme) ?? theme;
locale = prefs.getString(_kLocale) ?? locale;
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
timeFormat = prefs.getString(_kTimeFormat) ?? timeFormat;
weekStart = prefs.getString(_kWeekStart) ?? weekStart;
currency = prefs.getString(_kCurrency) ?? currency;
fontSize = prefs.getString(_kFontSize) ?? fontSize;
notifyListeners();
@@ -40,6 +55,8 @@ class AppSettings extends ChangeNotifier {
await prefs.setString(_kTheme, theme);
await prefs.setString(_kLocale, locale);
await prefs.setString(_kDateFormat, dateFormat);
await prefs.setString(_kTimeFormat, timeFormat);
await prefs.setString(_kWeekStart, weekStart);
await prefs.setString(_kCurrency, currency);
await prefs.setString(_kFontSize, fontSize);
}
@@ -49,6 +66,8 @@ class AppSettings extends ChangeNotifier {
theme = p.theme;
locale = p.locale;
dateFormat = p.dateFormat;
timeFormat = p.timeFormat;
weekStart = p.weekStart;
currency = p.currency;
fontSize = p.fontSize;
_persist();
@@ -61,12 +80,16 @@ class AppSettings extends ChangeNotifier {
String? theme,
String? locale,
String? dateFormat,
String? timeFormat,
String? weekStart,
String? currency,
String? fontSize,
}) {
if (theme != null) this.theme = theme;
if (locale != null) this.locale = locale;
if (dateFormat != null) this.dateFormat = dateFormat;
if (timeFormat != null) this.timeFormat = timeFormat;
if (weekStart != null) this.weekStart = weekStart;
if (currency != null) this.currency = currency;
if (fontSize != null) this.fontSize = fontSize;
_persist();
+143 -1
View File
@@ -71,7 +71,149 @@ String formatPartialDate(String iso) {
/// disagree on screen.
String formatDateTime(DateTime? d) {
if (d == null) return "";
return "${formatDate(d)} ${DateFormat.Hm(_locale).format(d)}";
return "${formatDate(d)} ${formatTime(d)}";
}
/// The clock alone. "auto" leaves the reading to the region, which is what every
/// time in the app said before there was a setting; the other two are for the
/// people whose region and habit disagree — plenty of Poles read 12-hour clocks
/// and plenty of Americans read 24-hour ones, and the region picker also decides
/// how money and numbers are grouped, so it is the wrong lever to reach for.
///
/// The setting decides *which* clock; this file decides how it is punctuated.
/// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is
/// a real difference in how people tell the time. It is not worth asking whether
/// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one
/// screen of DriverVault writing 13.45 while the next writes 13:45 is not local
/// colour, it is an inconsistency. So every time this app prints comes out of
/// the two lines below, the same as the web app's format.js.
///
/// The cost is that the am/pm marker reads in English everywhere. It is the same
/// trade the setting itself makes: a 12-hour clock is not a convention most of
/// these regions use, so choosing one — or living in a region that does — is
/// choosing the clock that comes with it.
String formatTime(DateTime? d) {
if (d == null) return "";
final mm = d.minute.toString().padLeft(2, "0");
if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm";
// 12 for both noon and midnight, and midnight is the am one.
final h = d.hour % 12 == 0 ? 12 : d.hour % 12;
return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}";
}
/// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is
/// no date to hand [formatTime] — read on the clock the user chose. Anything
/// that is not a time of day comes back as it arrived.
String formatClock(String hhmm) {
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim());
if (m == null) return hhmm;
final h = int.parse(m.group(1)!);
if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}";
final twelve = h % 12 == 0 ? 12 : h % 12;
return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}";
}
/// Whether times are written on a 12-hour clock right now: what the setting says
/// outright, or what the region says when it is left on auto.
///
/// Not only [formatTime]'s business. A control that lets somebody *enter* a time
/// has to offer the same clock, and a box that reads 13:45 beside a picker that
/// says 01:45 PM is the disagreement this setting exists to end — see
/// widgets/time_field.dart.
bool clockIsTwelveHour() {
switch (appSettings.timeFormat) {
case "12":
return true;
case "24":
return false;
default:
return _regionReadsTwelveHour();
}
}
/// Whether the chosen region tells the time on a 12-hour clock — the one
/// question "auto" asks it. Cached because this is asked once per timestamp on a
/// page that can hold a great many, and the answer only changes with the region.
final Map<String, bool> _twelveHourRegions = {};
bool _regionReadsTwelveHour() {
return _twelveHourRegions.putIfAbsent(_locale, () {
try {
// DateFormat.j() is the locale's own preferred hour field: "h" where it is
// read on a 12-hour clock, "H" where it is not.
return DateFormat.j(_locale).pattern?.contains("h") ?? false;
} catch (_) {
// An unusable locale is not a reason to print nothing; 24-hour is the
// safer default, being the one that cannot be read as the wrong half of
// the day.
return false;
}
});
}
// --- Weekdays ---------------------------------------------------------------
//
// A week does not start on the same day everywhere: Monday across most of
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
// that always begins on Sunday reads wrong to half the people looking at it,
// and reads wrong in a way that is easy to mistap — Settings Appearance
// First day of the week is the answer, with "auto" following the chosen region
// the way the clock setting does.
//
// Everything that lays weekdays out in a row goes through these, so there is one
// answer to "which day comes first" rather than one per screen. Days are
// numbered the way the scheduler's stored tasks number them: 0 = Sunday …
// 6 = Saturday.
/// Whether weeks are drawn as starting on Monday right now: what the setting
/// says outright, or what the region says when it is left on auto.
bool weekStartsOnMonday() {
switch (appSettings.weekStart) {
case "monday":
return true;
case "sunday":
return false;
default:
return _regionStartsOnMonday();
}
}
final Map<String, bool> _mondayRegions = {};
bool _regionStartsOnMonday() {
return _mondayRegions.putIfAbsent(_locale, () {
try {
// intl carries the region's own answer in its date symbols, numbered
// 0 = Monday … 6 = Sunday (the Closure convention its data came from).
return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0;
} catch (_) {
// Monday is the safer default: it is ISO 8601's, and the convention in
// every region this app's own currency list covers bar one.
return true;
}
});
}
/// The seven days in the order they should be drawn, as day numbers.
List<int> weekdaysInOrder() =>
weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6];
/// One day's short name in the user's own language, so a row reads Pn Wt Śr in
/// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
/// sits, so the offset lands each number on its own day.
String weekdayShortName(int day) {
try {
return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day));
} catch (_) {
return "$day";
}
}
/// A set of days, listed in the order this account reads a week in — so the same
/// three days always come out in the same order wherever they are shown.
List<int> sortWeekdays(Iterable<int> days) {
final order = weekdaysInOrder();
return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
}
// 0 km is a reading — a car collected new — not a blank. See format.js.
+207 -1
View File
@@ -890,6 +890,12 @@ class UserProfile {
final String theme; // light | dark | system
final String locale; // BCP-47 language-REGION, e.g. "en-US"
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
final String timeFormat; // auto (the region's own) | 24 | 12
/// The day a week is drawn as starting on, wherever weekdays are laid out in
/// a row — the charging scheduler's day picker today.
final String weekStart; // auto (the region's own) | monday | sunday
final String currency; // ISO 4217 code, e.g. "EUR"
final String fontSize; // small | medium | large
final String role; // user | admin
@@ -914,6 +920,8 @@ class UserProfile {
required this.theme,
required this.locale,
required this.dateFormat,
this.timeFormat = "auto",
this.weekStart = "auto",
this.currency = "USD",
required this.fontSize,
required this.role,
@@ -934,6 +942,8 @@ class UserProfile {
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
timeFormat: j["timeFormat"] == null ? "auto" : _asStr(j["timeFormat"]),
weekStart: j["weekStart"] == null ? "auto" : _asStr(j["weekStart"]),
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
@@ -988,7 +998,21 @@ class IntegrationScope {
final String editableLayer; // "user" | "org"
final Map<String, IntegrationField> fields;
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
/// Anker only. The control modes this scope may still choose — the server has
/// already dropped whatever the layers above it hid. Empty for an integration
/// that has no such list.
final List<String> controlModes;
/// Anker, org scope only. The modes this organization hides from its own
/// users, which its admin edits here.
final List<String> controlModesDisabled;
const IntegrationScope({
this.editableLayer = "user",
this.fields = const {},
this.controlModes = const [],
this.controlModesDisabled = const [],
});
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
final raw = j["fields"];
@@ -1001,6 +1025,8 @@ class IntegrationScope {
return IntegrationScope(
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
fields: fields,
controlModes: _asStrList(j["controlModes"]),
controlModesDisabled: _asStrList(j["controlModesDisabled"]),
);
}
@@ -1235,6 +1261,10 @@ class AnkerCharger {
final String statusDesc; // charging | standby | … as the cloud names it
final bool? online; // null when no view reported a connection state
/// The product shot the service holds for this model, when it sent one. A URL
/// rather than an image: it is fetched only where it is drawn.
final String imageUrl;
const AnkerCharger({
required this.sn,
this.name = "",
@@ -1243,6 +1273,7 @@ class AnkerCharger {
this.siteName = "",
this.statusDesc = "",
this.online,
this.imageUrl = "",
});
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
@@ -1253,6 +1284,7 @@ class AnkerCharger {
siteName: _asStr(j["siteName"]),
statusDesc: _asStr(j["statusDesc"]),
online: j["online"] is bool ? j["online"] as bool : null,
imageUrl: _asStr(j["imageUrl"]),
);
/// What to call the charger in a list: its name when it has one, its serial
@@ -1284,6 +1316,180 @@ class AnkerChargerList {
}
}
// --- RFID cards (who may start a charge without a phone) --------------------
/// One card authorised on a charger, as the account holds it. The cloud answers
/// with its own field names — alias_name, card_number, create_time — and this is
/// the same three read out: a number to delete by, the name it was given, and
/// when it was added.
class RfidCard {
final String number;
final String name;
final String added; // as the cloud sent it: unix seconds, or ""
const RfidCard({required this.number, this.name = "", this.added = ""});
factory RfidCard.fromJson(Map<String, dynamic> j) {
final number = _asStr(j["card_number"]).trim();
final name = _asStr(j["alias_name"]).trim();
return RfidCard(
number: number,
// A card with no name of its own is still a card somebody holds, and its
// number is the only honest thing to call it.
name: name.isEmpty ? number : name,
added: _asStr(j["create_time"]).trim(),
);
}
}
/// What a card write answers with: whether the account holds that card now, and
/// the whole list as it stands after the write. Anker documents neither endpoint,
/// so a 200 proves nothing on its own — it is the list that says what happened.
class RfidCardWrite {
final bool present;
final List<RfidCard> cards;
final String detail;
const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""});
factory RfidCardWrite.fromJson(Map<String, dynamic> j) => RfidCardWrite(
present: _asBool(j["present"]),
cards: j["cards"] is List
? (j["cards"] as List)
.whereType<Map>()
.map((c) => RfidCard.fromJson(Map<String, dynamic>.from(c)))
.where((c) => c.number.isNotEmpty)
.toList()
: const [],
detail: _asStr(j["detail"]),
);
}
/// What a scan answers with: the card that was held against the reader, or the
/// plain fact that nothing was. A window that closed empty is an answer, not a
/// timeout, which is why [tapped] is separate from [card].
class RfidScan {
final bool tapped;
final String card;
const RfidScan({this.tapped = false, this.card = ""});
factory RfidScan.fromJson(Map<String, dynamic> j) =>
RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim());
}
// --- the charging scheduler -------------------------------------------------
//
// The charger's own cloud schedule can say one thing — "charge between these
// hours" — and it says it inside one charger. This is a list, and each entry is
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
// chargers, on these days. One named thing, switched on and off as one.
/// One command in a task's flow: what to do, and at what time of day.
class ChargingStep {
/// start, stop, limit (to [amps]) or boost.
final String action;
final double amps;
/// A 24-hour "HH:MM", read in the task's zone.
final String time;
const ChargingStep({required this.action, required this.time, this.amps = 0});
factory ChargingStep.fromJson(Map<String, dynamic> j) => ChargingStep(
action: _asStr(j["action"]),
time: _asStr(j["time"]),
amps: _asDouble(j["amps"]),
);
Map<String, dynamic> toJson() => {"action": action, "time": time, "amps": amps};
}
/// One entry in the home-charger scheduler. It belongs to the person, like the
/// chargers it acts on — one list covering every charger they own, rather than a
/// separate schedule inside each one.
class ChargingTask {
final String id;
final String name;
/// The home-charger records this task acts on. Empty means every charger the
/// owner has, including ones imported after the task was written — "all of
/// them" is a standing wish, not the list that happened to exist that day.
final List<String> chargers;
/// The flow, in the order it runs.
final List<ChargingStep> steps;
/// The IANA zone the steps' times are read in. The server's own clock is not
/// the one the user set 23:00 by.
final String zone;
/// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
final List<int> days;
final bool enabled;
/// What happened the last time a step of it fired, so a task that has been
/// failing quietly for a week says so in the list rather than in a log nobody
/// reads.
final DateTime? lastRun;
final String lastResult;
const ChargingTask({
required this.id,
this.name = "",
this.chargers = const [],
this.steps = const [],
this.zone = "",
this.days = const [],
this.enabled = true,
this.lastRun,
this.lastResult = "",
});
factory ChargingTask.fromJson(Map<String, dynamic> j) => ChargingTask(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
chargers: _asStrList(j["chargers"]),
steps: j["steps"] is List
? (j["steps"] as List)
.whereType<Map>()
.map((e) => ChargingStep.fromJson(Map<String, dynamic>.from(e)))
.toList()
: const [],
zone: _asStr(j["zone"]),
days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [],
enabled: _asBool(j["enabled"]),
lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(),
lastResult: _asStr(j["lastResult"]),
);
/// The time of day the task begins — its first step's, which is what the list
/// is ordered by, so the evening's task sits below the morning's.
String get firstTime => steps.isEmpty ? "99:99" : steps.first.time;
/// Whether the last firing reached every charger it was aimed at. The server
/// words the outcome as the step it fired and then "n of m sent", so every
/// charger answering is the only good case; unknown until it has fired once.
bool? get lastRunOk {
if (lastRun == null) return null;
final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult);
return m != null && m.group(1) == m.group(2);
}
ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask(
id: id,
name: name,
chargers: chargers,
steps: steps,
zone: zone,
days: days,
enabled: enabled ?? this.enabled,
lastRun: lastRun ?? this.lastRun,
lastResult: lastResult ?? this.lastResult,
);
}
// --- home chargers (the user's own wallbox) ---------------------------------
//
// The garage's import, aimed at the wall: a charger on a connected service
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
import "package:flutter/material.dart";
import "../format.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
import "../widgets/time_field.dart";
/// One line of the home-charger scheduler, being written or edited.
///
/// The charger's own cloud schedule asks four questions and asks them inside one
/// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
/// makes it a scheduler rather than a second copy of that: *which* chargers. A
/// task can name one, several, or none at all — and none means every charger on
/// the account, including ones imported after the task was written, because "all
/// of them" is a standing wish rather than the list that happened to exist that
/// day.
///
/// A task holds a flow rather than a single command: start at 23:00, ease down
/// to 10 A at 01:00, stop at 06:30. That is one intention, so it is one named
/// thing with one switch — splitting a charging window across two tasks meant
/// naming it twice and remembering to switch off both ends.
///
/// Pops the saved [ChargingTask].
Future<ChargingTask?> showChargingTaskSheet(
BuildContext context, {
ChargingTask? task,
required List<HomeCharger> chargers,
}) {
return showModalBottomSheet<ChargingTask>(
context: context,
isScrollControlled: true,
builder: (_) => _ChargingTaskSheet(task: task, chargers: chargers),
);
}
/// What the charger can actually be asked to do. Boost and the current limit
/// only reach it over the Anker cloud connection; start and stop reach it over
/// all three transports. The control mode is a Settings choice, not this form's
/// business, so all four are offered and the one that cannot be sent says so
/// when it fires — same as the buttons on the page behind this.
const List<String> _kActions = ["start", "stop", "limit", "boost"];
/// One row of the flow while it is being edited. Mutable, because the form edits
/// the rows in place; [ChargingStep] is what gets sent.
class _StepDraft {
String action;
String time;
double amps;
_StepDraft(this.action, this.time, this.amps);
}
class _ChargingTaskSheet extends StatefulWidget {
final ChargingTask? task;
final List<HomeCharger> chargers;
const _ChargingTaskSheet({this.task, required this.chargers});
@override
State<_ChargingTaskSheet> createState() => _ChargingTaskSheetState();
}
class _ChargingTaskSheetState extends State<_ChargingTaskSheet> {
late final TextEditingController _name =
TextEditingController(text: widget.task?.name ?? "");
/// The flow, as rows the form edits in place. A new task opens with the one
/// step most schedules start from, so the common case is a name and a time
/// rather than a decision about how many rows to add.
late final List<_StepDraft> _steps = (widget.task?.steps ?? const []).isEmpty
? [_StepDraft("start", "23:00", 16)]
: widget.task!.steps
.map((s) => _StepDraft(s.action, s.time, s.amps > 0 ? s.amps : 16))
.toList();
/// The chargers this task acts on. Empty is meaningful — it means all of them
/// — so the picker has a switch of its own rather than leaving an empty list
/// looking like an unfinished form.
late bool _allChargers = widget.task == null || widget.task!.chargers.isEmpty;
late final Set<String> _picked = {...?widget.task?.chargers};
late bool _everyDay = widget.task == null || widget.task!.days.isEmpty;
late final Set<int> _days = {...?widget.task?.days};
bool _saving = false;
String? _error;
bool get _editing => (widget.task?.id ?? "").isNotEmpty;
@override
void dispose() {
_name.dispose();
super.dispose();
}
/// A flow of one is a flow, so the last row cannot be removed — an empty task
/// would have nothing to fire and the server refuses it anyway.
void _addStep() => setState(() => _steps.add(_StepDraft("stop", "06:30", 16)));
void _removeStep(int i) {
if (_steps.length <= 1) return;
setState(() => _steps.removeAt(i));
}
/// Unticking every day (or every charger) by hand is the same wish as the
/// "all" switch, so it lands there rather than leaving a task that acts on
/// nothing.
void _toggleDay(int day) {
setState(() {
_everyDay = false;
_days.contains(day) ? _days.remove(day) : _days.add(day);
if (_days.isEmpty) _everyDay = true;
});
}
void _toggleCharger(String id) {
setState(() {
_allChargers = false;
_picked.contains(id) ? _picked.remove(id) : _picked.add(id);
if (_picked.isEmpty) _allChargers = true;
});
}
/// A time still being typed is not a time — [TimeField] says so with an empty
/// value — and one unfinished row is enough to make the whole flow unsaveable,
/// because the server would otherwise refuse it with a step number the form
/// does not show.
bool get _canSave =>
_name.text.trim().isNotEmpty && _steps.every((s) => s.time.isNotEmpty) && !_saving;
Future<void> _submit() async {
if (!_canSave) return;
setState(() {
_saving = true;
_error = null;
});
final body = <String, dynamic>{
"name": _name.text.trim(),
// The amps ride along on every step so switching one to "limit" and back
// does not lose the number that was typed; the server keeps them for the
// same reason and ignores them on the actions that have no ceiling.
"steps": [
for (final s in _steps)
{
"action": s.action,
"time": s.time,
"amps": s.action == "limit" ? s.amps : 0,
},
],
"chargers": _allChargers ? <String>[] : _picked.toList(),
"days": _everyDay ? <int>[] : _days.toList(),
// The time is a wall clock, and the server's is not the one it was set by.
// Sending the zone this phone is in is what keeps 23:00 at 23:00 for a
// server sitting in another country.
"zone": _deviceZone(),
};
try {
final saved = _editing
? await apiClient.updateChargingTask(widget.task!.id, body)
: await apiClient.createChargingTask(body);
if (mounted) Navigator.pop(context, saved);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _saving = false);
}
}
/// The phone's own zone name. Dart has no IANA name to hand — only an offset
/// and the platform's abbreviation — so the server is sent what it can read
/// and falls back to its own clock when it cannot: an offset is not a zone,
/// and a name that is not IANA would be worse than saying nothing.
String _deviceZone() {
final name = DateTime.now().timeZoneName;
return name.contains("/") ? name : "";
}
String _chargerSubtitle(HomeCharger c) =>
[c.serial, c.model].where((v) => v.isNotEmpty).join(" · ");
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t(_editing ? "forms.chargingTask.editTitle" : "forms.chargingTask.title"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
Text(t("forms.chargingTask.name"),
style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
TextField(
controller: _name,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("forms.chargingTask.namePlaceholder"),
),
onChanged: (_) => setState(() {}),
),
// The flow. One row per step, each an action and the time it fires —
// read down, they are the night: start at 23:00, ease off at 01:00,
// stop at 06:30.
const SizedBox(height: 16),
Text(t("forms.chargingTask.flow"),
style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
for (var i = 0; i < _steps.length; i++) _stepRow(context, i, sunken, muted),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _addStep,
child: Text(t("forms.chargingTask.addStep")),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("forms.chargingTask.flowHint"),
style: TextStyle(fontSize: 12, color: muted)),
),
// Which chargers. The point of one scheduler for all of them.
const SizedBox(height: 16),
Text(t("forms.chargingTask.chargers"),
style: const TextStyle(fontWeight: FontWeight.w500)),
CheckboxListTile(
value: _allChargers,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(t("forms.chargingTask.allChargers"),
style: const TextStyle(fontSize: 14)),
subtitle: _allChargers
? Text(t("forms.chargingTask.allChargersHint"),
style: TextStyle(fontSize: 12, color: muted))
: null,
onChanged: (on) => setState(() {
_allChargers = on ?? true;
if (_allChargers) _picked.clear();
}),
),
if (widget.chargers.isEmpty)
Text(t("forms.chargingTask.noChargers"),
style: TextStyle(fontSize: 12, color: muted)),
for (final c in widget.chargers)
CheckboxListTile(
value: !_allChargers && _picked.contains(c.id),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(c.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14)),
subtitle: _chargerSubtitle(c).isEmpty
? null
: Text(_chargerSubtitle(c),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11, color: muted)),
onChanged: (_) => _toggleCharger(c.id),
),
// Which days.
const SizedBox(height: 8),
Text(t("forms.chargingTask.days"),
style: const TextStyle(fontWeight: FontWeight.w500)),
CheckboxListTile(
value: _everyDay,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(t("forms.chargingTask.everyDay"),
style: const TextStyle(fontSize: 14)),
onChanged: (on) => setState(() {
_everyDay = on ?? true;
if (_everyDay) _days.clear();
}),
),
// The row starts on whichever day this account reads a week as
// starting on — Settings Appearance First day of the week,
// following the region unless it was answered outright. format.dart
// owns the rule for every weekday row in the app.
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final d in weekdaysInOrder())
ChoiceChip(
label: Text(weekdayShortName(d), style: const TextStyle(fontSize: 12)),
selected: !_everyDay && _days.contains(d),
onSelected: (_) => _toggleDay(d),
),
],
),
const SizedBox(height: 16),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: Text(t("common.cancel")),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _canSave ? _submit : null,
child: Text(_saving ? t("common.saving") : t("common.save")),
),
),
]),
],
),
),
);
}
Widget _stepRow(BuildContext context, int i, Color sunken, Color muted) {
final s = _steps[i];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: sunken,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: s.action,
isExpanded: true,
decoration:
const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final a in _kActions)
DropdownMenuItem(
value: a,
child: Text(t("charging.scheduler.actions.$a"),
overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() => s.action = v ?? s.action),
),
),
const SizedBox(width: 8),
TimeField(
value: s.time,
onChanged: (v) => setState(() => s.time = v),
),
// The last step cannot go: a task with no steps has nothing to
// fire, so the control is absent rather than there and refusing.
if (_steps.length > 1)
IconButton(
icon: const Icon(Icons.close, size: 18),
color: muted,
tooltip: t("forms.chargingTask.removeStep"),
onPressed: () => _removeStep(i),
),
],
),
// The ceiling, under the one action that takes one.
if (s.action == "limit") ...[
const SizedBox(height: 8),
Row(children: [
Text(t("forms.chargingTask.amps"),
style: TextStyle(fontSize: 13, color: muted)),
const Spacer(),
Text("${s.amps.round()} A",
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
]),
Slider(
value: s.amps.clamp(6, 32),
min: 6,
max: 32,
divisions: 26,
label: "${s.amps.round()} A",
onChanged: (v) => setState(() => s.amps = v),
),
Text(t("forms.chargingTask.ampsHint"),
style: TextStyle(fontSize: 11, color: muted)),
],
],
),
);
}
}
+6 -1
View File
@@ -257,7 +257,11 @@ class _LoginScreenState extends State<LoginScreen> {
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
decoration: InputDecoration(
labelText: t("login.email"),
border: const OutlineInputBorder(),
hintText: "you@example.com",
),
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
),
const SizedBox(height: 12),
@@ -267,6 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
decoration: InputDecoration(
labelText: t("login.password"),
border: const OutlineInputBorder(),
hintText: "••••••••",
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
+212 -11
View File
@@ -480,6 +480,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
"theme": appSettings.theme,
"locale": appSettings.locale,
"dateFormat": appSettings.dateFormat,
"timeFormat": appSettings.timeFormat,
"weekStart": appSettings.weekStart,
"currency": appSettings.currency,
"fontSize": appSettings.fontSize,
};
@@ -487,6 +489,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
theme: patch["theme"],
locale: patch["locale"],
dateFormat: patch["dateFormat"],
timeFormat: patch["timeFormat"],
weekStart: patch["weekStart"],
currency: patch["currency"],
fontSize: patch["fontSize"],
);
@@ -498,6 +502,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
theme: prev["theme"],
locale: prev["locale"],
dateFormat: prev["dateFormat"],
timeFormat: prev["timeFormat"],
weekStart: prev["weekStart"],
currency: prev["currency"],
fontSize: prev["fontSize"],
);
@@ -615,6 +621,63 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
// Beside the date rather than under the region, because it is the same
// question asked about the other half of a timestamp.
const SizedBox(height: 16),
Text(t("settings.appearance.timeFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.timeFormat,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.timeAuto"))),
DropdownMenuItem(value: "24", child: Text(t("settings.appearance.time24"))),
DropdownMenuItem(value: "12", child: Text(t("settings.appearance.time12"))),
],
onChanged: (v) => v == null ? null : _save({"timeFormat": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
// Thirteen-something rather than now: an example at 09:00 reads the
// same in both conventions, which is the one time of day that cannot
// show the choice.
child: Text(
t("settings.appearance.timeExample", params: {
"example": formatTime(DateTime(2024, 1, 1, 13, 45)),
}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
// Under the clock, as the last of the three questions a region is asked
// and the one it is least often asked out loud.
const SizedBox(height: 16),
Text(t("settings.appearance.weekStart"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: appSettings.weekStart,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.weekAuto"))),
DropdownMenuItem(value: "monday", child: Text(t("settings.appearance.weekMonday"))),
DropdownMenuItem(value: "sunday", child: Text(t("settings.appearance.weekSunday"))),
],
onChanged: (v) => v == null ? null : _save({"weekStart": v}),
),
Padding(
padding: const EdgeInsets.only(top: 4),
// The week as this account will now see it drawn — the clearest
// possible example, because the setting has no other visible effect on
// this page.
child: Text(
t("settings.appearance.weekExample", params: {
"example": weekdaysInOrder().map(weekdayShortName).join(" "),
}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
const SizedBox(height: 16),
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
@@ -1484,20 +1547,42 @@ class _DangerSectionState extends State<_DangerSection> {
// second "org" scope to edit organization-wide defaults; superadmins manage the
// shared layer in the API Server panel, so here it is read-only.
enum _FieldType { text, number, password, select }
enum _FieldType { text, number, password, select, multiselect }
/// The control modes the server still offers in a scope, and the ones this
/// organization hides from its own users. Named functions rather than closures
/// so the field specs below can stay const.
List<String> _scopeControlModes(IntegrationScope s) => s.controlModes;
List<String> _scopeHiddenControlModes(IntegrationScope s) => s.controlModesDisabled;
/// Describes one credential field within an integration card.
class _FieldSpec {
final String key;
final String labelKey;
final _FieldType type;
final List<(String, String)> options; // (value, labelKey) for selects
final List<(String, String)> options; // (value, labelKey) for select/multiselect
final String? placeholder; // literal placeholder
final String? hintKey; // shown below the field when not locked
final String defaultValue;
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
final int? maxLength;
/// Narrows the declared options to those the server still offers in this
/// scope — the control-mode hide-list, which is what makes a mode a superadmin
/// switched off disappear from an organization's picker, and one an
/// organization switched off disappear from its users'. Null leaves every
/// declared option standing.
final List<String> Function(IntegrationScope)? scopeOptions;
/// Where a multiselect reads its current value from — a scope list rather than
/// a field, because a hide-list is not a cascaded value: it is this layer's
/// own instruction to the layers below.
final List<String> Function(IntegrationScope)? scopeValue;
/// Only shown when editing the organization layer. A user has nobody below
/// them, so a hide-list would mean nothing there.
final bool orgOnly;
const _FieldSpec({
required this.key,
required this.labelKey,
@@ -1508,6 +1593,9 @@ class _FieldSpec {
this.defaultValue = "",
this.showEffectiveWhenLocked = false,
this.maxLength,
this.scopeOptions,
this.scopeValue,
this.orgOnly = false,
});
}
@@ -1593,6 +1681,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
hintKey: "settings.integrations.controlModeHint",
defaultValue: "off",
showEffectiveWhenLocked: true,
scopeOptions: _scopeControlModes,
options: [
("off", "settings.integrations.controlOff"),
("mqtt", "settings.integrations.controlCloud"),
@@ -1601,6 +1690,23 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
("proxy", "settings.integrations.controlProxy"),
],
),
// What this organization hides from its own users. Off is absent on
// purpose: monitoring only is the fallback, so no layer may take it away.
_FieldSpec(
key: "controlModesDisabled",
labelKey: "settings.integrations.controlModesHidden",
type: _FieldType.multiselect,
hintKey: "settings.integrations.controlModesHiddenHint",
orgOnly: true,
scopeOptions: _scopeControlModes,
scopeValue: _scopeHiddenControlModes,
options: [
("mqtt", "settings.integrations.controlCloud"),
("modbus", "settings.integrations.controlModbus"),
("own", "settings.integrations.controlOwn"),
("proxy", "settings.integrations.controlProxy"),
],
),
],
);
@@ -1736,6 +1842,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
final Map<String, TextEditingController> _controllers = {};
final Map<String, String> _selects = {};
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
_IntegrationConfig get _c => widget.config;
@@ -1743,7 +1850,9 @@ class _IntegrationCardState extends State<_IntegrationCard> {
void initState() {
super.initState();
for (final f in _c.fields) {
if (f.type != _FieldType.select) _controllers[f.key] = TextEditingController();
if (f.type != _FieldType.select && f.type != _FieldType.multiselect) {
_controllers[f.key] = TextEditingController();
}
}
_load();
}
@@ -1762,6 +1871,25 @@ class _IntegrationCardState extends State<_IntegrationCard> {
bool get _readOnly => _view?.isSuperadmin ?? false;
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
IntegrationField _field(String k) => _scopeData.field(k);
/// The fields this scope actually shows: an org-only field (a hide-list) is
/// out of the user scope, and a field whose options the layers above have all
/// hidden has nothing left to offer.
List<_FieldSpec> get _shownFields => [
for (final f in _c.fields)
if ((!f.orgOnly || _editingOrg) && (f.options.isEmpty || _optionsFor(f).isNotEmpty)) f,
];
/// A field's options after the scope's own list has narrowed them.
List<(String, String)> _optionsFor(_FieldSpec f) {
if (f.scopeOptions == null) return f.options;
final allowed = f.scopeOptions!(_scopeData);
if (allowed.isEmpty) return f.options;
return [
for (final o in f.options)
if (allowed.contains(o.$1)) o,
];
}
bool _locked(String k) => _readOnly || _field(k).locked;
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
@@ -1776,6 +1904,12 @@ class _IntegrationCardState extends State<_IntegrationCard> {
// fields and the secret password are never prefilled.
void _fillForm() {
for (final f in _c.fields) {
if (f.type == _FieldType.multiselect) {
// A hide-list is this layer's own, not something inherited, so it comes
// from the scope rather than from a cascaded field.
_multi[f.key] = {...?f.scopeValue?.call(_scopeData)};
continue;
}
final field = _field(f.key);
String value;
if (f.type == _FieldType.password) {
@@ -1788,7 +1922,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
value = field.own.isNotEmpty ? field.own : f.defaultValue;
}
if (f.type == _FieldType.select) {
_selects[f.key] = value;
// A stored mode the layers above have since hidden is no longer on
// offer, so the picker starts from the default instead of showing a
// choice that would not take effect anyway.
final options = _optionsFor(f);
_selects[f.key] = options.any((o) => o.$1 == value) ? value : f.defaultValue;
} else {
_controllers[f.key]!.text = value;
}
@@ -1833,9 +1971,19 @@ class _IntegrationCardState extends State<_IntegrationCard> {
_saved = false;
});
final config = <String, dynamic>{};
for (final f in _c.fields) {
for (final f in _shownFields) {
if (_locked(f.key)) continue;
final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _controllers[f.key]!.text;
final String val;
switch (f.type) {
case _FieldType.multiselect:
// The server stores a hide-list as the same comma-separated string the
// API Server panel writes, so both layers round-trip identically.
val = _optionsFor(f).map((o) => o.$1).where(_multi[f.key]!.contains).join(",");
case _FieldType.select:
val = _selects[f.key] ?? "";
default:
val = _controllers[f.key]!.text;
}
if (f.type == _FieldType.password && val.isEmpty) continue;
config[f.key] = val;
}
@@ -1995,7 +2143,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
),
// Credential fields.
for (final f in _c.fields) ...[
for (final f in _shownFields) ...[
const SizedBox(height: 12),
_fieldWidget(f),
],
@@ -2083,12 +2231,51 @@ class _IntegrationCardState extends State<_IntegrationCard> {
final field = _field(f.key);
Widget input;
if (f.type == _FieldType.select) {
final options = _optionsFor(f);
if (f.type == _FieldType.multiselect) {
final chosen = _multi[f.key] ??= {};
input = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final o in options)
InkWell(
onTap: locked
? null
: () => setState(() {
if (!chosen.remove(o.$1)) chosen.add(o.$1);
}),
child: Row(children: [
Checkbox(
value: chosen.contains(o.$1),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: locked
? null
: (v) => setState(() {
if (v ?? false) {
chosen.add(o.$1);
} else {
chosen.remove(o.$1);
}
}),
),
const SizedBox(width: 4),
Expanded(child: Text(t(o.$2), style: const TextStyle(fontSize: 14))),
]),
),
],
);
} else if (f.type == _FieldType.select) {
// A mode hidden above is gone from the list, so a stored value naming one
// has nothing to select — fall back to the default rather than crashing
// the dropdown on a value it does not carry.
final current = _selects[f.key] ?? f.defaultValue;
final value = options.any((o) => o.$1 == current) ? current : f.defaultValue;
input = DropdownButtonFormField<String>(
initialValue: _selects[f.key] ?? f.defaultValue,
initialValue: value,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final o in f.options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
for (final o in options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
],
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
);
@@ -2100,6 +2287,20 @@ class _IntegrationCardState extends State<_IntegrationCard> {
final showDots = isPassword
? field.effective.isNotEmpty
: locked && !f.showEffectiveWhenLocked;
// What an inherited field shows when it is empty.
//
// A locked field is standing in for a value set above the caller, and the
// server has already decided which of those may be read: it sends the
// secrets back as dots and everything else in the clear. So the
// placeholder is that effective value — the thing the field will actually
// use — and the example is for the other case, an empty box waiting to be
// filled in.
//
// The example belongs only there. A country field placeholdered "DE" under
// the words "inherited from your organization" is not a hint, it is a
// wrong answer to the question the user is asking it: which country am I
// inheriting?
final placeholder = field.locked ? field.effective : f.placeholder;
input = TextField(
controller: _controllers[f.key],
obscureText: isPassword,
@@ -2111,7 +2312,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
border: const OutlineInputBorder(),
isDense: true,
counterText: "",
hintText: showDots ? "••••••••" : f.placeholder,
hintText: showDots ? "••••••••" : placeholder,
),
);
}
+193
View File
@@ -0,0 +1,193 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "../format.dart";
/// A time box that reads on the clock the user chose.
///
/// The same problem the web app's `components/TimeField.vue` solves, and the
/// same shape of answer. Flutter's own `showTimePicker` renders on the *device's*
/// locale, which nothing in this app steers: a Settings → Time format of 24-hour
/// still met the account with an AM/PM dial, disagreeing with the 00:00 the card
/// beside it printed. So the typing is ours — four digits, masked into the clock
/// in force, with the meridiem as its own control rather than something to be
/// spelled.
///
/// The value in and out is always 24-hour "HH:MM", which is what the charger's
/// schedule commands take and what every caller already had. A half-typed time
/// emits "" — a half-typed time is not a time, and emitting the part of it that
/// parses would set the charger's schedule to whatever was passed through on the
/// way to the value somebody meant.
class TimeField extends StatefulWidget {
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
final String? label;
const TimeField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
this.label,
});
@override
State<TimeField> createState() => _TimeFieldState();
}
class _TimeFieldState extends State<TimeField> {
final _controller = TextEditingController();
bool _pm = false;
bool _twelve = false;
@override
void initState() {
super.initState();
_twelve = clockIsTwelveHour();
_controller.text = _toText(widget.value);
_pm = _toPm(widget.value);
}
@override
void didUpdateWidget(TimeField old) {
super.didUpdateWidget(old);
// Switching the setting elsewhere re-lays out what is already in the box,
// rather than leaving one field on the old clock.
final twelve = clockIsTwelveHour();
if (twelve != _twelve) {
_twelve = twelve;
_controller.text = _toText(widget.value);
_pm = _toPm(widget.value);
return;
}
// Only re-render the box when the value it is showing is genuinely a
// different time. Half-typed input emits "" — there is no time yet — and
// reacting to that would wipe the very digits being typed.
if (_toValue(_controller.text, _pm) == widget.value) return;
_controller.text = _toText(widget.value);
_pm = _toPm(widget.value);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
static String _pad(int n) => n.toString().padLeft(2, "0");
/// "HH:MM" → its two numbers, or null for anything that is not a time of day.
static (int, int)? _parse(String value) {
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(value.trim());
if (m == null) return null;
final h = int.parse(m.group(1)!);
final min = int.parse(m.group(2)!);
return h > 23 || min > 59 ? null : (h, min);
}
/// The digits the box shows: the hour as this clock writes it, and the minute.
String _toText(String value) {
final p = _parse(value);
if (p == null) return "";
final h = _twelve ? (p.$1 % 12 == 0 ? 12 : p.$1 % 12) : p.$1;
return "${_pad(h)}:${_pad(p.$2)}";
}
/// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
/// where the box cannot say it and the toggle has to.
bool _toPm(String value) {
final p = _parse(value);
return p != null && p.$1 >= 12;
}
/// What the box and the toggle hold → "HH:MM", or "" while it is still half
/// typed.
String _toValue(String text, bool pm) {
final digits = text.replaceAll(RegExp(r"\D"), "");
if (digits.length != 4) return "";
var h = int.parse(digits.substring(0, 2));
final min = int.parse(digits.substring(2));
if (min > 59) return "";
if (_twelve) {
if (h < 1 || h > 12) return "";
h = (h % 12) + (pm ? 12 : 0);
} else if (h > 23) {
return "";
}
return "${_pad(h)}:${_pad(min)}";
}
void _onChanged(String raw) {
// The formatter below has already regrouped the digits; this only reports
// what they now mean.
widget.onChanged(_toValue(raw, _pm));
}
void _setPm(bool pm) {
setState(() => _pm = pm);
widget.onChanged(_toValue(_controller.text, pm));
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 76,
child: TextField(
controller: _controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [_ClockMask()],
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
counterText: "",
hintText: "--:--",
labelText: widget.label,
),
onChanged: _onChanged,
),
),
if (_twelve) ...[
const SizedBox(width: 6),
// A toggle rather than a dropdown: two values, and the one not chosen
// is the only other answer there is.
SegmentedButton<bool>(
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
showSelectedIcon: false,
segments: const [
ButtonSegment(value: false, label: Text("am")),
ButtonSegment(value: true, label: Text("pm")),
],
selected: {_pm},
onSelectionChanged: widget.enabled ? (s) => _setPm(s.first) : null,
),
],
],
);
}
}
/// Digits regrouped as hh:mm as they are typed. No trailing colon: it appears
/// with the next digit, and adding it early only gives backspace something to
/// fight with.
class _ClockMask extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue _, TextEditingValue next) {
var digits = next.text.replaceAll(RegExp(r"\D"), "");
if (digits.length > 4) digits = digits.substring(0, 4);
final text =
digits.length > 2 ? "${digits.substring(0, 2)}:${digits.substring(2)}" : digits;
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
}
+2
View File
@@ -83,6 +83,8 @@ void main() {
// Named by their own headings, so the sheet reads like the page.
const cardLabels = {
"control": "charging.control.title",
"rfid": "charging.rfid.title",
"settings": "charging.modbus.settingsTitle",
"connection": "charging.control.connectionTitle",
"readings": "charging.modbus.title",
"info": "charging.info.title",
+50
View File
@@ -316,6 +316,27 @@ export const api = {
}),
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }),
// The home-charger scheduler: one list of charging tasks per user, covering
// every charger they own. The server holds the clock — a schedule that only
// fires while this page is open would be a reminder, not a schedule — so the
// page only writes tasks and reads back how each one last went.
// runChargingStep fires one step now, whatever its time and whether or not
// the task it belongs to is switched on.
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
createChargingTask: (body) =>
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
updateChargingTask: (id, body) =>
request(`/charging-tasks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(body),
}).then((r) => r.task),
deleteChargingTask: (id) => request(`/charging-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
// One step of a flow, fired now: running a start and the stop that closes it
// back to back would leave the charger where it began and prove nothing.
runChargingStep: (id, step) =>
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/run`,
{ method: "POST" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
@@ -335,6 +356,35 @@ export const api = {
getAnkerChargerDetails: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`),
// The RFID cards on one charger — the only calls in this client that change
// anything on the Anker account. Anker documents neither endpoint, so the
// server infers the request and then reads the list back: both of these answer
// with {present, cards}, and it is the list that says what happened, not the
// status code.
saveAnkerRfidCard: (sn, cardNumber, cardName) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards`, {
method: "POST",
body: JSON.stringify({ cardNumber, cardName }),
}),
// Opens the charger's own card reader and waits for a tap — the request is in
// flight for the whole twenty-second window, and answers whether or not a card
// arrived.
scanAnkerRfidCard: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/scan`, {
method: "POST",
}),
deleteAnkerRfidCard: (sn, cardNumber) =>
request(
`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`,
{ method: "DELETE" }
),
// The list the charger itself holds, asked of the device rather than of the
// account. Both are written by every add and remove, and they can still come
// apart; this is the only call that says so. Answers with {cards} — bare
// numbers, because the device has no field for a card's name.
getAnkerChargerCards: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/charger`),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
// status, and a live status snapshot — an OCPP session snapshot in own/proxy
@@ -0,0 +1,272 @@
<script setup>
// One line of the home-charger scheduler, being written or edited.
//
// The charger's own cloud schedule asks four questions and asks them inside one
// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
// makes it a scheduler rather than a second copy of that: *which* chargers. A
// task can name one, several, or none at all — and none means every charger on
// the account, including ones imported after the task was written, because "all
// of them" is a standing wish rather than the list that happened to exist that
// day.
//
// A task holds a flow rather than a single command: start at 23:00, ease down to
// 10 A at 01:00, stop at 06:30. That is one intention, so it is one named thing
// with one switch — splitting a charging window across two tasks meant naming it
// twice and remembering to switch off both ends.
import { ref, computed, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import { weekdaysInOrder, weekdayShortName } from "../lib/format.js";
import Modal from "./Modal.vue";
import TimeField from "./TimeField.vue";
const props = defineProps({
// The task being edited, or null to write a new one.
task: { type: Object, default: null },
// The chargers this account has, as the Charging page already loaded them.
chargers: { type: Array, default: () => [] },
});
const emit = defineEmits(["saved", "close"]);
const editing = computed(() => !!props.task?.id);
const name = ref(props.task?.name || "");
// The flow, as rows the form edits in place. A new task opens with the one step
// most schedules start from, so the common case is a name and a time rather than
// a decision about how many rows to add.
const steps = ref(
(props.task?.steps || []).length
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
: [{ action: "start", time: "23:00", amps: 16 }]
);
// A flow of one is a flow, so the last row cannot be removed — an empty task
// would have nothing to fire and the server refuses it anyway.
function addStep() {
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
}
function removeStep(i) {
if (steps.value.length <= 1) return;
steps.value = steps.value.filter((_, n) => n !== i);
}
// The chargers this task acts on. Empty is meaningful — it means all of them —
// so the picker has a switch of its own rather than leaving an empty list
// looking like an unfinished form.
const allChargers = ref(!props.task || (props.task.chargers || []).length === 0);
const picked = ref([...(props.task?.chargers || [])]);
const days = ref([...(props.task?.days || [])]);
const everyDay = ref(!props.task || (props.task.days || []).length === 0);
const saving = ref(false);
const error = ref("");
// What the charger can actually be asked to do. Boost and the current limit only
// reach it over the Anker cloud connection; start and stop reach it over all
// three transports. The control mode is a Settings choice, not this form's
// business, so all four are offered and the one that cannot be sent says so when
// it fires — same as the buttons on the page behind this.
const ACTIONS = ["start", "stop", "limit", "boost"];
// The row starts on whichever day this account reads a week as starting on —
// Settings Appearance First day of the week, following the region unless it
// was answered outright. A computed rather than a constant, so changing the
// setting in another tab re-lays the row out instead of leaving it on the old
// week. Both of these come from lib/format.js, which owns the rule for every
// weekday row in the app.
const weekdays = computed(() => weekdaysInOrder());
function toggleDay(day) {
everyDay.value = false;
days.value = days.value.includes(day)
? days.value.filter((d) => d !== day)
: [...days.value, day];
}
function toggleCharger(id) {
allChargers.value = false;
picked.value = picked.value.includes(id)
? picked.value.filter((x) => x !== id)
: [...picked.value, id];
}
// Unticking every day (or every charger) by hand is the same wish as the "all"
// switch, so it lands there rather than leaving a task that acts on nothing.
watch(days, (list) => {
if (list.length === 0) everyDay.value = true;
});
watch(picked, (list) => {
if (list.length === 0) allChargers.value = true;
});
watch(everyDay, (on) => {
if (on) days.value = [];
});
watch(allChargers, (on) => {
if (on) picked.value = [];
});
// A time still being typed is not a time — TimeField says so with an empty
// value — and one unfinished row is enough to make the whole flow unsaveable,
// because the server would otherwise refuse it with a step number the form does
// not show.
const canSave = computed(
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
);
function chargerSubtitle(c) {
return [c.serial, c.model].filter(Boolean).join(" · ");
}
async function submit() {
if (!canSave.value) return;
saving.value = true;
error.value = "";
const body = {
name: name.value.trim(),
// The amps ride along on every step so switching one to "limit" and back
// does not lose the number that was typed; the server keeps them for the
// same reason and ignores them on the actions that have no ceiling.
steps: steps.value.map((s) => ({
action: s.action,
time: s.time,
amps: s.action === "limit" ? Number(s.amps) || 0 : 0,
})),
chargers: allChargers.value ? [] : [...picked.value],
days: everyDay.value ? [] : [...days.value],
// The time is a wall clock, and the server's is not the one it was set by.
// Sending the zone the browser is in is what keeps 23:00 at 23:00 for a
// server sitting in another country.
zone: browserZone(),
};
try {
const task = editing.value
? await api.updateChargingTask(props.task.id, body)
: await api.createChargingTask(body);
emit("saved", task);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function browserZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
return "";
}
}
</script>
<template>
<Modal
:title="editing ? t('forms.chargingTask.editTitle') : t('forms.chargingTask.title')"
@close="emit('close')"
>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="dh-label">{{ t("forms.chargingTask.name") }}</label>
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
</div>
<!-- The flow. One row per step, each an action and the time it fires
read down, they are the night: start at 23:00, ease off at 01:00, stop
at 06:30. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
<div class="flex items-start gap-2">
<select v-model="s.action" class="dh-input min-w-0 flex-1">
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
</select>
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
<!-- The last step cannot go: a task with no steps has nothing to
fire, so the control is absent rather than there and refusing. -->
<button
v-if="steps.length > 1"
type="button"
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
:title="t('forms.chargingTask.removeStep')"
@click="removeStep(i)"
>
&times;
</button>
</div>
<!-- The ceiling, under the one action that takes one. -->
<div v-if="s.action === 'limit'" class="mt-3">
<label class="dh-label">
{{ t("forms.chargingTask.amps") }}
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
</label>
<input v-model.number="s.amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
</div>
</div>
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
{{ t("forms.chargingTask.addStep") }}
</button>
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
</div>
<!-- Which chargers. The point of one scheduler for all of them. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.chargers") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="allChargers" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.allChargers") }}</span>
</label>
<p v-if="allChargers" class="px-2 pb-1 text-xs text-muted">{{ t("forms.chargingTask.allChargersHint") }}</p>
<p v-if="chargers.length === 0" class="px-2 text-xs text-muted">{{ t("forms.chargingTask.noChargers") }}</p>
<label
v-for="c in chargers"
:key="c.id"
class="flex cursor-pointer items-center gap-2 rounded-control p-2 transition-colors hover:bg-sunken"
>
<input type="checkbox" :checked="!allChargers && picked.includes(c.id)" @change="toggleCharger(c.id)" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-strong">{{ c.name }}</span>
<span v-if="chargerSubtitle(c)" class="data block truncate text-[11px] text-muted">{{ chargerSubtitle(c) }}</span>
</span>
</label>
</div>
<!-- Which days. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.days") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="everyDay" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.everyDay") }}</span>
</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<button
v-for="d in weekdays"
:key="d"
type="button"
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
:class="!everyDay && days.includes(d)
? 'border-accent bg-brand-100 text-strong'
: 'border-subtle text-muted hover:bg-sunken'"
@click="toggleDay(d)"
>
{{ weekdayShortName(d) }}
</button>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="!canSave" class="dh-btn dh-btn-primary">
{{ saving ? t("common.saving") : t("common.save") }}
</button>
</div>
</form>
</Modal>
</template>
+147
View File
@@ -0,0 +1,147 @@
<script setup>
// A time box that reads on the clock the user chose.
//
// The same problem DateField solves for dates, and the same shape of answer.
// `<input type="time">` renders in the *browser's* locale and nothing on the
// page moves it — `lang` included, which was measured rather than assumed: the
// control comes out identically wide whatever it is set to, because it follows
// navigator.languages and not the document. So a Settings → Time format of
// 24-hour still met this account with "12:00 AM" in every schedule field,
// disagreeing with the 00:00 the card beside it printed, and the browser's am/pm
// did not fit the box either.
//
// So the typing half is ours: four digits, masked into the clock in force, with
// the meridiem as its own control rather than something to be spelled. There is
// no picker half — a calendar earns its button, four digits do not, and the
// browser's time popup would have brought the same 12-hour reading back with it.
//
// The value in and out is always 24-hour "HH:MM", which is what the charger's
// schedule commands take and what every caller already had.
import { computed, ref, watch } from "vue";
import { clockIsTwelveHour } from "../lib/format.js";
const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue"]);
const twelve = computed(() => clockIsTwelveHour());
const pad = (n) => String(n).padStart(2, "0");
// "HH:MM" → its two numbers, or null for anything that is not a time of day.
function parse(value) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
return h > 23 || min > 59 ? null : { h, min };
}
// The digits the box shows: the hour as this clock writes it, and the minute.
function toText(value) {
const p = parse(value);
if (!p) return "";
return `${pad(twelve.value ? p.h % 12 || 12 : p.h)}:${pad(p.min)}`;
}
// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
// where the box cannot say it and the select has to.
function toPm(value) {
const p = parse(value);
return !!p && p.h >= 12;
}
// What the box and the select hold → "HH:MM", or "" while it is still half
// typed. A half-typed time is not a time, and emitting the part of it that
// parses would set the charger's schedule to whatever was passed through on the
// way to the value somebody meant.
function toValue(text, pm) {
const digits = String(text).replace(/\D/g, "");
if (digits.length !== 4) return "";
let h = Number(digits.slice(0, 2));
const min = Number(digits.slice(2));
if (min > 59) return "";
if (twelve.value) {
if (h < 1 || h > 12) return "";
h = (h % 12) + (pm ? 12 : 0);
} else if (h > 23) {
return "";
}
return `${pad(h)}:${pad(min)}`;
}
// Digits regrouped as hh:mm. No trailing colon: it appears with the next digit,
// and adding it early only gives backspace something to fight with.
function mask(text) {
const digits = String(text).replace(/\D/g, "").slice(0, 4);
return digits.length > 2 ? `${digits.slice(0, 2)}:${digits.slice(2)}` : digits;
}
const text = ref(toText(props.modelValue));
const pm = ref(toPm(props.modelValue));
// Only re-render the box when the value it is showing is genuinely a different
// time. Half-typed input emits "" — there is no time yet — and reacting to that
// would wipe the very digits being typed.
watch(
() => props.modelValue,
(value) => {
if (toValue(text.value, pm.value) === (value || "")) return;
text.value = toText(value);
pm.value = toPm(value);
}
);
// Switching the setting in another tab re-lays out what is already in the box,
// rather than leaving one field on the old clock.
watch(twelve, () => {
text.value = toText(props.modelValue);
pm.value = toPm(props.modelValue);
});
function onInput(event) {
const el = event.target;
const masked = mask(el.value);
text.value = masked;
// Written straight to the DOM: Vue skips the patch when the bound value is
// unchanged from the last render, which would leave a stray character the
// user just typed sitting in the box.
el.value = masked;
emit("update:modelValue", toValue(masked, pm.value));
}
function onMeridiem(event) {
pm.value = event.target.value === "pm";
emit("update:modelValue", toValue(text.value, pm.value));
}
</script>
<template>
<span class="flex items-center gap-1">
<input
type="text"
inputmode="numeric"
autocomplete="off"
class="dh-input data w-20 shrink-0 text-center"
:value="text"
:disabled="disabled"
:aria-label="ariaLabel"
placeholder="--:--"
maxlength="5"
@input="onInput"
/>
<select
v-if="twelve"
class="dh-input w-16 shrink-0"
:value="pm ? 'pm' : 'am'"
:disabled="disabled"
:aria-label="ariaLabel"
@change="onMeridiem"
>
<option value="am">am</option>
<option value="pm">pm</option>
</select>
</span>
</template>
+138 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
"public": "Offentlige ladere",
"home": "Hjemmeladere"
"home": "Hjemmeladere",
"scheduler": "Planlægning af hjemmelader"
},
"liveMap": "Live-kort",
"youAreHere": "Du er her",
@@ -64,6 +65,33 @@
"idle": "Oplader ikke",
"idleHint": "Tilslut ved en station for at starte en session."
},
"rfid": {
"title": "RFID-kortindstillinger",
"none": "Ingen kort er godkendt til denne lader.",
"unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.",
"add": "Tilføj kort",
"tap": "Hold kortet mod laderen",
"tapping": "Hold kortet mod læseren… {n}s",
"tapHint": "Læseren er åben. Hold kortet mod laderen.",
"tapSave": "Hold kortet mod laderen, og tilføj det",
"tapSaveHint": "Tilføjer kortet, så snart det holdes mod læseren, med navnet RFID og kortets sidste fire cifre.",
"tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.",
"addTitle": "Tilføj et kort",
"remove": "Fjern",
"removeConfirm": "Fjern {name} fra denne lader?",
"numberPlaceholder": "Kortnummer",
"namePlaceholder": "Navn (valgfrit)",
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
"readCharger": "Læs laderens egen liste",
"chargerTitle": "På selve laderen",
"chargerNone": "Laderen har ingen kort.",
"chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.",
"driftTitle": "De to lister er ikke enige",
"onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}",
"onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}",
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
},
"stations": {
"heading": "I nærheden",
"homeHeading": "Dine ladere",
@@ -129,6 +157,18 @@
"timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.",
"settingsReported": "Rapporteret, kan ikke indstilles",
"settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.",
"blockCharging": "Opladning",
"blockSchedule": "Tidsplan",
"blockBalancing": "Belastningsbalancering",
"blockSolar": "Sol",
"blockPanel": "Panel og lys",
"blockLocal": "Lokalt netværk",
"windowStart": "Start",
"windowEnd": "Slut",
"reset": "Fortryd",
"cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.",
"cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.",
"modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.",
"device": "Enhed",
"alarms": "Alarmer",
"phase": "Fase",
@@ -273,6 +313,45 @@
"relatedBy": "Kan nås via",
"timeZone": "Tidszone",
"linked": "Tilknyttet",
"nickname": "Kaldenavn",
"productCode": "Produktkode",
"deviceType": "Enhedstype",
"charging": "Oplader",
"statusCode": "Statuskode",
"ocppLink": "OCPP-forbindelse",
"wifiOnline": "Wi-Fi forbundet",
"bleId": "Bluetooth-id",
"blePassword": "Bluetooth-parringskode",
"ownerId": "Ejer-id",
"fields": {
"email": "E-mail",
"serial": "Serienummer",
"memberId": "Medlems-id",
"memberType": "Medlemstype",
"userId": "Bruger-id",
"status": "Status",
"inviteLimit": "Invitationsgrænse",
"sessions": "Sessioner",
"chargeTime": "Opladningstid",
"energy": "Opladet energi",
"co2Saved": "CO2 sparet",
"cost": "Omkostning",
"costSaved": "Sparet beløb",
"currency": "Valuta",
"mileage": "Kilometertal",
"page": "Side",
"perPage": "Pr. side",
"records": "Poster",
"from": "Fra",
"source": "Kilde",
"timeZone": "Tidszone",
"updated": "Opdateret",
"added": "Tilføjet",
"address": "Adresse",
"name": "Navn",
"cardName": "Kortnavn",
"cardNumber": "Kortnummer"
},
"groups": {
"device": "Enhed",
"status": "Status",
@@ -307,6 +386,33 @@
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
},
"scheduler": {
"title": "Ladeopgaver",
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
"add": "Ny opgave",
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
"allChargers": "Alle ladere",
"missingChargers": "Ingen lader på kontoen længere",
"everyDay": "Hver dag",
"runNow": "Kør nu",
"running": "Sender…",
"lastRun": "Sidst kørt {when}",
"noResult": "intet resultat registreret",
"toggleHint": "Om uret udløser denne opgave.",
"removeConfirm": "Slet opgaven “{name}”?",
"taskCount": {
"one": "{n} opgave",
"other": "{n} opgaver"
},
"actions": {
"start": "Start opladning",
"stop": "Stop opladning",
"limit": "Sæt strømgrænse",
"boost": "Boost sessionen"
}
}
},
@@ -444,6 +550,16 @@
"regionHint": "Tal- og valutaformat.",
"dateFormat": "Datoformat",
"dateExample": "Eksempel: {example}",
"timeFormat": "Tidsformat",
"timeAuto": "Følg regionen",
"time24": "24-timers",
"time12": "12-timers",
"timeExample": "Eksempel: {example}",
"weekStart": "Første dag i ugen",
"weekAuto": "Følg regionen",
"weekMonday": "Mandag",
"weekSunday": "Søndag",
"weekExample": "Eksempel: {example}",
"currency": "Valuta",
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
"fontSize": "Skriftstørrelse",
@@ -510,6 +626,8 @@
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen.",
"controlModesHidden": "Skjul for dine brugere",
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
@@ -1156,6 +1274,25 @@
"submit": "Del",
"peopleWithAccess": "Personer med adgang",
"notShared": "Endnu ikke delt med nogen."
},
"chargingTask": {
"title": "Ny ladeopgave",
"editTitle": "Rediger ladeopgave",
"name": "Navn",
"namePlaceholder": "Nattakst",
"time": "Kl.",
"flow": "Forløbet",
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
"addStep": "+ Tilføj et trin",
"removeStep": "Fjern dette trin",
"amps": "Strømgrænse",
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
"chargers": "På disse ladere",
"allChargers": "Alle mine ladere",
"allChargersHint": "Inklusive ladere du importerer senere.",
"noChargers": "Ingen ladere på kontoen endnu.",
"days": "På disse dage",
"everyDay": "Hver dag"
}
},
+138 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Drag a tab to rearrange the charging tabs.",
"public": "Public chargers",
"home": "Home chargers"
"home": "Home chargers",
"scheduler": "Home charger scheduler"
},
"liveMap": "Live map",
"youAreHere": "You are here",
@@ -115,6 +116,18 @@
"timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.",
"settingsReported": "Reported, not settable",
"settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.",
"blockCharging": "Charging",
"blockSchedule": "Schedule",
"blockBalancing": "Load balancing",
"blockSolar": "Solar",
"blockPanel": "Panel and light",
"blockLocal": "Local network",
"windowStart": "Start",
"windowEnd": "End",
"reset": "Undo",
"cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.",
"cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.",
"modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.",
"device": "Device",
"alarms": "Alarms",
"phase": "Phase",
@@ -259,6 +272,45 @@
"relatedBy": "Reachable by",
"timeZone": "Time zone",
"linked": "Linked",
"nickname": "Nickname",
"productCode": "Product code",
"deviceType": "Device type",
"charging": "Charging",
"statusCode": "Status code",
"ocppLink": "OCPP connection",
"wifiOnline": "Wi-Fi connected",
"bleId": "Bluetooth id",
"blePassword": "Bluetooth pairing code",
"ownerId": "Owner id",
"fields": {
"email": "Email",
"serial": "Serial",
"memberId": "Member id",
"memberType": "Member type",
"userId": "User id",
"status": "Status",
"inviteLimit": "Invite limit",
"sessions": "Sessions",
"chargeTime": "Time charging",
"energy": "Energy charged",
"co2Saved": "CO2 saved",
"cost": "Cost",
"costSaved": "Cost saved",
"currency": "Currency",
"mileage": "Mileage",
"page": "Page",
"perPage": "Per page",
"records": "Records",
"from": "From",
"source": "Source",
"timeZone": "Time zone",
"updated": "Updated",
"added": "Added",
"address": "Address",
"name": "Name",
"cardName": "Card name",
"cardNumber": "Card number"
},
"groups": {
"device": "Device",
"status": "Status",
@@ -294,6 +346,33 @@
"rawTitle": "As the service reports it",
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed."
},
"rfid": {
"title": "RFID cards settings",
"none": "No cards are authorised on this charger.",
"unsupported": "The service this charger came from does not report RFID cards.",
"add": "Add card",
"tap": "Tap card at the charger",
"tapping": "Hold the card against the reader… {n}s",
"tapHint": "The reader is open. Hold the card against the charger.",
"tapSave": "Tap card and add it",
"tapSaveHint": "Adds the card as soon as it is tapped, named RFID and its last four digits.",
"tapNone": "No card was tapped before the reader closed.",
"addTitle": "Add a card",
"remove": "Remove",
"removeConfirm": "Remove {name} from this charger?",
"numberPlaceholder": "Card number",
"namePlaceholder": "Name (optional)",
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
"notRemoved": "The service took the request, but the card is still on the charger.",
"readCharger": "Read the charger's own list",
"chargerTitle": "On the charger itself",
"chargerNone": "The charger holds no cards.",
"chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.",
"driftTitle": "The two lists disagree",
"onlyOnCharger": "Opens the charger but is not on the account: {cards}",
"onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}",
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
},
"stations": {
"heading": "Nearby",
"homeHeading": "Your chargers",
@@ -306,6 +385,33 @@
"full": "Full",
"offPeak": "off-peak",
"homeCharger": "Home charger"
},
"scheduler": {
"title": "Charging tasks",
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
"add": "New task",
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
"allChargers": "All chargers",
"missingChargers": "No charger on your account any more",
"everyDay": "Every day",
"runNow": "Run now",
"running": "Sending…",
"lastRun": "Last run {when}",
"noResult": "no result recorded",
"toggleHint": "Whether the clock fires this task.",
"removeConfirm": "Delete the task “{name}”?",
"taskCount": {
"one": "{n} task",
"other": "{n} tasks"
},
"actions": {
"start": "Start charging",
"stop": "Stop charging",
"limit": "Set current limit",
"boost": "Boost the session"
}
}
},
@@ -443,6 +549,16 @@
"regionHint": "Number and currency layout.",
"dateFormat": "Date format",
"dateExample": "Example: {example}",
"timeFormat": "Time format",
"timeAuto": "Follow the region",
"time24": "24-hour",
"time12": "12-hour",
"timeExample": "Example: {example}",
"weekStart": "First day of the week",
"weekAuto": "Follow the region",
"weekMonday": "Monday",
"weekSunday": "Sunday",
"weekExample": "Example: {example}",
"currency": "Currency",
"currencyExample": "Example: {example} — display only, no amounts are converted.",
"fontSize": "Font size",
@@ -509,6 +625,8 @@
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
"controlMode": "Control mode",
"controlModeHint": "How DriverVault controls the charger.",
"controlModesHidden": "Hide from your users",
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
"controlOff": "Off (monitoring only)",
"controlOwn": "Own CSMS (full control)",
"controlProxy": "Proxy CSMS (relay + control)",
@@ -1155,6 +1273,25 @@
"submit": "Share",
"peopleWithAccess": "People with access",
"notShared": "Not shared with anyone yet."
},
"chargingTask": {
"title": "New charging task",
"editTitle": "Edit charging task",
"name": "Name",
"namePlaceholder": "Night rate",
"time": "At",
"flow": "The flow",
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
"addStep": "+ Add a step",
"removeStep": "Remove this step",
"amps": "Current limit",
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
"chargers": "On these chargers",
"allChargers": "All my chargers",
"allChargersHint": "Including any charger you import later.",
"noChargers": "No chargers on your account yet.",
"days": "On these days",
"everyDay": "Every day"
}
},
+140 -1
View File
@@ -50,7 +50,8 @@
"tabs": {
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
"home": "Ładowarki domowe",
"scheduler": "Harmonogram ładowarki"
},
"liveMap": "Mapa na żywo",
"youAreHere": "Tu jesteś",
@@ -64,6 +65,33 @@
"idle": "Brak ładowania",
"idleHint": "Podłącz na stacji, aby rozpocząć sesję."
},
"rfid": {
"title": "Ustawienia kart RFID",
"none": "Na tej ładowarce nie autoryzowano żadnej karty.",
"unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.",
"add": "Dodaj kartę",
"tap": "Przyłóż kartę do ładowarki",
"tapping": "Przytrzymaj kartę przy czytniku… {n}s",
"tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.",
"tapSave": "Przyłóż kartę i dodaj ją",
"tapSaveHint": "Dodaje kartę zaraz po przyłożeniu, pod nazwą RFID i cztery ostatnie znaki numeru.",
"tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.",
"addTitle": "Dodaj kartę",
"remove": "Usuń",
"removeConfirm": "Usunąć {name} z tej ładowarki?",
"numberPlaceholder": "Numer karty",
"namePlaceholder": "Nazwa (opcjonalnie)",
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
"readCharger": "Odczytaj własną listę ładowarki",
"chargerTitle": "Na samej ładowarce",
"chargerNone": "Ładowarka nie ma żadnych kart.",
"chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.",
"driftTitle": "Obie listy się nie zgadzają",
"onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}",
"onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}",
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
},
"stations": {
"heading": "W pobliżu",
"homeHeading": "Twoje ładowarki",
@@ -131,6 +159,18 @@
"timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.",
"settingsReported": "Raportowane, nieustawialne",
"settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.",
"blockCharging": "Ładowanie",
"blockSchedule": "Harmonogram",
"blockBalancing": "Balansowanie obciążenia",
"blockSolar": "Fotowoltaika",
"blockPanel": "Panel i podświetlenie",
"blockLocal": "Sieć lokalna",
"windowStart": "Początek",
"windowEnd": "Koniec",
"reset": "Cofnij",
"cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.",
"cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.",
"modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.",
"device": "Urządzenie",
"alarms": "Alarmy",
"phase": "Faza",
@@ -275,6 +315,45 @@
"relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa",
"linked": "Powiązano",
"nickname": "Nazwa własna",
"productCode": "Kod produktu",
"deviceType": "Typ urządzenia",
"charging": "Ładowanie",
"statusCode": "Kod statusu",
"ocppLink": "Połączenie OCPP",
"wifiOnline": "Wi-Fi połączone",
"bleId": "Identyfikator Bluetooth",
"blePassword": "Kod parowania Bluetooth",
"ownerId": "Identyfikator właściciela",
"fields": {
"email": "E-mail",
"serial": "Numer seryjny",
"memberId": "Identyfikator członka",
"memberType": "Typ członka",
"userId": "Identyfikator użytkownika",
"status": "Status",
"inviteLimit": "Limit zaproszeń",
"sessions": "Sesje",
"chargeTime": "Czas ładowania",
"energy": "Naładowana energia",
"co2Saved": "Oszczędność CO2",
"cost": "Koszt",
"costSaved": "Oszczędność kosztów",
"currency": "Waluta",
"mileage": "Przebieg",
"page": "Strona",
"perPage": "Na stronę",
"records": "Rekordy",
"from": "Od",
"source": "Źródło",
"timeZone": "Strefa czasowa",
"updated": "Zaktualizowano",
"added": "Dodano",
"address": "Adres",
"name": "Nazwa",
"cardName": "Nazwa karty",
"cardNumber": "Numer karty"
},
"groups": {
"device": "Urządzenie",
"status": "Status",
@@ -309,6 +388,35 @@
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
},
"scheduler": {
"title": "Zadania ładowania",
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
"add": "Nowe zadanie",
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
"allChargers": "Wszystkie ładowarki",
"missingChargers": "Nie ma już takiej ładowarki na koncie",
"everyDay": "Codziennie",
"runNow": "Uruchom teraz",
"running": "Wysyłanie…",
"lastRun": "Ostatnio {when}",
"noResult": "brak zapisanego wyniku",
"toggleHint": "Czy zegar uruchamia to zadanie.",
"removeConfirm": "Usunąć zadanie „{name}”?",
"taskCount": {
"one": "{n} zadanie",
"few": "{n} zadania",
"many": "{n} zadań",
"other": "{n} zadania"
},
"actions": {
"start": "Rozpocznij ładowanie",
"stop": "Zatrzymaj ładowanie",
"limit": "Ustaw limit prądu",
"boost": "Przyspiesz sesję"
}
}
},
@@ -448,6 +556,16 @@
"regionHint": "Format liczb i waluty.",
"dateFormat": "Format daty",
"dateExample": "Przykład: {example}",
"timeFormat": "Format godziny",
"timeAuto": "Jak w regionie",
"time24": "24-godzinny",
"time12": "12-godzinny",
"timeExample": "Przykład: {example}",
"weekStart": "Pierwszy dzień tygodnia",
"weekAuto": "Zgodnie z regionem",
"weekMonday": "Poniedziałek",
"weekSunday": "Niedziela",
"weekExample": "Przykład: {example}",
"currency": "Waluta",
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
"fontSize": "Rozmiar czcionki",
@@ -514,6 +632,8 @@
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
"controlModesHidden": "Ukryj przed użytkownikami",
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
@@ -1170,6 +1290,25 @@
"submit": "Udostępnij",
"peopleWithAccess": "Osoby z dostępem",
"notShared": "Jeszcze nikomu nie udostępniono."
},
"chargingTask": {
"title": "Nowe zadanie ładowania",
"editTitle": "Edytuj zadanie ładowania",
"name": "Nazwa",
"namePlaceholder": "Taryfa nocna",
"time": "O godzinie",
"flow": "Przebieg",
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
"addStep": "+ Dodaj krok",
"removeStep": "Usuń ten krok",
"amps": "Limit prądu",
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"chargers": "Na tych ładowarkach",
"allChargers": "Wszystkie moje ładowarki",
"allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.",
"noChargers": "Na koncie nie ma jeszcze ładowarek.",
"days": "W te dni",
"everyDay": "Codziennie"
}
},
+145 -2
View File
@@ -76,8 +76,151 @@ export function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d)) return "—";
const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" });
return `${formatDate(value)} ${time}`;
return `${formatDate(value)} ${formatTime(d)}`;
}
// The clock alone. "auto" leaves the reading to the region, which is what every
// time in the app said before there was a setting; the other two are for the
// people whose region and habit disagree — plenty of Poles read 12-hour clocks
// and plenty of Americans read 24-hour ones, and the region picker also decides
// how money and numbers are grouped, so it is the wrong lever to reach for.
//
// hourCycle rather than hour12: with hour12:false the en-US formatter prints
// midnight as 24:00.
// The setting decides *which* clock; this file decides how it is punctuated.
//
// That split is the whole of it. A region is worth asking whether a reader
// expects 13:45 or 01:45 pm — that is a real difference in how people tell the
// time. It is not worth asking whether the two numbers are joined by a colon or
// a dot: Danish writes 13.45, and one screen of DriverVault writing 13.45 while
// the next writes 13:45 is not local colour, it is an inconsistency. So every
// time this app prints comes out of the same two lines below, and the region is
// asked one question only, under "auto".
//
// The cost is that the am/pm marker reads in English everywhere. It is the same
// trade the setting itself makes: a 12-hour clock is not a convention most of
// these regions use, so choosing one — or living in a region that does — is
// choosing the clock that comes with it.
export function formatTime(value) {
if (!value) return "—";
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d)) return "—";
const h = d.getHours();
const pad = (n) => String(n).padStart(2, "0");
const mm = pad(d.getMinutes());
if (!clockIsTwelveHour()) return `${pad(h)}:${mm}`;
// 12 for both noon and midnight, and midnight is the am one.
return `${pad(h % 12 || 12)}:${mm} ${h < 12 ? "am" : "pm"}`;
}
// Whether times are written on a 12-hour clock right now: what the setting says
// outright, or what the region says when it is left on auto.
//
// Exported because printing a time is not the only thing that has to know. A
// control that lets somebody *enter* one has to offer the same clock, and a box
// that reads 13:45 beside a picker that says 01:45 PM is the disagreement this
// setting exists to end (see components/TimeField.vue).
export function clockIsTwelveHour() {
const mode = prefs.timeFormat;
if (mode === "12") return true;
if (mode === "24") return false;
return regionReadsTwelveHour();
}
// Whether the chosen region tells the time on a 12-hour clock — the one question
// "auto" asks it. Cached because this is asked once per timestamp on a page that
// can hold a great many, and the answer only changes when the region does.
const twelveHourRegions = new Map();
function regionReadsTwelveHour() {
const locale = prefs.locale || "";
if (!twelveHourRegions.has(locale)) {
let twelve = false;
try {
const cycle = new Intl.DateTimeFormat(locale || undefined, { hour: "numeric" })
.resolvedOptions().hourCycle;
twelve = cycle === "h11" || cycle === "h12";
} catch {
// An unusable locale is not a reason to print nothing; 24-hour is the
// safer default, being the one that cannot be read as the wrong half of
// the day.
}
twelveHourRegions.set(locale, twelve);
}
return twelveHourRegions.get(locale);
}
// --- Weekdays --------------------------------------------------------------
//
// A week does not start on the same day everywhere: Monday across most of
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
// that always begins on Sunday reads wrong to half the people looking at it,
// and reads wrong in a way that is easy to misclick — Settings Appearance
// First day of the week is the answer, with "auto" following the chosen region
// the way the clock setting does.
//
// Everything that lays weekdays out in a row goes through these two, so there
// is one answer to "which day comes first" rather than one per screen. Days are
// numbered the way Date.getDay() and the scheduler's stored tasks number them:
// 0 = Sunday … 6 = Saturday.
// Whether weeks are drawn as starting on Monday right now: what the setting
// says outright, or what the region says when it is left on auto.
export function weekStartsOnMonday() {
const mode = prefs.weekStart;
if (mode === "monday") return true;
if (mode === "sunday") return false;
return regionStartsOnMonday();
}
// The one question "auto" asks the region. Cached per locale like the clock's,
// and for the same reason — it is asked once per weekday button.
const mondayRegions = new Map();
function regionStartsOnMonday() {
const locale = prefs.locale || "";
if (!mondayRegions.has(locale)) {
// ISO 8601 numbers the days 1 = Monday … 7 = Sunday, which is what weekInfo
// reports. Browsers expose it as a method on some engines and a property on
// others, hence both.
let monday = true;
try {
const info = new Intl.Locale(locale || "en-US");
const first = (info.getWeekInfo?.() || info.weekInfo)?.firstDay;
if (first) monday = first === 1;
} catch {
// An engine without week information, or an unusable locale. Monday is
// the safer default: it is ISO 8601's, and the convention in every region
// this app's own currency list covers bar one.
}
mondayRegions.set(locale, monday);
}
return mondayRegions.get(locale);
}
// The seven days in the order they should be drawn, as day numbers.
export function weekdaysInOrder() {
return weekStartsOnMonday() ? [1, 2, 3, 4, 5, 6, 0] : [0, 1, 2, 3, 4, 5, 6];
}
// One day's short name in the user's own language, so a row reads Pn Wt Śr in
// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
// sits, so the offset lands each number on its own day.
export function weekdayShortName(day) {
try {
return new Intl.DateTimeFormat(prefs.locale || undefined, { weekday: "short", timeZone: "UTC" })
.format(new Date(Date.UTC(2024, 0, 7 + day)));
} catch {
return String(day);
}
}
// A set of days, listed in the order this account reads a week in — so the same
// three days always come out in the same order wherever they are shown.
export function sortWeekdays(days) {
const order = weekdaysInOrder();
return [...(days || [])].sort((a, b) => order.indexOf(a) - order.indexOf(b));
}
// Every number we render goes through here so the grouping separator follows
+3 -1
View File
@@ -10,7 +10,9 @@
import { prefs } from "../prefs.js";
export const CHARGING_TABS = ["public", "home"];
// The scheduler comes last by default: it acts on the chargers the tab before
// it lists, so it reads as the thing you set up once the chargers are there.
export const CHARGING_TABS = ["public", "home", "scheduler"];
// A car's tabs in their default order. Information sits second because the
// connected service, when there is one, is what you came to look at.
+7
View File
@@ -6,6 +6,11 @@ export const prefs = reactive({
theme: "system", // light | dark | system
locale: "en-US",
dateFormat: "YMD", // YMD | DMY | MDY
timeFormat: "auto", // auto (the region's own convention) | 24 | 12
// The day a week is drawn as starting on, wherever weekdays are laid out in a
// row — the charging scheduler's day picker today. See lib/format.js, which
// owns the rule so every such row reads the same.
weekStart: "auto", // auto (the region's own convention) | monday | sunday
currency: "USD", // ISO 4217 code
fontSize: "medium", // small | medium | large
// Holds every arrangement still: the garage, a car's tabs, its Information
@@ -56,6 +61,8 @@ export function applyProfilePrefs(profile) {
prefs.theme = profile.theme || "system";
prefs.locale = profile.locale || "en-US";
prefs.dateFormat = profile.dateFormat || "YMD";
prefs.timeFormat = profile.timeFormat || "auto";
prefs.weekStart = profile.weekStart || "auto";
prefs.currency = profile.currency || "USD";
prefs.fontSize = profile.fontSize || "medium";
prefs.dragLocked = !!profile.dragLocked;
+15
View File
@@ -361,6 +361,21 @@ body {
box-shadow: var(--shadow-focus);
}
/* Checkbox for the small multi-choice lists (hidden control modes, ...).
accent-color keeps the native control and its keyboard behaviour. */
.dh-checkbox {
width: 1rem;
height: 1rem;
flex: none;
accent-color: var(--accent);
cursor: pointer;
}
.dh-checkbox:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: 3px;
}
.dh-label {
display: block;
margin-bottom: 0.375rem;
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -41,13 +41,13 @@ async function submit() {
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<div>
<label class="dh-label">{{ t("login.email") }}</label>
<input v-model="email" type="email" required autocomplete="username" class="dh-input" />
<input v-model="email" type="email" required autocomplete="username" class="dh-input" placeholder="you@example.com" />
</div>
<div>
<label class="dh-label">{{ t("login.password") }}</label>
<div class="relative">
<input v-model="password" :type="showPassword ? 'text' : 'password'" required autocomplete="current-password"
class="dh-input pr-10" />
class="dh-input pr-10" placeholder="••••••••" />
<button type="button" @click="showPassword = !showPassword"
:aria-label="showPassword ? t('login.hidePassword') : t('login.showPassword')"
class="absolute inset-y-0 right-0 flex items-center px-3 text-muted transition-colors hover:text-strong">
+131 -14
View File
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from "vue-router";
import { api } from "../api";
import { state, isAdmin, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName }
from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
import { askConfirm } from "../lib/confirm.js";
@@ -189,7 +190,17 @@ function saveDefaultTab(surface, key) {
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
// Thirteen-something rather than now: an example at 09:00 reads the same in
// both conventions, which is the one time of day that cannot show the choice.
const timeFormatExample = computed(() => {
const d = new Date();
d.setHours(13, 45, 0, 0);
return formatTime(d);
});
const currencyExample = computed(() => formatMoney(1234.5));
// The week as this account will now see it drawn the clearest possible
// example, because the setting has no other visible effect on this page.
const weekStartExample = computed(() => weekdaysInOrder().map(weekdayShortName).join(" "));
// Language and region are two controls over the one stored BCP-47 locale, so
// the pair can be mixed freely (English in Poland, say) rather than being
@@ -329,6 +340,21 @@ async function saveBio() {
}
}
// What an inherited field shows when it is empty.
//
// A locked field is standing in for a value set above the caller, and the server
// has already decided which of those may be read: it sends the secrets back as
// dots and everything else in the clear. So the placeholder is that effective
// value the thing the field will actually use and the example is for the
// other case, an empty box waiting to be filled in.
//
// The example belongs only there. A country field placeholdered "DE" under the
// words "inherited from your organization" is not a hint, it is a wrong answer
// to the question the user is asking it: which country am I inheriting?
function inheritedPlaceholder(field, example = "") {
return field.locked ? field.effective || "" : example;
}
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
//
// The server resolves a superadmin org admin user cascade and returns, per
@@ -493,7 +519,7 @@ function showsIntegration(id) {
const anker = ref(null); // resolved view from the server
const ankerScope = ref("user"); // "user" | "org" (org admins only)
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off", controlModesDisabled: [] });
const ankerSaving = ref(false);
const ankerSaved = ref(false);
const ankerError = ref("");
@@ -522,17 +548,63 @@ function ankerSourceLabel(k) {
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
}
// Clamp a stored mode to what this scope still offers.
function ankerModeInScope(mode) {
const modes = ankerScopeData.value.controlModes;
if (!mode) return "off";
if (modes?.length && !modes.includes(mode)) return "off";
return mode;
}
function fillAnkerForm() {
const f = ankerScopeData.value.fields || {};
ankerForm.value = {
email: f.email?.locked ? "" : f.email?.own || "",
password: "",
country: f.country?.locked ? "" : f.country?.own || "",
// controlMode isn't secret, so show the effective value when it's locked.
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
// controlMode isn't secret, so show the effective value when it's locked. A
// mode the layers above have since hidden is no longer on offer, so the
// picker starts from monitoring-only rather than showing a dead choice.
controlMode: ankerModeInScope(
f.controlMode?.locked ? f.controlMode?.effective : f.controlMode?.own,
),
// Which modes this organization hides from its own users (org scope only).
controlModesDisabled: [...(ankerScopeData.value.controlModesDisabled || [])],
};
}
// The modes this scope may still pick: the server has already removed whatever
// the layers above hid. An empty list would leave the picker blank, so fall back
// to monitoring-only, which no layer can take away.
const ankerControlModeOptions = computed(() => {
const modes = ankerScopeData.value.controlModes;
return modes?.length ? modes : ["off"];
});
// The modes an org admin may hide from their users everything they can choose
// themselves, minus off, which is the fallback.
const ankerHideableModes = computed(() =>
ankerControlModeOptions.value.filter((m) => m !== "off"),
);
const ankerModeLabels = {
off: "controlOff",
mqtt: "controlCloud",
modbus: "controlModbus",
own: "controlOwn",
proxy: "controlProxy",
};
function ankerModeLabel(m) {
return t("settings.integrations." + (ankerModeLabels[m] || "controlOff"));
}
function ankerHidesMode(m) {
return ankerForm.value.controlModesDisabled.includes(m);
}
function toggleAnkerHiddenMode(m, on) {
const set = new Set(ankerForm.value.controlModesDisabled);
if (on) set.add(m);
else set.delete(m);
ankerForm.value.controlModesDisabled = ankerHideableModes.value.filter((v) => set.has(v));
}
// The effective control mode (off | mqtt | modbus | own | proxy) it gates the
// control panel.
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
@@ -691,6 +763,9 @@ async function saveAnkerSettings() {
if (k === "password" && !ankerForm.value.password) continue;
config[k] = ankerForm.value[k];
}
// The hide-list travels as the comma-separated string the server stores; only
// a layer with users under it has one.
if (ankerEditingOrg.value) config.controlModesDisabled = ankerForm.value.controlModesDisabled.join(",");
try {
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
ankerSaved.value = true;
@@ -1127,6 +1202,30 @@ onBeforeUnmount(() => {
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
</div>
<!-- Beside the date rather than under the region, because it is the
same question asked about the other half of a timestamp. -->
<div>
<label class="dh-label">{{ t("settings.appearance.timeFormat") }}</label>
<select :value="prefs.timeFormat" class="dh-input" @change="saveAppearance({ timeFormat: $event.target.value })">
<option value="auto">{{ t("settings.appearance.timeAuto") }}</option>
<option value="24">{{ t("settings.appearance.time24") }}</option>
<option value="12">{{ t("settings.appearance.time12") }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
</div>
<!-- Under the clock, as the last of the three questions a region is
asked and the one it is least often asked out loud. -->
<div>
<label class="dh-label">{{ t("settings.appearance.weekStart") }}</label>
<select :value="prefs.weekStart" class="dh-input" @change="saveAppearance({ weekStart: $event.target.value })">
<option value="auto">{{ t("settings.appearance.weekAuto") }}</option>
<option value="monday">{{ t("settings.appearance.weekMonday") }}</option>
<option value="sunday">{{ t("settings.appearance.weekSunday") }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
@@ -1542,7 +1641,7 @@ onBeforeUnmount(() => {
v-model="ankerForm.country"
class="dh-input"
:disabled="ankerLocked('country')"
placeholder="DE"
:placeholder="inheritedPlaceholder(ankerField('country'), 'DE')"
maxlength="2"
autocomplete="off"
/>
@@ -1552,15 +1651,33 @@ onBeforeUnmount(() => {
<div>
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
<option value="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</option>
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
<option v-for="m in ankerControlModeOptions" :key="m" :value="m">{{ ankerModeLabel(m) }}</option>
</select>
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
</div>
<!-- An org admin decides which of the modes left to them their own
users get to see. Modes the superadmin already hid are not in
the list at all, so they cannot be handed back. -->
<div v-if="ankerEditingOrg && ankerHideableModes.length">
<label class="dh-label">{{ t("settings.integrations.controlModesHidden") }}</label>
<div class="flex flex-col gap-1.5 pt-1">
<label
v-for="m in ankerHideableModes"
:key="m"
class="flex items-center gap-2 text-sm text-body"
>
<input
type="checkbox"
class="dh-checkbox"
:checked="ankerHidesMode(m)"
@change="toggleAnkerHiddenMode(m, $event.target.checked)"
/>
<span>{{ ankerModeLabel(m) }}</span>
</label>
</div>
<p class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModesHiddenHint") }}</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
@@ -1806,7 +1923,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.port"
class="dh-input"
:disabled="greencellLocked('port')"
placeholder="1883"
:placeholder="inheritedPlaceholder(greencellField('port'), '1883')"
inputmode="numeric"
autocomplete="off"
/>
@@ -1859,7 +1976,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.serial"
class="dh-input"
:disabled="greencellLocked('serial')"
placeholder="EVGC021B22752405ZM0018"
:placeholder="inheritedPlaceholder(greencellField('serial'), 'EVGC021B22752405ZM0018')"
autocomplete="off"
/>
<p v-if="greencellField('serial').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('serial') }}</p>
@@ -1871,7 +1988,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.timeout"
class="dh-input"
:disabled="greencellLocked('timeout')"
placeholder="12"
:placeholder="inheritedPlaceholder(greencellField('timeout'), '12')"
inputmode="numeric"
autocomplete="off"
/>
@@ -1884,7 +2001,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.commandTopic"
class="dh-input"
:disabled="greencellLocked('commandTopic')"
placeholder="/greencell/evse/{sn}/command"
:placeholder="inheritedPlaceholder(greencellField('commandTopic'), '/greencell/evse/{sn}/command')"
autocomplete="off"
/>
<p v-if="greencellField('commandTopic').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('commandTopic') }}</p>