UNPKG

bt-seneca-msc

Version:

A pure Javascript API for the Seneca Multi Smart Calibrator (MSC) device, using web bluetooth.

1,747 lines (1,554 loc) 404 kB
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.MSC = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ "use strict"; /** * 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; 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 }; },{"./classes/APIState":2,"./classes/SenecaMSC":6,"./constants":7,"./modbusRtu":10,"./modbusTestData":11,"./utils":14,"loglevel":12}],2:[function(require,module,exports){ var constants = require("../constants"); var MeterState = require("./MeterState"); // Current state of the bluetooth class APIState { constructor() { this.state = constants.State.NOT_CONNECTED; this.prev_state = constants.State.NOT_CONNECTED; this.state_cpt = 0; this.started = false; // State machine status this.stopRequest = false; // To request disconnect this.lastMeasure = {}; // Array with "MeasureName" : value this.lastSetpoint = {}; // Array with "SetpointType" : value // state of connected meter this.meter = new MeterState(); // last modbus RTU command this.command = null; // last modbus RTU answer this.response = null; // bluetooth properties this.charRead = null; this.charWrite = null; this.btService = null; this.btDevice = null; // emulated continuity checker this.continuity = false; // battery refresh counter for regular battery status updates this.batteryRefreshCounter = 0; // general statistics for debugging this.stats = { "requests": 0, "responses": 0, "modbus_errors": 0, "GATT disconnects": 0, "exceptions": 0, "subcribes": 0, "commands": 0, "responseTime": 0.0, "lastResponseTime": "", "last_connect": new Date(2020, 1, 1).toISOString() }; this.options = { "forceDeviceSelection": true }; } } let btState = new APIState(); module.exports = { APIState, btState }; },{"../constants":7,"./MeterState":5}],3:[function(require,module,exports){ var constants = require("../constants"); var utils = require("../utils"); var CommandType = constants.CommandType; /** * Command to the meter, may include setpoint * */ class Command { /** * Creates a new command * @param {CommandType} ctype */ constructor(ctype = CommandType.NONE_UNKNOWN) { this.type = parseInt(ctype); this.setpoint = null; this.setpoint2 = null; this.error = false; this.pending = true; this.request = null; this.response = null; } static CreateNoSP(ctype) { var cmd = new Command(ctype); return cmd; } static CreateOneSP(ctype, setpoint) { var cmd = new Command(ctype); cmd.setpoint = parseFloat(setpoint); return cmd; } static CreateTwoSP(ctype, set1, set2) { var cmd = new Command(ctype); cmd.setpoint = parseFloat(set1); cmd.setpoint2 = parseFloat(set2); return cmd; } toString() { return "Type: " + utils.Parse(CommandType, this.type) + ", setpoint:" + this.setpoint + ", setpoint2: " + this.setpoint2 + ", pending:" + this.pending + ", error:" + this.error; } /** * Gets the default setpoint for this command type * @returns {Array} setpoint(s) expected */ defaultSetpoint() { switch (this.type) { case CommandType.GEN_THERMO_B: case CommandType.GEN_THERMO_E: case CommandType.GEN_THERMO_J: case CommandType.GEN_THERMO_K: case CommandType.GEN_THERMO_L: case CommandType.GEN_THERMO_N: case CommandType.GEN_THERMO_R: case CommandType.GEN_THERMO_S: case CommandType.GEN_THERMO_T: case CommandType.GEN_Cu50_3W: case CommandType.GEN_Cu50_2W: case CommandType.GEN_Cu100_2W: case CommandType.GEN_Ni100_2W: case CommandType.GEN_Ni120_2W: case CommandType.GEN_PT100_2W: case CommandType.GEN_PT500_2W: case CommandType.GEN_PT1000_2W: return { "Temperature (°C)": 0.0 }; case CommandType.GEN_V: return { "Voltage (V)": 0.0 }; case CommandType.GEN_mV: return { "Voltage (mV)": 0.0 }; case CommandType.GEN_mA_active: case CommandType.GEN_mA_passive: return { "Current (mA)": 0.0 }; case CommandType.GEN_LoadCell: return { "Imbalance (mV/V)": 0.0 }; case CommandType.GEN_Frequency: return { "Frequency (Hz)": 0.0 }; case CommandType.GEN_PulseTrain: return { "Pulses count": 0, "Frequency (Hz)": 0.0 }; case CommandType.SET_UThreshold_F: return { "Uthreshold (V)": 2.0 }; case CommandType.SET_Sensitivity_uS: return { "Sensibility (uS)": 2.0 }; case CommandType.SET_ColdJunction: return { "Cold junction compensation": 0.0 }; case CommandType.SET_Ulow: return { "U low (V)": 0.0 / constants.MAX_U_GEN }; case CommandType.SET_Uhigh: return { "U high (V)": 5.0 / constants.MAX_U_GEN }; case CommandType.SET_ShutdownDelay: return { "Delay (s)": 60 * 5 }; default: return {}; } } isGeneration() { return utils.isGeneration(this.type); } isMeasurement() { return utils.isMeasurement(this.type); } isSetting() { return utils.isSetting(this.type); } isValid() { return (utils.isMeasurement(this.type) || utils.isGeneration(this.type) || utils.isSetting(this.type)); } } module.exports = Command; },{"../constants":7,"../utils":14}],4:[function(require,module,exports){ class CommandResult { value = 0.0; success = false; message = ""; unit = ""; secondary_value = 0.0; secondary_unit = ""; } module.exports = CommandResult; },{}],5:[function(require,module,exports){ var constants = require("../constants"); /** * Current state of the meter * */ class MeterState { constructor() { this.firmware = ""; // Firmware version this.serial = ""; // Serial number this.mode = constants.CommandType.NONE_UNKNOWN; this.battery = 0.0; } isMeasurement() { return this.mode > constants.CommandType.NONE_UNKNOWN && this.mode < constants.CommandType.OFF; } isGeneration() { return this.mode > constants.CommandType.OFF && this.mode < constants.CommandType.GEN_RESERVED; } } module.exports = MeterState; },{"../constants":7}],6:[function(require,module,exports){ "use strict"; /** * This module contains the SenecaMSC object, which provides the main operations for bluetooth module. * It uses the modbus helper functions from senecaModbus / modbusRtu to interact with the meter with SendAndResponse function */ var log = require("loglevel"); var utils = require("../utils"); var senecaMB = require("../senecaModbus"); var modbus = require("../modbusRtu"); var constants = require("../constants"); const { btState } = require("./APIState"); var CommandType = constants.CommandType; var ResultCode = constants.ResultCode; const RESET_POWER_OFF = 6; const SET_POWER_OFF = 7; const CLEAR_AVG_MIN_MAX = 5; const PULSE_CMD = 9; class SenecaMSC { constructor(fnSendAndResponse) { this.SendAndResponse = fnSendAndResponse; } /** * Gets the meter serial number (12345_1234) * May throw ModbusError * @returns {string} */ async getSerialNumber() { log.debug("\t\tReading serial number"); var response = await this.SendAndResponse(senecaMB.makeSerialNumber()); return senecaMB.parseSerialNumber(response); } /** * Gets the current mode set on the MSC device * May throw ModbusError * @returns {CommandType} active mode */ async getCurrentMode() { log.debug("\t\tReading current mode"); var response = await this.SendAndResponse(senecaMB.makeCurrentMode()); return senecaMB.parseCurrentMode(response, CommandType.NONE_UNKNOWN); } /** * Gets the battery voltage from the meter for battery level indication * May throw ModbusError * @returns {number} voltage (V) */ async getBatteryVoltage() { log.debug("\t\tReading battery voltage"); var response = await this.SendAndResponse(senecaMB.makeBatteryLevel()); return Math.round(senecaMB.parseBattery(response) * 100) / 100; } /** * Check measurement error flags from meter * May throw ModbusError * @returns {boolean} */ async getQualityValid() { log.debug("\t\tReading measure quality bit"); var response = await this.SendAndResponse(senecaMB.makeQualityBitRequest()); return senecaMB.isQualityValid(response); } /** * Check generation error flags from meter * May throw ModbusError * @returns {boolean} */ async getGenQualityValid(current_mode) { log.debug("\t\tReading generation quality bit"); var response = await this.SendAndResponse(senecaMB.makeGenStatusRead()); return senecaMB.parseGenStatus(response, current_mode); } /** * Reads the measurements from the meter, including error flags * May throw ModbusError * @param {CommandType} mode current meter mode * @returns {array|null} measurement array (units, values, error flag) */ async getMeasures(mode) { log.debug("\t\tReading measures"); var valid = await this.getQualityValid(); var response = await this.SendAndResponse(senecaMB.makeMeasureRequest(mode)); if (response != null) { var meas = senecaMB.parseMeasure(response, mode); meas["error"] = !valid; return meas; } return null; } /** * Reads the active setpoints from the meter, including error flags * May throw ModbusError * @param {CommandType} mode current meter mode * @returns {array|null} setpoints array (units, values, error flag) */ async getSetpoints(mode) { log.debug("\t\tReading setpoints"); var valid = await this.getGenQualityValid(mode); var response = await this.SendAndResponse(senecaMB.makeSetpointRead(mode)); if (response != null) { var results = senecaMB.parseSetpointRead(response, mode); results["error"] = !valid; return results; } return null; } /** * Puts the meter in OFF mode * May throw ModbusError * @returns {ResultCode} result of the operation */ async switchOff() { log.debug("\t\tSetting meter to OFF"); var packet = senecaMB.makeModeRequest(CommandType.OFF); if (packet == null) return ResultCode.FAILED_NO_RETRY; await this.SendAndResponse(packet); await utils.sleep(100); return ResultCode.SUCCESS; } /** * Write the setpoints to the meter * May throw ModbusError * @param {CommandType} command_type type of generation command * @param {number} setpoint setpoint of generation * @param {number} setpoint2 facultative, second setpoint * @returns {ResultCode} result of the operation */ async writeSetpoints(command_type, setpoint, setpoint2) { var startGen; log.debug("\t\tSetting command:"+ command_type + ", setpoint: " + setpoint + ", setpoint 2: " + setpoint2); var packets = senecaMB.makeSetpointRequest(command_type, setpoint, setpoint2); for(const p of packets) { var response = await this.SendAndResponse(p); if (response != null && !modbus.parseFC16checked(response, 0)) { return ResultCode.FAILED_SHOULD_RETRY; } } // Special handling of the SET Delay command switch (command_type) { case CommandType.SET_ShutdownDelay: startGen = modbus.makeFC16(modbus.SENECA_MB_SLAVE_ID, senecaMB.MSCRegisters.CMD, [RESET_POWER_OFF]); response = await this.SendAndResponse(startGen); if (!modbus.parseFC16checked(response, 1)) { return ResultCode.FAILED_NO_RETRY; } break; default: break; } return ResultCode.SUCCESS; } /** * Clear Avg/Min/Max statistics * May throw ModbusError * @returns {ResultCode} result of the operation */ async clearStatistics() { log.debug("\t\tResetting statistics"); var startGen = modbus.makeFC16(modbus.SENECA_MB_SLAVE_ID, senecaMB.MSCRegisters.CMD, [CLEAR_AVG_MIN_MAX]); var response = await this.SendAndResponse(startGen); if (!modbus.parseFC16checked(response, 1)) { return ResultCode.FAILED_NO_RETRY; } return ResultCode.SUCCESS; } /** * Begins the pulse generation * May throw ModbusError * @returns {ResultCode} result of the operation */ async startPulseGen() { log.debug("\t\tStarting pulse generation"); var startGen = modbus.makeFC16(modbus.SENECA_MB_SLAVE_ID, senecaMB.MSCRegisters.GEN_CMD, [PULSE_CMD, 2]); // Start with low var response = await this.SendAndResponse(startGen); if (!modbus.parseFC16checked(response, 2)) { return ResultCode.FAILED_NO_RETRY; } return ResultCode.SUCCESS; } /** * Begins the frequency generation * May throw ModbusError * @returns {ResultCode} result of the operation */ async startFreqGen() { log.debug("\t\tStarting freq gen"); var startGen = modbus.makeFC16(modbus.SENECA_MB_SLAVE_ID, senecaMB.MSCRegisters.GEN_CMD, [PULSE_CMD, 1]); // start gen var response = await this.SendAndResponse(startGen); if (!modbus.parseFC16checked(response, 2)) { return ResultCode.FAILED_NO_RETRY; } return ResultCode.SUCCESS; } /** * Disable auto power off to the meter * May throw ModbusError * @returns {ResultCode} result of the operation */ async disablePowerOff() { log.debug("\t\tDisabling power off"); var startGen = modbus.makeFC16(modbus.SENECA_MB_SLAVE_ID, senecaMB.MSCRegisters.CMD, [RESET_POWER_OFF]); await this.SendAndResponse(startGen); return ResultCode.SUCCESS; } /** * Changes the current mode on the meter * May throw ModbusError * @param {CommandType} command_type the new mode to set the meter in * @returns {ResultCode} result of the operation */ async changeMode(command_type) { log.debug("\t\tSetting meter mode to :" + command_type); var packet = senecaMB.makeModeRequest(command_type); if (packet == null) { log.error("Could not generate modbus packet for command type", command_type); return ResultCode.FAILED_NO_RETRY; } var response = await this.SendAndResponse(packet); if (!modbus.parseFC16checked(response, 0)) { log.error("Could not generate modbus packet for command type", command_type); return ResultCode.FAILED_NO_RETRY; } var result = ResultCode.SUCCESS; // Some commands require additional command to be given to work properly, after a slight delay switch (command_type) { case CommandType.Continuity: btState.continuity = true; break; case CommandType.V: case CommandType.mV: case CommandType.mA_active: case CommandType.mA_passive: case CommandType.PulseTrain: await utils.sleep(1000); result = await this.clearStatistics(); break; case CommandType.GEN_PulseTrain: await utils.sleep(1000); result = await this.startPulseGen(); break; case CommandType.GEN_Frequency: await utils.sleep(1000); result = await this.startFreqGen(); break; } if (result == ResultCode.SUCCESS) { result = await this.disablePowerOff(); } return result; } } module.exports = { SenecaMSC }; },{"../constants":7,"../modbusRtu":10,"../senecaModbus":13,"../utils":14,"./APIState":2,"loglevel":12}],7:[function(require,module,exports){ /** * Command type, aka mode value to be written into MSC current state register * */ const CommandType = { NONE_UNKNOWN: 0, /*** MEASURING FEATURES AFTER THIS POINT *******/ mA_passive: 1, mA_active: 2, V: 3, mV: 4, THERMO_J: 5, // Termocoppie THERMO_K: 6, THERMO_T: 7, THERMO_E: 8, THERMO_L: 9, THERMO_N: 10, THERMO_R: 11, THERMO_S: 12, THERMO_B: 13, PT100_2W: 14, // RTD 2 fili PT100_3W: 15, PT100_4W: 16, PT500_2W: 17, PT500_3W: 18, PT500_4W: 19, PT1000_2W: 20, PT1000_3W: 21, PT1000_4W: 22, Cu50_2W: 23, Cu50_3W: 24, Cu50_4W: 25, Cu100_2W: 26, Cu100_3W: 27, Cu100_4W: 28, Ni100_2W: 29, Ni100_3W: 30, Ni100_4W: 31, Ni120_2W: 32, Ni120_3W: 33, Ni120_4W: 34, LoadCell: 35, // Celle di carico Frequency: 36, // Frequenza PulseTrain: 37, // Conteggio impulsi RESERVED: 38, RESERVED_2: 40, Continuity: 41, OFF: 100, // ********* GENERATION AFTER THIS POINT *****************/ GEN_mA_passive: 101, GEN_mA_active: 102, GEN_V: 103, GEN_mV: 104, GEN_THERMO_J: 105, GEN_THERMO_K: 106, GEN_THERMO_T: 107, GEN_THERMO_E: 108, GEN_THERMO_L: 109, GEN_THERMO_N: 110, GEN_THERMO_R: 111, GEN_THERMO_S: 112, GEN_THERMO_B: 113, GEN_PT100_2W: 114, GEN_PT500_2W: 117, GEN_PT1000_2W: 120, GEN_Cu50_2W: 123, GEN_Cu100_2W: 126, GEN_Ni100_2W: 129, GEN_Ni120_2W: 132, GEN_LoadCell: 135, GEN_Frequency: 136, GEN_PulseTrain: 137, GEN_RESERVED: 138, // Special settings below this points SETTING_RESERVED: 1000, SET_UThreshold_F: 1001, SET_Sensitivity_uS: 1002, SET_ColdJunction: 1003, SET_Ulow: 1004, SET_Uhigh: 1005, SET_ShutdownDelay: 1006 }; const ContinuityImpl = CommandType.Cu50_2W; const ContinuityThresholdOhms = 75; /* * Internal state machine descriptions */ const State = { NOT_CONNECTED: "Not connected", CONNECTING: "Bluetooth device pairing...", DEVICE_PAIRED: "Device paired", SUBSCRIBING: "Bluetooth interfaces connecting...", IDLE: "Idle", BUSY: "Busy", ERROR: "Error", STOPPING: "Closing BT interfaces...", STOPPED: "Stopped", METER_INIT: "Meter connected", METER_INITIALIZING: "Reading meter state..." }; const ResultCode = { FAILED_NO_RETRY: 1, FAILED_SHOULD_RETRY: 2, SUCCESS: 0 }; const MAX_U_GEN = 27.0; // maximum voltage module.exports = {State, CommandType, ResultCode, MAX_U_GEN, ContinuityImpl, ContinuityThresholdOhms}; },{}],8:[function(require,module,exports){ "use strict"; const log = require("loglevel"); const constants = require("./constants"); const APIState = require("./classes/APIState"); const Command = require("./classes/Command"); const PublicAPI = require("./meterPublicAPI"); const TestData = require("./modbusTestData"); log.setLevel(log.levels.ERROR, true); exports.Stop = PublicAPI.Stop; exports.Pair = PublicAPI.Pair; exports.Execute = PublicAPI.Execute; exports.SimpleExecute = PublicAPI.SimpleExecute; exports.GetState = PublicAPI.GetState; exports.State = constants.State; exports.CommandType = constants.CommandType; exports.Command = Command; exports.Parse = PublicAPI.Parse; exports.log = log; exports.GetStateJSON = PublicAPI.GetStateJSON; exports.ExecuteJSON = PublicAPI.ExecuteJSON; exports.SimpleExecuteJSON = PublicAPI.SimpleExecuteJSON; exports.GetJsonTraces = TestData.GetJsonTraces; },{"./classes/APIState":2,"./classes/Command":3,"./constants":7,"./meterPublicAPI":9,"./modbusTestData":11,"loglevel":12}],9:[function(require,module,exports){ /* * This file contains the public API of the meter, i.e. the functions designed * to be called from third party code. * 1- Pair() : bool * 2- Execute(Command) : bool + JSON version * 3- Stop() : bool * 4- GetState() : array + JSON version * 5- SimpleExecute(Command) : returns the updated measurement or null */ var CommandResult = require("./classes/CommandResult"); var APIState = require("./classes/APIState"); var constants = require("./constants"); var bluetooth = require("./bluetooth"); var utils = require("./utils"); var log = require("loglevel"); var meterApi = require("./meterApi"); var btState = APIState.btState; var State = constants.State; /** * Returns a copy of the current state * @returns {array} status of meter */ async function GetState() { let ready = false; let initializing = false; switch (btState.state) { // States requiring user input case State.ERROR: case State.STOPPED: case State.NOT_CONNECTED: ready = false; initializing = false; break; case State.BUSY: case State.IDLE: ready = true; initializing = false; break; case State.CONNECTING: case State.DEVICE_PAIRED: case State.METER_INIT: case State.METER_INITIALIZING: case State.SUBSCRIBING: initializing = true; ready = false; break; default: ready = false; initializing = false; } return { "lastSetpoint": btState.lastSetpoint, "lastMeasure": btState.lastMeasure, "deviceName": btState.btDevice ? btState.btDevice.name : "", "deviceSerial": btState.meter?.serial, "stats": btState.stats, "deviceMode": btState.meter?.mode, "status": btState.state, "batteryLevel": btState.meter?.battery, "ready": ready, "initializing": initializing }; } /** * Provided for compatibility with Blazor * @returns {string} JSON state object */ async function GetStateJSON() { return JSON.stringify(await GetState()); } /** * Execute command with setpoints, JSON version * @param {string} jsonCommand the command to execute * @returns {string} JSON command object */ async function ExecuteJSON(jsonCommand) { let command = JSON.parse(jsonCommand); // deserialized object has lost its methods, let's recreate a complete one. let command2 = meterApi.Command.CreateTwoSP(command.type, command.setpoint, command.setpoint2); return JSON.stringify(await Execute(command2)); } async function SimpleExecuteJSON(jsonCommand) { let command = JSON.parse(jsonCommand); // deserialized object has lost its methods, let's recreate a complete one. let command2 = meterApi.Command.CreateTwoSP(command.type, command.setpoint, command.setpoint2); return JSON.stringify(await SimpleExecute(command2)); } /** * Execute a command and returns the measurement or setpoint with error flag and message * @param {Command} command */ async function SimpleExecute(command) { const SIMPLE_EXECUTE_TIMEOUT_S = 5; var cr = new CommandResult(); log.info("SimpleExecute called..."); if (command == null) { cr.success = false; cr.message = "Invalid command"; return cr; } command.pending = true; // In case caller does not set pending flag // Fail immediately if not paired. if (!btState.started) { cr.success = false; cr.message = "Device is not paired"; log.warn(cr.message); return cr; } // Another command may be pending. if (btState.command != null && btState.command.pending) { cr.success = false; cr.message = "Another command is pending"; log.warn(cr.message); return cr; } // Wait for completion of the command, or halt of the state machine btState.command = command; if (command != null) { await utils.waitForTimeout(() => !command.pending || btState.state == State.STOPPED, SIMPLE_EXECUTE_TIMEOUT_S); } // Check if error or timeouts if (command.error || command.pending) { cr.success = false; cr.message = "Error while executing the command."; log.warn(cr.message); // Reset the active command btState.command = null; return cr; } // State is updated by execute command, so we can use btState right away if (utils.isGeneration(command.type)) { cr.value = btState.lastSetpoint["Value"]; cr.unit = btState.lastSetpoint["Unit"]; } else if (utils.isMeasurement(command.type)) { cr.value = btState.lastMeasure["Value"]; cr.unit = btState.lastMeasure["Unit"]; cr.secondary_value = btState.lastMeasure["SecondaryValue"]; cr.secondary_unit = btState.lastMeasure["SecondaryUnit"]; } else { cr.value = 0.0; // Settings commands; } cr.success = true; cr.message = "Command executed successfully"; return cr; } /** * External interface to require a command to be executed. * The bluetooth device pairing window will open if device is not connected. * This may fail if called outside a user gesture. * @param {Command} command */ async function Execute(command) { log.info("Execute called..."); if (command == null) return null; command.pending = true; var cpt = 0; while (btState.command != null && btState.command.pending && cpt < 300) { log.debug("Waiting for current command to complete..."); await utils.sleep(100); cpt++; } log.info("Setting new command :" + command); btState.command = command; // Start the regular state machine if (!btState.started) { btState.state = State.NOT_CONNECTED; try { await bluetooth.stateMachine(); } catch (err) { log.error("Failed to start state machine:", err); command.error = true; command.pending = false; return command; } } // Wait for completion of the command, or halt of the state machine if (command != null) { await utils.waitFor(() => !command.pending || btState.state == State.STOPPED); } // Return the command object result return command; } /** * MUST BE CALLED FROM A USER GESTURE EVENT HANDLER * @returns {boolean} true if meter is ready to execute command * */ async function Pair(forceSelection = false) { log.info("Pair(" + forceSelection + ") called..."); btState.options["forceDeviceSelection"] = forceSelection; if (!btState.started) { btState.state = State.NOT_CONNECTED; bluetooth.stateMachine().catch((err) => { log.error("State machine failed during pairing:", err); btState.state = State.ERROR; }); // Start it } else if (btState.state == State.ERROR) { btState.state = State.NOT_CONNECTED; // Try to restart } await utils.waitFor(() => btState.state == State.IDLE || btState.state == State.STOPPED); log.info("Pairing completed, state :", btState.state); return (btState.state != State.STOPPED); } /** * Stops the state machine and disconnects bluetooth. * */ async function Stop() { log.info("Stop request received"); btState.stopRequest = true; await utils.sleep(100); while (btState.started || (btState.state != State.STOPPED && btState.state != State.NOT_CONNECTED)) { btState.stopRequest = true; await utils.sleep(100); } btState.command = null; btState.stopRequest = false; log.warn("Stopped on request."); return true; } module.exports = { Stop, Pair, Execute, ExecuteJSON, SimpleExecute, SimpleExecuteJSON, GetState, GetStateJSON, log }; },{"./bluetooth":1,"./classes/APIState":2,"./classes/CommandResult":4,"./constants":7,"./meterApi":8,"./utils":14,"loglevel":12}],10:[function(require,module,exports){ "use strict"; /******************************** MODBUS RTU handling ***********************************************/ var log = require("loglevel"); const SENECA_MB_SLAVE_ID = 25; // Modbus RTU slave ID class ModbusError extends Error { /** * Creates a new modbus error * @param {String} message message * @param {number} fc function code */ constructor(message, fc) { super(message); this.message = message; this.fc = fc; } } /** * Returns the 4 bytes CRC code from the buffer contents * @param {ArrayBuffer} buffer */ function crc16(buffer) { var crc = 0xFFFF; var odd; for (var i = 0; i < buffer.length; i++) { crc = crc ^ buffer[i]; for (var j = 0; j < 8; j++) { odd = crc & 0x0001; crc = crc >> 1; if (odd) { crc = crc ^ 0xA001; } } } return crc; } /** * Make a Modbus Read Holding Registers (FC=03) to serial port * * @param {number} ID slave ID * @param {number} count number of registers to read * @param {number} register starting register */ function makeFC3(ID, count, register) { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setUint8(0, ID); view.setUint8(1, 3); view.setUint16(2, register, false); view.setUint16(4, count, false); var crc = crc16(new Uint8Array(buffer.slice(0, -2))); view.setUint16(6, crc, true); return buffer; } /** * Write a Modbus "Preset Multiple Registers" (FC=16) to serial port. * * @param {number} address the slave unit address. * @param {number} dataAddress the Data Address of the first register. * @param {Array} array the array of values to write to registers. */ function makeFC16(address, dataAddress, array) { const code = 16; // sanity check if (typeof address === "undefined" || typeof dataAddress === "undefined") { return; } let dataLength = array.length; const codeLength = 7 + 2 * dataLength; const buf = new ArrayBuffer(codeLength + 2); // add 2 crc bytes const dv = new DataView(buf); dv.setUint8(0, address); dv.setUint8(1, code); dv.setUint16(2, dataAddress, false); dv.setUint16(4, dataLength, false); dv.setUint8(6, dataLength * 2); // copy content of array to buf for (let i = 0; i < dataLength; i++) { dv.setUint16(7 + 2 * i, array[i], false); } const crc = crc16(new Uint8Array(buf.slice(0, -2))); // add crc bytes to buffer dv.setUint16(codeLength, crc, true); return buf; } /** * Returns the registers values from a FC03 answer by RTU slave * * @param {ArrayBuffer} response */ function parseFC3(response) { if (!(response instanceof ArrayBuffer)) { return null; } const view = new DataView(response); // Invalid modbus packet if (response.length < 5) return; var computed_crc = crc16(new Uint8Array(response.slice(0, -2))); var actual_crc = view.getUint16(view.byteLength - 2, true); if (computed_crc != actual_crc) { throw new ModbusError("Wrong CRC (expected:" + computed_crc + ",got:" + actual_crc + ")", 3); } var address = view.getUint8(0); if (address != SENECA_MB_SLAVE_ID) { throw new ModbusError("Wrong slave ID :" + address, 3); } var fc = view.getUint8(1); if (fc > 128) { var exp = view.getUint8(2); throw new ModbusError("Exception by slave:" + exp, 3); } if (fc != 3) { throw new ModbusError("Wrong FC :" + fc, fc); } // Length in bytes from slave answer var length = view.getUint8(2); const buffer = new ArrayBuffer(length); const registers = new DataView(buffer); for (var i = 3; i < view.byteLength - 2; i += 2) { var reg = view.getInt16(i, false); registers.setInt16(i - 3, reg, false); var idx = ((i - 3) / 2 + 1); log.debug("\t\tRegister " + idx + "/" + (length / 2) + " = " + reg); } return registers; } /** * Check if the FC16 response is correct (CRC, return code) AND optionally matching the register length expected * @param {ArrayBuffer} response modbus rtu raw output * @param {number} expected number of expected written registers from slave. If <=0, it will not be checked. * @returns {boolean} true if all registers have been written */ function parseFC16checked(response, expected) { try { const result = parseFC16(response); return (expected <= 0 || result[1] === expected); // check if length is matching } catch (err) { log.error("FC16 answer error", err); return false; } } /** * Parse the answer to the write multiple registers from the slave * @param {ArrayBuffer} response */ function parseFC16(response) { const view = new DataView(response); if (response.length < 3) return; var slave = view.getUint8(0); if (slave != SENECA_MB_SLAVE_ID) { return; } var fc = view.getUint8(1); if (fc > 128) { var exp = view.getUint8(2); throw new ModbusError("Exception :" + exp, 16); } if (fc != 16) { throw new Mo