Compare commits

..
5 Commits
Author SHA1 Message Date
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
6 changed files with 153 additions and 17 deletions
@@ -97,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
@@ -558,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()
}
@@ -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")
}
}
@@ -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)
}
+46 -4
View File
@@ -87,14 +87,56 @@ export function formatDateTime(value) {
//
// 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 opts = { hour: "2-digit", minute: "2-digit" };
if (prefs.timeFormat === "24") opts.hourCycle = "h23";
else if (prefs.timeFormat === "12") opts.hourCycle = "h12";
return d.toLocaleTimeString(prefs.locale || undefined, opts);
const h = d.getHours();
const pad = (n) => String(n).padStart(2, "0");
const mm = pad(d.getMinutes());
const mode = prefs.timeFormat;
const twelve = mode === "12" || (mode !== "24" && regionReadsTwelveHour());
if (!twelve) 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 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);
}
// Every number we render goes through here so the grouping separator follows
+40 -6
View File
@@ -337,6 +337,23 @@ const ctlStatusLabel = computed(() => {
return (ctlReadsDevice.value ? s.statusDesc : s.connectorStatus) || "—";
});
// And named for whichever of the two it is showing. The tile was called
// "Connector" from when OCPP was the only thing it read: an OCPP connector state
// is what that word means. Reading the charger's own snapshot it holds the
// charger's own status — the same statusDesc the readings card shows as
// "Charging status", so it takes that card's name for it rather than a second
// name for one value.
const ctlStatusTitle = computed(() =>
ctlReadsDevice.value ? t("charging.modbus.chargingStatus") : t("charging.control.status")
);
// The energy tile has the same two readings and the same problem: "Energy" is
// what an OCPP meter total is, and the charger's own snapshot counts this
// session's energy instead. Named from the same card as the status above it.
const ctlMeterTitle = computed(() =>
ctlReadsDevice.value ? t("charging.modbus.sessionEnergy") : t("charging.control.meter")
);
// --- The charger's own snapshot, grouped for reading ---
//
// Reading the charger directly reports far more than OCPP does: one poll carries
@@ -689,18 +706,35 @@ const mqttDraft = ref({});
const mqttBase = ref({});
const mqttBusy = ref("");
// The settings the charger has reported, kept per serial across reads.
//
// The two halves of a snapshot arrive on different messages: the 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, before any settings frame has arrived — and
// seeding the controls from that answer alone emptied the card of everything the
// charger had already told us. Which is not the state of the charger; it is the
// state of one message.
//
// So a value the charger has reported stays until it reports another. The values
// 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.
const mqttSeen = ref({}); // serial → the last value each setting was reported with
function syncMqttSettings() {
const draft = {};
const sn = ctlSerial.value.trim();
const seen = { ...(mqttSeen.value[sn] || {}) };
for (const block of MQTT_SETTING_BLOCKS) {
for (const f of block.fields) {
for (const [key, at] of fieldEntries(f)) {
const v = snapshotValue(at);
if (isSet(v) && v !== "") draft[key] = v;
if (isSet(v) && v !== "") seen[key] = v;
}
}
}
mqttDraft.value = draft;
mqttBase.value = { ...draft };
mqttSeen.value = { ...mqttSeen.value, [sn]: seen };
mqttDraft.value = { ...seen };
mqttBase.value = { ...seen };
}
// Only the settings the charger has actually reported get a control. A value it
@@ -2031,11 +2065,11 @@ onMounted(async () => {
<div class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div>
<div class="text-[11px] text-muted">{{ t("charging.control.status") }}</div>
<div class="text-[11px] text-muted">{{ ctlStatusTitle }}</div>
</div>
<div class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctlMeterKwh }} kWh</div>
<div class="text-[11px] text-muted">{{ t("charging.control.meter") }}</div>
<div class="text-[11px] text-muted">{{ ctlMeterTitle }}</div>
</div>
<div v-if="ctlPower" class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctlPower }}</div>
+20 -5
View File
@@ -336,6 +336,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
@@ -1561,7 +1576,7 @@ onBeforeUnmount(() => {
v-model="ankerForm.country"
class="dh-input"
:disabled="ankerLocked('country')"
placeholder="DE"
:placeholder="inheritedPlaceholder(ankerField('country'), 'DE')"
maxlength="2"
autocomplete="off"
/>
@@ -1825,7 +1840,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.port"
class="dh-input"
:disabled="greencellLocked('port')"
placeholder="1883"
:placeholder="inheritedPlaceholder(greencellField('port'), '1883')"
inputmode="numeric"
autocomplete="off"
/>
@@ -1878,7 +1893,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>
@@ -1890,7 +1905,7 @@ onBeforeUnmount(() => {
v-model="greencellForm.timeout"
class="dh-input"
:disabled="greencellLocked('timeout')"
placeholder="12"
:placeholder="inheritedPlaceholder(greencellField('timeout'), '12')"
inputmode="numeric"
autocomplete="off"
/>
@@ -1903,7 +1918,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>