Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1b76a801d | ||
|
|
830ef0cde5 | ||
|
|
263d35c688 | ||
|
|
4d51a34c44 | ||
|
|
c65ce0c081 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user