Skip to content

Commit dcf310b

Browse files
charging-dashboard: self-updating Slack TUI from Pecan accumulator
Adds a charging dashboard that posts one self-updating Slack message (post once, chat.update every 5s, like the file-uploader) while charging the accumulator via the Kvaser bridge on the internal (pecan-dev) build. Bot (server/installer/slackbot): - charge_dashboard.py: TUI renderer (SoC bar, pack/cell/temp/alert lines, per-module sparkline + stats), per-session manager, stdlib HTTP receiver (POST /charging/state behind CF Zero Trust + shared-token auth), heartbeat staleness. - soc_model.py: data-derived SOC/ETA from the wfr26 audit. PackCurrent/BMS SOC are dead sentinels and PackVoltage is absent, so SOC is an OCV->SOC lookup on the limiting cell and ETA integrates a CV taper; phase is from the voltage trend. - Wired into slack_bot.py __main__; compose port/env; Dockerfile. - unittests: 17 (renderer, formatters, session updates, HTTP auth, model). Pecan: - chargingSnapshot.ts: pure, mockable snapshot builder mirroring the accumulator page; sentinel-guards the dead signals; derives pack voltage from the series-cell sum. - useChargingDashboard.ts: 5s poster, gated on VITE_INTERNAL + live Kvaser source (:9081). Never broadcasts the demo relay or replay data. - vitest: 15 (aggregation, alert chips, dead-signal guards, broadcast gate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57b0645 commit dcf310b

13 files changed

Lines changed: 1258 additions & 1 deletion
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
buildChargingSnapshot,
4+
chipHigh,
5+
chipLow,
6+
type SignalReader,
7+
} from './chargingSnapshot';
8+
import {
9+
MODULE_IDS,
10+
CELLS_PER_MODULE,
11+
getCellSignalInfo,
12+
getThermistorSignalInfo,
13+
type ModuleId,
14+
} from '../components/accumulator/AccumulatorTypes';
15+
16+
interface ReaderOpts {
17+
current?: number;
18+
soc?: number;
19+
packV?: number;
20+
cells?: Partial<Record<ModuleId, number[]>>;
21+
temps?: Partial<Record<ModuleId, number[]>>;
22+
}
23+
24+
function makeReader(opts: ReaderOpts): SignalReader {
25+
const map = new Map<string, number>();
26+
const put = (msgId: string, sig: string, v: number) => map.set(`${msgId}::${sig}`, v);
27+
28+
if (opts.current !== undefined) put('512', 'PackCurrent', opts.current);
29+
if (opts.soc !== undefined) put('512', 'StateOfCharge', opts.soc);
30+
if (opts.packV !== undefined) put('512', 'PackVoltage', opts.packV);
31+
32+
for (const id of MODULE_IDS) {
33+
(opts.cells?.[id] ?? []).forEach((v, idx) => {
34+
const { msgId, signalName } = getCellSignalInfo(id, idx + 1);
35+
put(msgId, signalName, v);
36+
});
37+
(opts.temps?.[id] ?? []).forEach((t, idx) => {
38+
const { msgId, signalName } = getThermistorSignalInfo(id, idx + 1);
39+
put(msgId, signalName, t);
40+
});
41+
}
42+
43+
return {
44+
getSignal(msgID, signalName) {
45+
const v = map.get(`${msgID}::${signalName}`);
46+
return v === undefined ? undefined : { sensorReading: v };
47+
},
48+
};
49+
}
50+
51+
const flat = (v: number) => Array(CELLS_PER_MODULE).fill(v);
52+
53+
describe('chipHigh / chipLow', () => {
54+
it('chipHigh escalates with value', () => {
55+
expect(chipHigh(null, 0.1, 0.2)).toBe('ok');
56+
expect(chipHigh(0.05, 0.1, 0.2)).toBe('ok');
57+
expect(chipHigh(0.1, 0.1, 0.2)).toBe('warn');
58+
expect(chipHigh(0.25, 0.1, 0.2)).toBe('crit');
59+
});
60+
it('chipLow escalates as value drops', () => {
61+
expect(chipLow(null, 3.2, 3.0)).toBe('ok');
62+
expect(chipLow(3.5, 3.2, 3.0)).toBe('ok');
63+
expect(chipLow(3.2, 3.2, 3.0)).toBe('warn');
64+
expect(chipLow(2.9, 3.2, 3.0)).toBe('crit');
65+
});
66+
});
67+
68+
describe('buildChargingSnapshot — aggregation', () => {
69+
const cells: Partial<Record<ModuleId, number[]>> = {
70+
M1: flat(3.85),
71+
M2: (() => {
72+
const a = flat(3.84);
73+
a[0] = 3.99; // global max cell → M2·C1
74+
return a;
75+
})(),
76+
M3: (() => {
77+
const a = flat(3.83);
78+
a[9] = 3.65; // global min cell → M3·C10
79+
return a;
80+
})(),
81+
M4: flat(3.86),
82+
M5: flat(3.86),
83+
};
84+
const temps: Partial<Record<ModuleId, number[]>> = {
85+
M1: [30, 31],
86+
M4: [56], // critical temp → M4·T1
87+
};
88+
89+
const reader = makeReader({ current: -12.4, soc: 67, cells, temps });
90+
const snap = buildChargingSnapshot(reader, { session: 's1', startMs: 1_000, now: 61_000 });
91+
92+
it('reports five modules with correct per-module stats', () => {
93+
expect(snap.modules.map((m) => m.id)).toEqual([...MODULE_IDS]);
94+
const m3 = snap.modules.find((m) => m.id === 'M3')!;
95+
expect(m3.min).toBeCloseTo(3.65, 5);
96+
expect(m3.max).toBeCloseTo(3.83, 5);
97+
expect(m3.delta_mv).toBe(180);
98+
const m1 = snap.modules.find((m) => m.id === 'M1')!;
99+
expect(m1.delta_mv).toBe(0);
100+
expect(m1.avg).toBeCloseTo(3.85, 5);
101+
});
102+
103+
it('finds pack-wide min/max cells with labels', () => {
104+
expect(snap.min_cell).toEqual({ v: 3.65, label: 'M3·C10' });
105+
expect(snap.max_cell).toEqual({ v: 3.99, label: 'M2·C1' });
106+
expect(snap.delta_mv).toBe(340); // (3.99 - 3.65) * 1000
107+
});
108+
109+
it('derives pack voltage as the series-cell sum (no PackVoltage signal)', () => {
110+
const expected =
111+
3.85 * 20 + (3.84 * 19 + 3.99) + (3.83 * 19 + 3.65) + 3.86 * 20 + 3.86 * 20;
112+
expect(snap.pack_v).toBeCloseTo(expected, 3);
113+
});
114+
115+
it('maps temps and alert chips', () => {
116+
expect(snap.max_temp).toEqual({ c: 56, label: 'M4·T1' });
117+
expect(snap.min_temp.c).toBe(30);
118+
expect(snap.alerts.voltdelta).toBe('crit'); // 0.34 V ≥ 0.2
119+
expect(snap.alerts.temp).toBe('crit'); // 56 ≥ 55
120+
expect(snap.alerts.low).toBe('ok'); // 3.65 > 3.2
121+
});
122+
123+
it('computes elapsed seconds and passes through valid current/soc', () => {
124+
expect(snap.elapsed_s).toBe(60);
125+
expect(snap.current_a).toBe(-12.4);
126+
expect(snap.soc).toBe(67);
127+
expect(snap.state).toBe('charging');
128+
});
129+
});
130+
131+
describe('buildChargingSnapshot — dead-signal guards', () => {
132+
it('nulls the dead PackCurrent sentinel and the dead 0 SOC', () => {
133+
const reader = makeReader({ current: -3276, soc: 0, cells: { M1: flat(3.8) } });
134+
const snap = buildChargingSnapshot(reader, { session: 's', startMs: 0 });
135+
expect(snap.current_a).toBeNull();
136+
expect(snap.soc).toBeNull();
137+
expect(snap.state).toBe('standby'); // unknown current → not charging/discharging
138+
expect(snap.pack_v).toBeCloseTo(3.8 * 20, 5); // still derived from cells
139+
});
140+
141+
it('classifies discharging when current is positive and valid', () => {
142+
const reader = makeReader({ current: 12, cells: { M1: flat(3.8) } });
143+
const snap = buildChargingSnapshot(reader, { session: 's', startMs: 0 });
144+
expect(snap.state).toBe('discharging');
145+
});
146+
});

