vastra-radiator-valve
Version:
Node.js library to query and configure Vastra's smart radiator valves.
388 lines (332 loc) • 12.4 kB
text/typescript
import { IGattCharacteristic, IGattPeripheral } from "./bluetooth";
import {
MAX_STATE_READ_CHUNK,
VALVE_RX_UUID,
VALVE_SERVICE_UUID,
VALVE_STATE_LENGTH,
VALVE_TX_UUID,
} from "./constants";
import {
PACKET_HEADER_LENGTH,
PacketId,
RESPONSE_FOOTER_LENGTH,
createStateReadPacket,
createStateWritePackets,
createWakeUpPacket,
decodeStateField,
encodeStateField,
} from "./protocol";
import { RadiatorValvesOptions } from "./scanner";
import { TimeoutToken, withTimeout } from "./utils";
import {
FIELD_BATTERY_VOLTAGE,
FIELD_CURRENT_TEMPERATURE,
FIELD_LOCKED,
FIELD_MODE,
FIELD_NAME,
FIELD_SERIAL_NUMBER,
FIELD_TARGET_TEMPERATURE_AUTO,
FIELD_TARGET_TEMPERATURE_MANUAL,
FIELD_TARGET_TEMPERATURE_SAVING,
FIELD_TEMPERATURE_DEVIATION,
StateFieldInfo,
} from "./valve-state";
export default class RadiatorValve {
/** Characteristic used to read data from the device. */
private rx?: IGattCharacteristic;
/** Characteristic used to write data to the device. */
private tx?: IGattCharacteristic;
private lastSentWakeUpTime = 0;
private logger = this.options.logger;
constructor(
public readonly peripheral: IGattPeripheral,
private readonly options: Readonly<RadiatorValvesOptions>
) {}
/**
* Attempts to establish a connection with the device.
*
* @param attempt Counts how many attempts have been made so far. For internal use only.
*/
public async connect(attempt: number = 0): Promise<void> {
if (this.peripheral.state === "connected") {
await this.peripheral.disconnectAsync();
} else if (this.peripheral.state === "connecting") {
throw new Error(`Already connecting to ${this.peripheral.address}`);
}
if (attempt >= this.options.maxConnectionAttempts) {
throw new Error(`Too many attempts trying to connect to ${this.peripheral.address}`);
}
this.logger?.debug(
`Connecting to ${this.peripheral.address} (timeout=${this.options.connectionTimeout}, attempt=${attempt})`
);
const timeoutToken = new TimeoutToken(this.options.connectionTimeout);
if (this.peripheral.state !== "connected") {
await withTimeout(this.peripheral.connectAsync(), timeoutToken);
if (timeoutToken.timedOut) {
this.logger?.warn(`Timed out connecting to ${this.peripheral.address}`);
return this.connect(attempt + 1);
}
}
// Find handles to the read/write service.
const services = await withTimeout(
this.peripheral.discoverServicesAsync([VALVE_SERVICE_UUID]),
timeoutToken
);
if (timeoutToken.timedOut) {
this.logger?.warn(`Timed out discovering services of ${this.peripheral.address}`);
return this.connect(attempt + 1);
}
if (services.length === 0) {
throw new Error(`${this.peripheral.address} did not report a communication service`);
}
// Find handles to read/write characteristics.
const characteristics = await withTimeout(
services[0].discoverCharacteristicsAsync([VALVE_RX_UUID, VALVE_TX_UUID]),
timeoutToken
);
if (timeoutToken.timedOut) {
this.logger?.warn(`Timed out discovering characteristics of ${this.peripheral.address}`);
return this.connect(attempt + 1);
}
if (characteristics.length != 2) {
throw new Error(`${this.peripheral.address} did not report read/write characteristics`);
}
[this.rx, this.tx] = characteristics;
// Enable receiving notifications from RX characteristic.
// Writing the value always times out, but somehow works fine on Raspberry.
const descriptors = await withTimeout(this.rx.discoverDescriptorsAsync(), timeoutToken);
if (timeoutToken.timedOut) {
this.logger?.warn(`Timed out discovering descriptors of ${this.peripheral.address}`);
return this.connect(attempt + 1);
}
if (this.options.raspberryFix) {
descriptors[0].writeValueAsync(Buffer.from([0x01, 0x00]));
}
this.logger?.debug(`Connected to ${this.peripheral.address}`);
}
/**
* Closes connection with the peripheral.
*/
public async disconnect() {
if (this.peripheral.state !== "disconnected") {
await this.peripheral.disconnectAsync();
this.logger?.debug(`Closed connection to ${this.peripheral.address}`);
}
}
/**
* Writes data contained in given buffer to the device.
* Make sure to wait for the message to be fully sent by `await`-ing this
* method before writing more data.
*
* @param data Data to write.
*/
private write(data: Buffer) {
this.logger?.verbose(`[Host -> ${this.peripheral.address}]`, data);
return this.tx?.writeAsync(data, false);
}
/**
* Writes a request to the device and waits for the response.
*
* @param request Request to send.
* @returns Response.
*/
private sendRequest(request: Buffer) {
const work = async (resolve: Function, reject: Function, attempt: number) => {
if (!this.tx || !this.rx) {
throw new Error("Connection must be open before sending requests.");
}
if (attempt >= this.options.maxReadAttempts) {
// TODO: Probably we should re-connect, because it's difficult to say how
// the peripheral will act in case of a small congestion.
throw new Error(`Timed out reading response from ${this.peripheral.address}`);
}
let responseChunks: Array<Buffer> = [];
this.rx.notify(true);
this.rx.on("data", (data) => {
this.logger?.verbose(`[${this.peripheral.address} -> Host]`, data);
responseChunks.push(data);
if (data.length >= 2 && data.readUInt16LE(data.length - 2) === 0x0a0d) {
this.rx?.removeAllListeners("data");
this.rx?.notify(false);
clearTimeout(timeoutId);
resolve(Buffer.concat(responseChunks));
}
});
let timeoutId: NodeJS.Timeout;
if (this.options.readTimeout > 0) {
timeoutId = setTimeout(async () => {
if (this.rx) {
this.rx.removeAllListeners("data");
this.rx.notify(false);
}
this.logger?.warn(
`Timed out reading response from ${this.peripheral.address} (attempt ${attempt})`
);
try {
await work(resolve, reject, attempt + 1);
} catch (error) {
reject(error);
}
}, this.options.readTimeout);
}
await this.write(request);
};
return new Promise<Buffer>(async (resolve, reject) => {
try {
await work(resolve, reject, 0);
} catch (error) {
reject(error);
}
});
}
/**
* Sends Wake Up command to the peripheral and waits for a response.
*/
public async requestWakeUp() {
const timeSinceLastWakeUp = new Date().getTime() - this.lastSentWakeUpTime;
if (timeSinceLastWakeUp < this.options.wakeUpInterval) {
return;
}
await this.sendRequest(createWakeUpPacket());
this.lastSentWakeUpTime = new Date().getTime();
}
/**
* Requests value of a single field from peripheral's state buffer.
* @returns Buffer containing the value.
*/
public async requestReadField<T>(field: StateFieldInfo<T>): Promise<T> {
const [position, encoding] = field;
const packet = createStateReadPacket(position[0], position[1]);
const response = await this.sendRequest(packet);
// Skip header and checksum.
// TODO: Verify checksum.
const encodedValue = response.subarray(
PACKET_HEADER_LENGTH,
response.length - RESPONSE_FOOTER_LENGTH
);
return decodeStateField(encodedValue, encoding) as T;
}
/**
* Updates the value of a field.
*
* @param field Field to update.
* @param value New value.
*/
public async requestWriteField<T>(field: StateFieldInfo<T>, value: T) {
const [[offset, length], encoding] = field;
const encodedValue = encodeStateField(value, encoding);
if (encodedValue.length > length) {
throw new Error(
`Overflow when writing field value. Expected at most ${length} bytes, got ${encodedValue.length}`
);
}
const paddedValue = Buffer.concat([encodedValue], length);
const packets = createStateWritePackets(paddedValue, offset);
const work = async (attempt = 0) => {
if (attempt >= this.options.maxWriteAttempts) {
throw new Error(
`Too many failed attempts at updating configuration of ${this.peripheral.address}`
);
}
let failed = false;
for (let packetIndex = 0; packetIndex < packets.length; packetIndex++) {
const packet = packets[packetIndex];
const response = await this.sendRequest(packet);
if (response[2] !== PacketId.SaveSuccess) {
this.logger?.warn(
`Unable to update configuration of ${this.peripheral.address} (offset=${offset}, packet=${packet}, packetIndex=${packetIndex}, attempt=${attempt})`
);
failed = true;
break;
}
}
if (failed) {
await work(attempt + 1);
}
};
await work(0);
}
/**
* Requests a snapshot of the entire state buffer from the peripheral.
* @returns Buffer containing the state.
*/
public async requestStateSnapshot() {
let buffer = Buffer.alloc(0);
for (let offset = 0; offset < VALVE_STATE_LENGTH; offset += MAX_STATE_READ_CHUNK) {
const packet = createStateReadPacket(offset, MAX_STATE_READ_CHUNK);
let response = await this.sendRequest(packet);
// Skip header and checksum.
response = response.subarray(PACKET_HEADER_LENGTH, response.length - RESPONSE_FOOTER_LENGTH);
buffer = Buffer.concat([buffer, response]);
}
return buffer;
}
public async setName(name: string) {
if (name.length > 64) {
throw new Error("Name can not be longer than 64 characters");
}
await this.requestWakeUp();
await this.requestWriteField(FIELD_NAME, name);
}
public async getName() {
await this.requestWakeUp();
return this.requestReadField(FIELD_NAME);
}
public async getSerialNumber() {
await this.requestWakeUp();
return this.requestReadField(FIELD_SERIAL_NUMBER);
}
public async setLocked(locked: boolean) {
await this.requestWakeUp();
await this.requestWriteField(FIELD_LOCKED, locked ? 1 : 0);
}
public async getLocked() {
await this.requestWakeUp();
return this.requestReadField(FIELD_LOCKED);
}
public async getBatteryVoltage() {
await this.requestWakeUp();
return this.requestReadField(FIELD_BATTERY_VOLTAGE);
}
public async getTemperatureDeviation() {
await this.requestWakeUp();
return this.requestReadField(FIELD_TEMPERATURE_DEVIATION);
}
public async getCurrentTemperature() {
await this.requestWakeUp();
return this.requestReadField(FIELD_CURRENT_TEMPERATURE);
}
/**
* Sets the target temperature.
* It takes up to 9 minutes for the valve to actually apply
* the update in case of this field.
*
* @param value New target temperature.
*/
public async setTargetTemperature(value: number) {
if (value < 0.5 || value > 29.5) {
throw new Error("Target temperature must be in [0.5-29.5] range");
}
await this.requestWakeUp();
await this.requestWriteField(await this.getTargetTemperatureField(), value);
}
private async getTargetTemperatureField() {
const mode = await this.getMode();
if (mode === 0) {
return FIELD_TARGET_TEMPERATURE_AUTO;
} else if (mode === 1) {
return FIELD_TARGET_TEMPERATURE_MANUAL;
} else if (mode === 2) {
return FIELD_TARGET_TEMPERATURE_SAVING;
}
throw new Error(`Unknown mode: ${mode}`);
}
public async getTargetTemperature() {
await this.requestWakeUp();
return this.requestReadField(await this.getTargetTemperatureField());
}
public async getMode() {
await this.requestWakeUp();
return this.requestReadField(FIELD_MODE);
}
}