bt-seneca-msc
Version:
A pure Javascript API for the Seneca Multi Smart Calibrator (MSC) device, using web bluetooth.
670 lines (602 loc) • 19.3 kB
JavaScript
;
/**
* Bluetooth handling module, including main state machine loop.
* This module interacts with browser for bluetooth comunications and pairing, and with SenecaMSC object.
*/
var APIState = require("./classes/APIState");
var log = require("loglevel");
var constants = require("./constants");
var utils = require("./utils");
var senecaModule = require("./classes/SenecaMSC");
var modbus = require("./modbusRtu");
var testData = require("./modbusTestData");
var btState = APIState.btState;
var State = constants.State;
var CommandType = constants.CommandType;
var ResultCode = constants.ResultCode;
var simulation = false;
var logging = false;
/*
* Bluetooth constants
*/
const BlueToothMSC = {
ServiceUuid: "0003cdd0-0000-1000-8000-00805f9b0131", // bluetooth modbus RTU service for Seneca MSC
ModbusAnswerUuid: "0003cdd1-0000-1000-8000-00805f9b0131", // modbus RTU answers
ModbusRequestUuid: "0003cdd2-0000-1000-8000-00805f9b0131" // modbus RTU requests
};
/**
* Send the message using Bluetooth and wait for an answer
* @param {ArrayBuffer} command modbus RTU packet to send
* @returns {ArrayBuffer} the modbus RTU answer
*/
async function SendAndResponse(command) {
if (command == null)
return null;
log.debug(">> " + utils.buf2hex(command));
btState.response = null;
btState.stats["requests"]++;
var startTime = new Date().getTime();
if (simulation) {
btState.response = fakeResponse(command);
await utils.sleep(5);
}
else {
await btState.charWrite.writeValueWithoutResponse(command);
while (btState.state == State.METER_INITIALIZING ||
btState.state == State.BUSY) {
if (btState.response != null) break;
await new Promise(resolve => setTimeout(resolve, 35));
}
}
var endTime = new Date().getTime();
var answer = btState.response?.slice();
btState.response = null;
// Log the packets
if (logging) {
var packet = { "request": utils.buf2hex(command), "answer": utils.buf2hex(answer) };
var packets = window.localStorage.getItem("ModbusRTUtrace");
if (packets == null) {
packets = []; // initialize array
}
else {
packets = JSON.parse(packets); // Restore the json persisted object
}
packets.push(packet); // Add the new object
window.localStorage.setItem("ModbusRTUtrace", JSON.stringify(packets));
}
btState.stats["responseTime"] = Math.round((1.0 * btState.stats["responseTime"] * (btState.stats["responses"] % 500) + (endTime - startTime)) / ((btState.stats["responses"] % 500) + 1));
btState.stats["lastResponseTime"] = Math.round(endTime - startTime) + " ms";
btState.stats["responses"]++;
return answer;
}
let senecaMSC = new senecaModule.SenecaMSC(SendAndResponse);
/**
* Main loop of the meter handler.
* */
async function stateMachine() {
var nextAction;
var DELAY_MS = (simulation ? 20 : 750); // Update the status every X ms.
var TIMEOUT_MS = (simulation ? 1000 : 30000); // Give up some operations after X ms.
btState.started = true;
log.debug("Current state:" + btState.state);
// Consecutive state counted. Can be used to timeout.
if (btState.state == btState.prev_state) {
btState.state_cpt++;
} else {
btState.state_cpt = 0;
}
// Stop request from API
if (btState.stopRequest) {
btState.state = State.STOPPING;
}
log.debug("State:" + btState.state);
switch (btState.state) {
case State.NOT_CONNECTED: // initial state on Start()
if (simulation) {
nextAction = fakePairDevice;
} else {
nextAction = btPairDevice;
}
break;
case State.CONNECTING: // waiting for connection to complete
nextAction = undefined;
break;
case State.DEVICE_PAIRED: // connection complete, acquire meter state
if (simulation) {
nextAction = fakeSubscribe;
} else {
nextAction = btSubscribe;
}
break;
case State.SUBSCRIBING: // waiting for Bluetooth interfaces
nextAction = undefined;
if (btState.state_cpt > Math.floor(TIMEOUT_MS / DELAY_MS)) {
// Timeout, try to resubscribe
log.warn("Timeout in SUBSCRIBING");
btState.state = State.DEVICE_PAIRED;
btState.state_cpt = 0;
}
break;
case State.METER_INIT: // ready to communicate, acquire meter status
nextAction = meterInit;
break;
case State.METER_INITIALIZING: // reading the meter status
if (btState.state_cpt > Math.floor(TIMEOUT_MS / DELAY_MS)) {
log.warn("Timeout in METER_INITIALIZING");
// Timeout, try to resubscribe
if (simulation) {
nextAction = fakeSubscribe;
} else {
nextAction = btSubscribe;
}
btState.state_cpt = 0;
}
nextAction = undefined;
break;
case State.IDLE: // ready to process commands from API
if (btState.command != null)
nextAction = processCommand;
else {
nextAction = refresh;
}
break;
case State.ERROR: // anytime an error happens
nextAction = disconnect;
break;
case State.BUSY: // while a command in going on
if (btState.state_cpt > Math.floor(TIMEOUT_MS / DELAY_MS)) {
log.warn("Timeout in BUSY");
// Timeout, try to resubscribe
if (simulation) {
nextAction = fakeSubscribe;
} else {
nextAction = btSubscribe;
}
btState.state_cpt = 0;
}
nextAction = undefined;
break;
case State.STOPPING:
nextAction = disconnect;
break;
case State.STOPPED: // after a disconnector or Stop() request, stops the state machine.
nextAction = undefined;
break;
default:
break;
}
btState.prev_state = btState.state;
if (nextAction != undefined) {
log.debug("\tExecuting:" + nextAction.name);
try {
await nextAction();
}
catch (e) {
log.error("Exception in state machine", e);
}
}
if (btState.state != State.STOPPED) {
utils.sleep(DELAY_MS).then(() => stateMachine()).catch((err) => {
log.error("State machine error:", err);
btState.state = State.ERROR;
}); // Recheck status in DELAY_MS ms
}
else {
log.debug("\tTerminating State machine");
btState.started = false;
}
}
/**
* Called from state machine to execute a single command from btState.command property
* */
async function processCommand() {
try {
var command = btState.command;
var result = ResultCode.SUCCESS;
if (command == null) {
return;
}
btState.state = State.BUSY;
btState.stats["commands"]++;
log.info("\t\tExecuting command :" + command);
// First set NONE because we don't want to write new setpoints with active generation
result = await senecaMSC.switchOff();
if (result != ResultCode.SUCCESS) {
throw new Error("Cannot switch meter off before command write!");
}
// Now write the setpoint or setting
if (utils.isGeneration(command.type) || utils.isSetting(command.type) && command.type != CommandType.OFF) {
result = await senecaMSC.writeSetpoints(command.type, command.setpoint, command.setpoint2);
if (result != ResultCode.SUCCESS) {
throw new Error("Failure to write setpoints!");
}
}
if (!utils.isSetting(command.type) &&
utils.isValid(command.type) && command.type != CommandType.OFF) // IF this is a setting, we're done.
{
// Now write the mode set
result = await senecaMSC.changeMode(command.type);
if (result != ResultCode.SUCCESS) {
throw new Error("Failure to change meter mode!");
}
}
// Caller expects a valid property in GetState() once command is executed.
log.debug("\t\tRefreshing current state");
await refresh();
command.error = false;
command.pending = false;
btState.command = null;
btState.state = State.IDLE;
log.debug("\t\tCompleted command executed");
}
catch (err) {
log.error("** error while executing command: " + err);
btState.state = State.METER_INIT;
btState.stats["exceptions"]++;
if (err instanceof modbus.ModbusError)
btState.stats["modbus_errors"]++;
return;
}
}
function getExpectedStateHex() {
// Simulate current mode answer according to last command.
var stateHex = (CommandType.OFF).toString(16);
if (btState.command?.type != null) {
stateHex = (btState.command.type).toString(16);
}
// Add trailing 0
while (stateHex.length < 2)
stateHex = "0" + stateHex;
return stateHex;
}
/**
* Used to simulate RTU answers
* @param {ArrayBuffer} command real request
* @returns {ArrayBuffer} fake answer
*/
function fakeResponse(command) {
var commandHex = utils.buf2hex(command);
var forgedAnswers = {
"19 03 00 64 00 01 c6 0d": "19 03 02 00" + getExpectedStateHex() + " $$$$", // Current state
"default 03": "19 03 06 0001 0001 0001 $$$$", // default answer for FC3
"default 10": "19 10 00 d4 00 02 0001 0001 $$$$"
}; // default answer for FC10
// Start with the default answer
var responseHex = forgedAnswers["default " + commandHex.split(" ")[1]];
// Do we have a forged answer?
if (forgedAnswers[commandHex] != undefined) {
responseHex = forgedAnswers[commandHex];
}
else {
// Look into registered traces
var found = [];
for (const trace of testData.testTraces) {
if (trace["request"] === commandHex) {
found.push(trace["answer"]);
}
}
if (found.length > 0) {
// Select a random answer from the registered trace
responseHex = found[Math.floor((Math.random() * found.length))];
}
else {
console.info(commandHex + " not found in test traces");
}
}
// Compute CRC if needed
if (responseHex.includes("$$$$")) {
responseHex = responseHex.replace("$$$$", "");
var crc = modbus.crc16(new Uint8Array(utils.hex2buf(responseHex))).toString(16);
while (crc.length < 4)
crc = "0" + crc;
responseHex = responseHex + crc.substring(2, 4) + crc.substring(0, 2);
}
log.debug("<< " + responseHex);
return utils.hex2buf(responseHex);
}
/**
* Acquire the current mode and serial number of the device.
* */
async function meterInit() {
try {
btState.state = State.METER_INITIALIZING;
btState.meter.serial = await senecaMSC.getSerialNumber();
log.info("\t\tSerial number:" + btState.meter.serial);
btState.meter.mode = await senecaMSC.getCurrentMode();
log.debug("\t\tCurrent mode:" + btState.meter.mode);
btState.meter.battery = await senecaMSC.getBatteryVoltage();
log.debug("\t\tBattery (V):" + btState.meter.battery);
btState.state = State.IDLE;
}
catch (err) {
log.warn("Error while initializing meter :" + err);
btState.stats["exceptions"]++;
btState.state = State.DEVICE_PAIRED;
if (err instanceof modbus.ModbusError)
btState.stats["modbus_errors"]++;
}
}
/*
* Close the bluetooth interface (unpair)
* */
async function disconnect() {
btState.command = null;
try {
if (btState.btDevice != null) {
if (btState.btDevice?.gatt?.connected) {
log.warn("* Calling disconnect on btdevice");
// Avoid the event firing which may lead to auto-reconnect
btState.btDevice.removeEventListener("gattserverdisconnected", onDisconnected);
btState.btDevice.gatt.disconnect();
}
}
btState.btService = null;
}
catch { }
btState.state = State.STOPPED;
}
/**
* Event called by browser BT api when the device disconnect
* */
async function onDisconnected() {
log.warn("* GATT Server disconnected event, will try to reconnect *");
btState.btService = null;
btState.stats["GATT disconnects"]++;
btState.state = State.DEVICE_PAIRED; // Try to auto-reconnect the interfaces without pairing
}
/**
* Joins the arguments into a single buffer
* @returns {Buffer} concatenated buffer
*/
function arrayBufferConcat() {
var length = 0;
var buffer = null;
for (var i in arguments) {
buffer = arguments[i];
length += buffer.byteLength;
}
var joined = new Uint8Array(length);
var offset = 0;
for (i in arguments) {
buffer = arguments[i];
joined.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return joined.buffer;
}
/**
* Event called by bluetooth characteristics when receiving data
* @param {any} event
*/
function handleNotifications(event) {
let value = event.target.value;
if (value != null) {
log.debug("<< " + utils.buf2hex(value.buffer));
if (btState.response != null) {
// Prevent memory leak by limiting maximum response buffer size
const MAX_RESPONSE_SIZE = 1024; // 1KB limit for modbus responses
const newSize = btState.response.byteLength + value.buffer.byteLength;
if (newSize > MAX_RESPONSE_SIZE) {
log.warn("Response buffer too large, resetting");
btState.response = value.buffer.slice();
} else {
btState.response = arrayBufferConcat(btState.response, value.buffer);
}
} else {
btState.response = value.buffer.slice();
}
}
}
/**
* This function will succeed only if called as a consequence of a user-gesture
* E.g. button click. This is due to BlueTooth API security model.
* */
async function btPairDevice() {
btState.state = State.CONNECTING;
var forceSelection = btState.options["forceDeviceSelection"];
log.debug("btPairDevice(" + forceSelection + ")");
try {
if (typeof (navigator.bluetooth?.getAvailability) == "function") {
const availability = await navigator.bluetooth.getAvailability();
if (!availability) {
log.error("Bluetooth not available in browser.");
throw new Error("Browser does not provide bluetooth");
}
}
var device = null;
// Do we already have permission?
if (typeof (navigator.bluetooth?.getDevices) == "function"
&& !forceSelection) {
const availableDevices = await navigator.bluetooth.getDevices();
availableDevices.forEach(function (dev, index) {
log.debug("Found authorized device :" + dev.name);
if (dev.name.startsWith("MSC"))
device = dev;
});
log.debug("navigator.bluetooth.getDevices()=" + device);
}
// If not, request from user
if (device == null) {
device = await navigator.bluetooth
.requestDevice({
acceptAllDevices: false,
filters: [{ namePrefix: "MSC" }],
optionalServices: [BlueToothMSC.ServiceUuid]
});
}
btState.btDevice = device;
btState.state = State.DEVICE_PAIRED;
log.info("Bluetooth device " + device.name + " connected.");
await utils.sleep(500);
}
catch (err) {
log.warn("** error while connecting: " + err.message);
btState.btService = null;
if (btState.charRead != null) {
try {
btState.charRead.stopNotifications();
} catch (error) { }
}
btState.charRead = null;
btState.charWrite = null;
btState.state = State.ERROR;
btState.stats["exceptions"]++;
}
}
async function fakePairDevice() {
btState.state = State.CONNECTING;
var forceSelection = btState.options["forceDeviceSelection"];
log.debug("fakePairDevice(" + forceSelection + ")");
try {
var device = { name: "FakeBTDevice", gatt: { connected: true } };
btState.btDevice = device;
btState.state = State.DEVICE_PAIRED;
log.info("Bluetooth device " + device.name + " connected.");
await utils.sleep(50);
}
catch (err) {
log.warn("** error while connecting: " + err.message);
btState.btService = null;
btState.charRead = null;
btState.charWrite = null;
btState.state = State.ERROR;
btState.stats["exceptions"]++;
}
}
/**
* Once the device is available, initialize the service and the 2 characteristics needed.
* */
async function btSubscribe() {
try {
btState.state = State.SUBSCRIBING;
btState.stats["subcribes"]++;
let device = btState.btDevice;
let server = null;
if (!device?.gatt?.connected) {
log.debug(`Connecting to GATT Server on ${device.name}...`);
device.addEventListener("gattserverdisconnected", onDisconnected);
try {
if (btState.btService?.connected) {
btState.btService.disconnect();
btState.btService = null;
await utils.sleep(100);
}
} catch (err) { }
server = await device.gatt.connect();
log.debug("> Found GATT server");
}
else {
log.debug("GATT already connected");
server = device.gatt;
}
btState.btService = await server.getPrimaryService(BlueToothMSC.ServiceUuid);
if (btState.btService == null)
throw new Error("GATT Service request failed");
log.debug("> Found Serial service");
btState.charWrite = await btState.btService.getCharacteristic(BlueToothMSC.ModbusRequestUuid);
log.debug("> Found write characteristic");
btState.charRead = await btState.btService.getCharacteristic(BlueToothMSC.ModbusAnswerUuid);
log.debug("> Found read characteristic");
btState.response = null;
btState.charRead.addEventListener("characteristicvaluechanged", handleNotifications);
btState.charRead.startNotifications();
log.info("> Bluetooth interfaces ready.");
btState.stats["last_connect"] = new Date().toISOString();
await utils.sleep(50);
btState.state = State.METER_INIT;
}
catch (err) {
log.warn("** error while subscribing: " + err.message);
if (btState.charRead != null) {
try {
if (btState.btDevice?.gatt?.connected) {
btState.charRead.stopNotifications();
}
btState.btDevice?.gatt.disconnect();
} catch (error) { }
}
btState.charRead = null;
btState.charWrite = null;
btState.state = State.DEVICE_PAIRED;
btState.stats["exceptions"]++;
}
}
async function fakeSubscribe() {
try {
btState.state = State.SUBSCRIBING;
btState.stats["subcribes"]++;
let device = btState.btDevice;
let server = null;
if (!device?.gatt?.connected) {
log.debug(`Connecting to GATT Server on ${device.name}...`);
device["gatt"]["connected"] = true;
log.debug("> Found GATT server");
}
else {
log.debug("GATT already connected");
server = device.gatt;
}
btState.btService = {};
log.debug("> Found Serial service");
btState.charWrite = {};
log.debug("> Found write characteristic");
btState.charRead = {};
log.debug("> Found read characteristic");
btState.response = null;
log.info("> Bluetooth interfaces ready.");
btState.stats["last_connect"] = new Date().toISOString();
await utils.sleep(10);
btState.state = State.METER_INIT;
}
catch (err) {
log.warn("** error while subscribing: " + err.message);
btState.charRead = null;
btState.charWrite = null;
btState.state = State.DEVICE_PAIRED;
btState.stats["exceptions"]++;
}
}
/**
* When idle, this function is called
* */
async function refresh() {
btState.state = State.BUSY;
try {
// Check the mode first
var mode = await senecaMSC.getCurrentMode();
if (mode != CommandType.NONE_UNKNOWN) {
btState.meter.mode = mode;
if (btState.meter.isGeneration()) {
var setpoints = await senecaMSC.getSetpoints(btState.meter.mode);
btState.lastSetpoint = setpoints;
}
if (btState.meter.isMeasurement()) {
var meas = await senecaMSC.getMeasures(btState.meter.mode);
btState.lastMeasure = meas;
}
}
// Refresh battery status regularly (every 10 refresh cycles to avoid excessive communication)
if (!btState.batteryRefreshCounter) {
btState.batteryRefreshCounter = 0;
}
btState.batteryRefreshCounter++;
if (btState.batteryRefreshCounter >= 10) {
btState.meter.battery = await senecaMSC.getBatteryVoltage();
log.debug("\t\tBattery refreshed: " + btState.meter.battery + "V");
btState.batteryRefreshCounter = 0;
}
log.debug("\t\tFinished refreshing current state");
btState.state = State.IDLE;
}
catch (err) {
log.warn("Error while refreshing measure" + err);
btState.state = State.DEVICE_PAIRED;
btState.stats["exceptions"]++;
if (err instanceof modbus.ModbusError)
btState.stats["modbus_errors"]++;
}
}
function SetSimulation(value) {
simulation = value;
}
module.exports = { stateMachine, SendAndResponse, SetSimulation };