pecan/src/lib/chargingSnapshot.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Charging snapshot builder
3+
*
4+
* Pure functions that read the current accumulator state and produce the JSON
5+
* snapshot POSTed to the slackbot charging dashboard. Mirrors the data shown on
6+
* the Accumulator page (MasterAlertPanel pack stats + per-module ModuleStats).
7+
*
8+
* Kept dependency-injectable (`SignalReader`) so it is unit-testable without the
9+
* live DataStore.
10+
*/
11+
12+
import {
13+
MODULE_IDS,
14+
CELLS_PER_MODULE,
15+
THERMISTORS_PER_MODULE,
16+
ALERT_THRESHOLDS,
17+
getCellSignalInfo,
18+
getThermistorSignalInfo,
19+
type ModuleId,
20+
} from '../components/accumulator/AccumulatorTypes';
21+
22+
/** Minimal surface of DataStore needed here — easy to mock in tests. */
23+
export interface SignalReader {
24+
getSignal(msgID: string, signalName: string): { sensorReading: number } | undefined;
25+
}
26+
27+
const BMS_STATUS_ID = '512';
28+
const CHARGING_THRESHOLD = -0.5; // A — negative current = charging (per BatteryStatus.tsx)
29+
const DISCHARGING_THRESHOLD = 0.5; // A
30+
31+
// Telemetry reality (wfr26 audit): PackCurrent reads a dead -3276 sentinel, BMS SOC
32+
// reads constant 0, and there is no PackVoltage signal. We sentinel-guard those so a
33+
// dead value is sent as null (the slackbot's soc_model derives SOC/phase from cell
34+
// voltages instead), and we derive pack voltage from the series-cell sum.
35+
const PLAUSIBLE_CURRENT_A = 1000; // |PackCurrent| beyond this ⇒ treat as invalid/dead
36+
37+
export type AlertChip = 'ok' | 'warn' | 'crit';
38+
export type ChargeState = 'charging' | 'discharging' | 'standby';
39+
40+
export interface ModuleSnapshot {
41+
id: ModuleId;
42+
cells: (number | null)[];
43+
avg: number | null;
44+
min: number | null;
45+
max: number | null;
46+
delta_mv: number | null;
47+
tmax: number | null;
48+
}
49+
50+
export interface ChargingSnapshot {
51+
session: string;
52+
state: ChargeState;
53+
elapsed_s: number;
54+
soc: number | null;
55+
pack_v: number | null;
56+
current_a: number | null;
57+
avg_v: number | null;
58+
delta_mv: number | null;
59+
min_cell: { v: number; label: string } | null;
60+
max_cell: { v: number; label: string } | null;
61+
max_temp: { c: number; label: string } | null;
62+
min_temp: { c: number | null };
63+
alerts: { voltdelta: AlertChip; temp: AlertChip; bal: AlertChip; low: AlertChip };
64+
modules: ModuleSnapshot[];
65+
source: string;
66+
env: string;
67+
}
68+
69+
function read(reader: SignalReader, msgID: string, name: string): number | null {
70+
const s = reader.getSignal(msgID, name);
71+
return s ? s.sensorReading : null;
72+
}
73+
74+
function avg(xs: number[]): number | null {
75+
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
76+
}
77+
78+
export interface BuildOptions {
79+
session: string;
80+
startMs: number;
81+
now?: number;
82+
source?: string;
83+
env?: string;
84+
}
85+
86+
export function buildChargingSnapshot(reader: SignalReader, opts: BuildOptions): ChargingSnapshot {
87+
const now = opts.now ?? Date.now();
88+
89+
const rawCurrent = read(reader, BMS_STATUS_ID, 'PackCurrent');
90+
const current = rawCurrent !== null && Math.abs(rawCurrent) <= PLAUSIBLE_CURRENT_A ? rawCurrent : null;
91+
const rawSoc = read(reader, BMS_STATUS_ID, 'StateOfCharge') ?? read(reader, BMS_STATUS_ID, 'SOC');
92+
const soc = rawSoc !== null && rawSoc > 0 && rawSoc <= 100 ? rawSoc : null;
93+
94+
// Pack-wide extremes (with cell/thermistor labels), per-module aggregates.
95+
let minCell: { v: number; label: string } | null = null;
96+
let maxCell: { v: number; label: string } | null = null;
97+
let maxTemp: { c: number; label: string } | null = null;
98+
let minTemp: number | null = null;
99+
const allCellValues: number[] = [];
100+
const moduleAvgs: number[] = [];
101+
102+
const modules: ModuleSnapshot[] = MODULE_IDS.map((id) => {
103+
const cells: (number | null)[] = [];
104+
const cellValues: number[] = [];
105+
for (let i = 1; i <= CELLS_PER_MODULE; i++) {
106+
const { msgId, signalName } = getCellSignalInfo(id, i);
107+
const v = read(reader, msgId, signalName);
108+
cells.push(v);
109+
if (v !== null) {
110+
cellValues.push(v);
111+
allCellValues.push(v);
112+
const label = `${id}·C${i}`;
113+
if (minCell === null || v < minCell.v) minCell = { v, label };
114+
if (maxCell === null || v > maxCell.v) maxCell = { v, label };
115+
}
116+
}
117+
118+
let tmax: number | null = null;
119+
for (let i = 1; i <= THERMISTORS_PER_MODULE; i++) {
120+
const { msgId, signalName } = getThermistorSignalInfo(id, i);
121+
const t = read(reader, msgId, signalName);
122+
if (t !== null) {
123+
const label = `${id}·T${i}`;
124+
if (tmax === null || t > tmax) tmax = t;
125+
if (maxTemp === null || t > maxTemp.c) maxTemp = { c: t, label };
126+
if (minTemp === null || t < minTemp) minTemp = t;
127+
}
128+
}
129+
130+
const mAvg = avg(cellValues);
131+
if (mAvg !== null) moduleAvgs.push(mAvg);
132+
const mMin = cellValues.length ? Math.min(...cellValues) : null;
133+
const mMax = cellValues.length ? Math.max(...cellValues) : null;
134+
return {
135+
id,
136+
cells,
137+
avg: mAvg,
138+
min: mMin,
139+
max: mMax,
140+
delta_mv: mMin !== null && mMax !== null ? Math.round((mMax - mMin) * 1000) : null,
141+
tmax,
142+
};
143+
});
144+
145+
// PackVoltage signal is absent in telemetry → derive the stack voltage as the sum
146+
// of the (series) cell voltages. Prefer a real PackVoltage signal if one ever appears.
147+
const rawPackV = read(reader, BMS_STATUS_ID, 'PackVoltage');
148+
const packV =
149+
rawPackV !== null && rawPackV > 0
150+
? rawPackV
151+
: allCellValues.length
152+
? allCellValues.reduce((a, b) => a + b, 0)
153+
: null;
154+
155+
const packDelta =
156+
minCell !== null && maxCell !== null ? (maxCell as { v: number }).v - (minCell as { v: number }).v : null;
157+
const imbalance =
158+
moduleAvgs.length > 1 ? Math.max(...moduleAvgs) - Math.min(...moduleAvgs) : null;
159+
160+
let state: ChargeState = 'standby';
161+
if (current !== null) {
162+
if (current < CHARGING_THRESHOLD) state = 'charging';
163+
else if (current > DISCHARGING_THRESHOLD) state = 'discharging';
164+
}
165+
166+
return {
167+
session: opts.session,
168+
state,
169+
elapsed_s: Math.max(0, Math.round((now - opts.startMs) / 1000)),
170+
soc,
171+
pack_v: packV,
172+
current_a: current,
173+
avg_v: avg(allCellValues),
174+
delta_mv: packDelta !== null ? Math.round(packDelta * 1000) : null,
175+
min_cell: minCell,
176+
max_cell: maxCell,
177+
max_temp: maxTemp,
178+
min_temp: { c: minTemp },
179+
alerts: {
180+
voltdelta: chipHigh(packDelta, ALERT_THRESHOLDS.voltageDiff.warning, ALERT_THRESHOLDS.voltageDiff.critical),
181+
temp: chipHigh(maxTemp ? (maxTemp as { c: number }).c : null, ALERT_THRESHOLDS.overTemp.warning, ALERT_THRESHOLDS.overTemp.critical),
182+
bal: chipHigh(imbalance, ALERT_THRESHOLDS.moduleImbalance.warning, ALERT_THRESHOLDS.moduleImbalance.critical),
183+
low: chipLow(minCell ? (minCell as { v: number }).v : null, ALERT_THRESHOLDS.lowVoltage.warning, ALERT_THRESHOLDS.lowVoltage.critical),
184+
},
185+
modules,
186+
source: opts.source ?? 'kvaser-bridge',
187+
env: opts.env ?? 'pecan-dev',
188+
};
189+
}
190+
191+
/** Higher value = worse (delta, temp, imbalance). */
192+
export function chipHigh(value: number | null, warn: number, crit: number): AlertChip {
193+
if (value === null) return 'ok';
194+
if (value >= crit) return 'crit';
195+
if (value >= warn) return 'warn';
196+
return 'ok';
197+
}
198+
199+
/** Lower value = worse (min cell voltage). */
200+
export function chipLow(value: number | null, warn: number, crit: number): AlertChip {
201+
if (value === null) return 'ok';
202+
if (value <= crit) return 'crit';
203+
if (value <= warn) return 'warn';
204+
return 'ok';
205+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { isKvaserSource } from './useChargingDashboard';
4+
5+
beforeEach(() => {
6+
localStorage.clear();
7+
sessionStorage.clear();
8+
});
9+
10+
describe('isKvaserSource — broadcast gate', () => {
11+
it('is true when connected to the Kvaser bridge (:9081)', () => {
12+
sessionStorage.setItem('pecan-ws-last-ok', 'wss://localhost:9081');
13+
expect(isKvaserSource()).toBe(true);
14+
});
15+
16+
it('honours a configured custom bridge URL when nothing is connected yet', () => {
17+
localStorage.setItem('custom-ws-url', 'ws://127.0.0.1:9081');
18+
expect(isKvaserSource()).toBe(true);
19+
});
20+
21+
it('NEVER broadcasts the public demo relay (fake/generated data)', () => {
22+
sessionStorage.setItem('pecan-ws-last-ok', 'wss://ws-demo.westernformularacing.org');
23+
expect(isKvaserSource()).toBe(false);
24+
});
25+
26+
it('excludes the demo relay even if it is the configured custom URL', () => {
27+
localStorage.setItem('custom-ws-url', 'wss://ws-demo.westernformularacing.org');
28+
sessionStorage.setItem('pecan-ws-last-ok', 'wss://ws-demo.westernformularacing.org');
29+
expect(isKvaserSource()).toBe(false);
30+
});
31+
32+
it('does not broadcast the base-station bridge (:9080) or production', () => {
33+
sessionStorage.setItem('pecan-ws-last-ok', 'ws://10.0.0.5:9080');
34+
expect(isKvaserSource()).toBe(false);
35+
});
36+
37+
it('is false with no source configured', () => {
38+
expect(isKvaserSource()).toBe(false);
39+
});
40+
});

0 commit comments

Comments
 (0)