homebridge-miot
Version:
Homebridge plugin for devices supporting the miot protocol
1,411 lines (1,185 loc) • 44.8 kB
JavaScript
const EventEmitter = require('events');
const fetch = require('node-fetch');
const MiioProtocol = require('./MiioProtocol.js');
const MiotSpecFetcher = require('./MiotSpecFetcher.js');
const MiotService = require('./MiotService.js');
const MiotProperty = require('./MiotProperty.js');
const MiotAction = require('./MiotAction.js');
const MiotEvent = require('./MiotEvent.js');
const MiCloud = require('./MiCloud.js');
const DevTypes = require('../constants/DevTypes.js');
const PropFormat = require('../constants/PropFormat.js');
const PropUnit = require('../constants/PropUnit.js');
const PropAccess = require('../constants/PropAccess.js');
const Constants = require('../constants/Constants.js');
const Events = require('../constants/Events.js');
const Errors = require("../utils/Errors.js");
const MiotProtocolUtils = require('../utils/MiotProtocolUtils.js');
const MiCloudUtils = require('../utils/MiCloudUtils.js');
const ErrorUtils = require('../utils/ErrorUtils.js');
const COMMAND_GET = 'get_properties';
const COMMAND_SET = 'set_properties';
const COMMAND_ACTION = 'action';
const ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE = 14;
const MAX_POLL_RETRIES = 4;
// DEVICES: http://miot-spec.org/miot-spec-v2/instances?status=all
// device types: http://miot-spec.org/miot-spec-v2/spec/devices
// service types: http://miot-spec.org/miot-spec-v2/spec/services
// Miot subcommand response codes
// | 0 | Success |
// | 1 | Request received, but the operation has not been completed yet |
// | -4001 | Unreadable attribute |
// | -4002 | Attribute is not writable |
// | -4003 | Properties, methods, events do not exist |
// | -4004 | Other internal errors |
// | -4005 | Attribute value error |
// | -4006 | Method in parameter error |
// | -4007 | did error
class MiotDevice extends EventEmitter {
constructor(ip, token, deviceId, model, name, logger) {
super();
// config
this.ip = ip;
this.token = token;
this.deviceId = deviceId;
this.model = model;
this.name = name;
this.logger = logger;
if (!this.ip) {
this.logger.error(`ip required!`);
}
if (!this.token) {
this.logger.error(`token required!`);
}
//device spec
this.miotSpec = null;
//mi cloud
this.miCloud = undefined;
this.miCloudConfig = {};
this.isMiCloudRequired = false;
this.miCloudDeviceInfo = {};
//config
this.parseActionParams = true;
// prepare the variables
this.localConnected = false;
this.pollingInterval = Constants.DEFAULT_POLLING_INTERVAL;
this.services = [];
this.allPropertiesMap = {};
this.allActionsMap = {};
this.allEventsMap = {};
this.updateDevicePropertiesInterval = undefined;
this.pollRetries = 0;
this.allPropRequestChunkSize = ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE;
this.propertiesToMonitor = [];
this.reducedPollingInterval = null;
this.reconnectTimeoutTime = Math.max(this.pollingInterval * 4, 30000); // 4 times alive polling interval, at least 30 seconds
this.miioProtocol = new MiioProtocol(this.logger);
this.miioProtocol.updateDevice(this.ip, {
token: this.token
})
}
/*----------========== CONNECTION ==========----------*/
identify() {
if (this.model && this.model.length > 0) {
this.logger.info(`Model known: ${this.model}!`);
this.emit(Events.MIOT_DEVICE_IDENTIFIED, this); // we have model info so we already know what device we are dealing with
} else {
this.logger.info(`Model unknown! Starting device discovery!`);
this._identifyDevice();
}
}
startPropertyPolling() {
if (this.isConnected()) {
this._startPropertyPolling();
} else {
this._connectToDeviceAndPoll();
}
}
/*----------========== SETTINGS ==========----------*/
setMiCloudConfig(newMiCloudConfig = {}) {
this.miCloudConfig = newMiCloudConfig;
}
setPollingInterval(newPollingInterval = Constants.DEFAULT_POLLING_INTERVAL) {
if (newPollingInterval >= 1000) {
this.pollingInterval = newPollingInterval;
}
}
requiresMiCloud() {
return this.isMiCloudRequired;
}
setRequiresMiCloud(required = false) {
this.isMiCloudRequired = required;
}
getPropertiesToMonitor() {
return this.propertiesToMonitor || [];
}
setPropertiesToMonitor(propsToMonitor = []) {
propsToMonitor = Array.isArray(propsToMonitor) ? propsToMonitor : [];
this.propertiesToMonitor = [];
if (propsToMonitor.length > 0) {
this.propertiesToMonitor = propsToMonitor.filter(prop => this._canMonitorProperty(prop));
}
}
addPropertyToMonitor(miotProp) {
const propToMonitor = this.getProperty(miotProp);
//if length 0 then we already monitoring all properties so no need to add
if (this._canMonitorProperty(propToMonitor) && this.propertiesToMonitor && this.propertiesToMonitor.length !== 0 && !this.propertiesToMonitor.includes(propToMonitor.getName())) {
this.propertiesToMonitor.push(propToMonitor.getName());
}
}
getAllPropRequestChunkSize() {
return this.allPropRequestChunkSize || ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE;
}
setAllPropRequestChunkSize(chunkSize = ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE) {
if (chunkSize < 1) {
chunkSize = 1;
}
if (chunkSize > ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE) {
chunkSize = ALL_PROP_REQUEST_DEFAULT_CHUNK_SIZE;
}
this.allPropRequestChunkSize = chunkSize;
}
setMiotSpec(miotSpec) {
if (miotSpec) {
this.miotSpec = miotSpec;
this._parseMiotSpec();
}
}
isParseActionParams() {
return this.parseActionParams;
}
setParseActionParams(parseEnabled = true) {
this.parseActionParams = parseEnabled;
}
/*----------========== DEVICE CONTROL ==========----------*/
async pollProperties() {
if (this.isConnected()) {
await this.requestAllProperties();
this.emit(Events.MIOT_DEVICE_ALL_PROPERTIES_UPDATED, this);
this.logger.deepDebug(`Device properties updated: \n${this.getBeautifiedAllPropNameValues()}`);
} else {
throw new Error('Device not connected');
}
}
async fetchMiotSpec(forceFetch = false) {
if (!this.getMiotSpec() || forceFetch) {
this.logger.info(`Fetching device miot spec by model: ${this.getModel()}`);
const result = await MiotSpecFetcher.fetchMiotSpecByModel(this.getModel(), true);
this._processMiotSpecFetchResult(result);
}
return this.getMiotSpec();
}
async fetchMiotSpecFromUrl(specUrl, forceFetch = false) {
if (!this.getMiotSpec() || forceFetch) {
this.logger.info(`Fetching device miot spec from url: ${specUrl}`);
const result = await MiotSpecFetcher.fetchMiotSpecFromUrl(specUrl, true);
this._processMiotSpecFetchResult(result);
}
return this.getMiotSpec();
}
/*----------========== INFO ==========----------*/
// general
isConnected() {
if (this.shouldUseMiCloud()) {
return this.isConnectedToCloud();
}
return this.isConnectedToLocalDevice();
}
getModel() {
if (this.isConnectedToCloud()) {
return this.getCloudDeviceInfo().model;
}
if (this.isConnectedToLocalDevice()) {
return this.getDeviceInfo().model;
}
return this.model;
}
getDeviceId() {
return this.deviceId;
}
getFirmwareRevision() {
if (this.isConnectedToCloud()) {
if (this.getCloudDeviceInfo().extra) {
return this.getCloudDeviceInfo().extra.fw_version;
}
}
if (this.isConnectedToLocalDevice()) {
return this.getDeviceInfo().fw_ver;
}
return "unknown";
}
//local
isConnectedToLocalDevice() {
return this.localConnected;
}
getDeviceInfo() {
return this.miioProtocol.getDevice(this.ip).deviceInfo;
}
// micloud
shouldUseMiCloud() {
return this.requiresMiCloud() || this._getMiCloudForce() === true;
}
isConnectedToCloud() {
return this.isLoggedIntoMiCloud() && !!this.getCloudDeviceInfo();
}
isLoggedIntoMiCloud() {
return this.miCloud && this.miCloud.isLoggedIn();
}
getCloudDeviceInfo() {
return this.miCloudDeviceInfo;
}
//spec
getMiotSpec() {
return this.miotSpec;
}
getRawType() {
return this.getMiotSpec() ? this.getMiotSpec().type : '';
}
getType() {
return this.getRawType().split(':')[3] || null;
}
getDescription() {
return this.getMiotSpec() ? this.getMiotSpec().description : '';
}
/*----------========== GENERAL DEVICE CONNECTION ==========----------*/
async _identifyDevice() {
this._prepareNewConnection();
try {
if (this.shouldUseMiCloud()) {
await this._identifyCloudDevice();
} else {
await this._identifyLocalDevice();
}
} catch (err) {
this._handleConnectionError(err, 'device identify', this._identifyDevice.bind(this));
}
}
async _connectToDeviceAndPoll() {
this._prepareNewConnection();
try {
if (this.shouldUseMiCloud()) {
await this._connectToCloudDevice();
} else {
await this._connectToLocalDevice();
}
this._startPropertyPolling();
} catch (err) {
this._handleConnectionError(err, 'connection', this._connectToDeviceAndPoll.bind(this));
}
}
_disconnect() {
this.localConnected = false;
this._clearTimeoutAndInterval();
// if required, also refresh the micloud connection
if (this.shouldUseMiCloud()) {
if (this.isLoggedIntoMiCloud()) {
this.miCloud.logout();
}
}
this.logger.debug('Device diconnected!');
this.emit(Events.MIOT_DEVICE_DISCONNECTED, this);
}
/*----------========== LOCAL DEVICE ==========----------*/
async _connectToLocalDevice() {
if (!this.isConnectedToLocalDevice()) {
await this.miioProtocol.handshake(this.ip);
const result = await this.miioProtocol.getInfo(this.ip);
this.localConnected = true;
this.logger.info(`Device found! Setting up miot device from local connection!`);
await this._localDeviceSetup();
this.logger.info(`Connected to device: ${result.model}`);
this.emit(Events.MIOT_DEVICE_CONNECTED, this);
}
}
async _localDeviceSetup() {
// log some basic device info
this.logger.debug(`Device firmware: ${this.getFirmwareRevision()}`);
this.logger.deepDebug(`Full device info: \n${JSON.stringify(this.getDeviceInfo(), null, 2)}`);
// retrieve the deviceId from the device
if (!this.getDeviceId()) {
this.deviceId = String(this.miioProtocol.getDevice(this.ip).did); // returns did as number, so convert o string
if (this.getDeviceId()) {
this.logger.info(`Did not specified. Got did: ${this.getDeviceId()} from device!`);
}
}
this._validateDid();
// validate if the specified model is the same as the actual model of the connected device
this._validateModel();
}
async _identifyLocalDevice() {
await this._connectToLocalDevice();
let foundDeviceModel = this.getDeviceInfo().model;
this._identfiyDeviceInternal(foundDeviceModel);
}
/*----------========== MICLOUD DEVICE ==========----------*/
async _connectToCloudDevice() {
if (!this.isConnectedToCloud()) {
if (this._canUseMiCloud()) {
if (!this.miCloud) {
this.miCloud = new MiCloud(this.logger);
//set timeout
let requestTimeout = parseInt(this._getMiCloudTimeout());
if (requestTimeout) {
requestTimeout = requestTimeout > this.pollingInterval ? this.pollingInterval : requestTimeout; // make sure we do not exceed polling interval
this.miCloud.setRequestTimeout(requestTimeout);
}
// set request encryption
this.miCloud.setUseUnencryptedRequests(this._getMiCloudUseUnencryptedRequests());
}
try {
this.logger.info(`Log in to MiCloud!`);
await this.miCloud.login(this._getMiCloudUsername(), this._getMiCloudPassword());
this.logger.debug(`Using server country: ${this._getMiCloudCountry()}`);
this.miCloud.setCountry(this._getMiCloudCountry());
this.logger.info(`Successfully connected to MiCloud! Setting up miot device from MiCloud connection!`);
await this._cloudDeviceSetup();
this.logger.info(`Connected to device: ${this.getModel()}`);
this.emit(Events.MIOT_DEVICE_CONNECTED, this);
} catch (err) {
this.logger.debug(`Connection to MiCloud device failed!`);
this.logger.deepDebug(err.stack);
throw err;
}
} else {
throw new Errors.MissingMiCloudCredentials();
}
}
}
async _cloudDeviceSetup() {
// when no device id specified try to retrieve the deviceId from the device
if (!this.getDeviceId()) {
this.logger.info(`DeviceId not specified but is required for a MiCloud connection! Trying to connect to the device using a local connection and get the deviceId!`);
await this._connectToLocalDevice();
}
// validate that we have a deviceId
this._validateDid();
// fetch the cloud device info
await this._fetchCloudDeviceInfoDevice();
// validate if the specified model is the same as the actual model of the connected device
this._validateModel();
// check if the device is online, soft warning that the device is not online
if (!this.getCloudDeviceInfo().isOnline) {
this.logger.warn(`The device with deviceId ${ this.getDeviceId() } is not online! Device must be online for proper synchronization!`);
}
}
async _identifyCloudDevice() {
await this._connectToCloudDevice();
let foundDeviceModel = this.getCloudDeviceInfo().model;
this._identfiyDeviceInternal(foundDeviceModel);
}
async _fetchCloudDeviceInfoDevice() {
this.logger.debug(`Fetching device info from the MiCloud!`);
let result = await this.miCloud.getDevice(this.getDeviceId());
if (result != undefined) {
this.miCloudDeviceInfo = result;
this.logger.debug(`Got device info from MiCloud: \n${JSON.stringify(result, null, 2)}`);
} else {
throw new Errors.DeviceNotFound(this.getDeviceId(), this._getMiCloudCountry());
}
}
/*----------========== INTERNAL HELPERS ==========----------*/
_getReconnectTimeoutSeconds() {
return this.reconnectTimeoutTime / 1000;
}
_runTimeoutAction(actionFunc) {
this.currentRunningTimeout = setTimeout(() => {
actionFunc();
}, this.reconnectTimeoutTime);
}
_clearTimeoutAndInterval() {
clearTimeout(this.currentRunningTimeout);
this.currentRunningTimeout = undefined;
clearInterval(this.updateDevicePropertiesInterval);
this.updateDevicePropertiesInterval = undefined;
}
_prepareNewConnection() {
clearTimeout(this.currentRunningTimeout); //clear any previous discoveries which might be running
}
async _handleConnectionError(err, actionName, timeoutFunction) {
if (ErrorUtils.isNonRecoverableError(err)) { // non recoverable errors, do not try to reconnect
this.logger.error(err);
this.logger.error(`Stopping ${ actionName }! Missing or invalid required information!`);
} else {
this.logger.debug(err);
this.logger.debug(`Error during ${ actionName }! Retrying in ${ this._getReconnectTimeoutSeconds() } seconds!`);
if (this.isConnected()) {
this._disconnect();
}
this._runTimeoutAction(timeoutFunction.bind(this));
}
}
async _identfiyDeviceInternal(foundDeviceModel) {
if (foundDeviceModel) {
if (!this.model) {
this.model = foundDeviceModel;
this.logger.info(`Device identified! Model: ${foundDeviceModel}`);
this.emit(Events.MIOT_DEVICE_IDENTIFIED, this);
}
} else {
throw new Errors.UnknownDeviceModel();
}
}
_validateDid() {
//make sure did is in a string format, if not then correct, did must be of type string for micloud request else cloud will respond with -8 "data type not valid"
if (this.getDeviceId() && !this._isString(this.getDeviceId())) {
this.logger.debug(`Did is not of type string. Converting to string!`);
this.deviceId = String(this.getDeviceId());
}
// make sure that we have the deviceId
if (!this.getDeviceId()) {
if (this.shouldUseMiCloud()) {
throw new Errors.MissingDeviceId(this.name);
} else {
// just a soft warning since local device the control works also without did
this.logger.warn(`Could not find deviceId for ${this.name}! This may cause issues! Please specify a deviceId in the 'config.json' file!`);
}
}
}
_validateModel() {
if (this.model && this.model !== this.getModel()) {
this.logger.debug(`Found device model ${this.getModel()} does not match specified model ${this.model}! Some services might not work correctly!`);
}
}
/*----------========== DEVICE LIFECYCLE ==========----------*/
async _startPropertyPolling() {
if (!this.updateDevicePropertiesInterval) {
// initial properties fetch
await this._doInitialPropertiesFetch();
// start device property polling
this._pollDeviceProperties();
} else {
this.logger.info(`Property polling is already in running!`);
}
}
async _doInitialPropertiesFetch() {
this.logger.info(`Doing initial property fetch.`);
try {
// initial properties fetch
await this.requestAllProperties();
// on initial connection log the retrieved properties
this.logger.debug(`Got initial device properties: \n${this.getBeautifiedAllPropNameValues()}`);
this.emit(Events.MIOT_DEVICE_INITIAL_PROPERTY_FETCH_DONE, this);
} catch (err) {
this.logger.debug(`Error on initial property request! ${err}`);
}
}
_pollDeviceProperties() {
this.logger.info(`Starting property polling.`);
this.updateDevicePropertiesInterval = setInterval(async () => {
try {
await this.pollProperties();
this.pollRetries = 0;
} catch (err) {
this.pollRetries++;
this.logger.debug(`Poll failed ${this.pollRetries} times!`);
if (this.updateDevicePropertiesInterval && this.pollRetries >= MAX_POLL_RETRIES) {
let warnMsg = `Poll failed ${this.pollRetries} times in a row! Stopping polling and trying to reconnect! Reason: ${err}`;
this.pollRetries = 0;
if (err instanceof Errors.DeviceNotOnline) { // no need to disconnect when device is not online, just retry after some time
this.logger.debug(warnMsg); // hide the device offline message behind debug as this is expected!
this._restartPolling();
} else {
this.logger.warn(warnMsg);
this._reconnectAndRestartPolling();
}
} else {
this.logger.debug(`Poll failed! ${err}`);
this._checkIfPollingIntervalReductionIsNecessary(err);
}
}
}, this.pollingInterval);
}
_checkIfPollingIntervalReductionIsNecessary(err) {
//if we have a 'user ack timeout' response from device then most likley the device is spammed by to many commands, so let's reduce the polling interval
if (!this.reducedPollingInterval && err && err.message && err.message.includes("user ack timeout")) {
this.reducedPollingInterval = this.pollingInterval * 2;
this.pollingInterval = this.reducedPollingInterval;
}
}
_restartPolling() {
this._clearTimeoutAndInterval();
this.logger.debug(`Restarting polling in ${ this._getReconnectTimeoutSeconds() } seconds...`);
this._runTimeoutAction(this._pollDeviceProperties.bind(this));
}
_reconnectAndRestartPolling() {
this._disconnect();
this.logger.debug(`Reconnecting and restarting polling in ${ this._getReconnectTimeoutSeconds() } seconds...`);
this._runTimeoutAction(this._connectToDeviceAndPoll.bind(this));
}
/*----------========== MICLOUD CONFIG ==========----------*/
_getMiCloudConfigProp(key) {
if (this.miCloudConfig) {
if (this.miCloudConfig.device && Object.keys(this.miCloudConfig.device).includes(key)) {
return this.miCloudConfig.device[key];
} else if (this.miCloudConfig.global && Object.keys(this.miCloudConfig.global).includes(key)) {
return this.miCloudConfig.global[key];
}
}
return null;
}
_canUseMiCloud() {
return this.miCloudConfig && this._getMiCloudUsername() && this._getMiCloudPassword();
}
_getMiCloudUsername() {
return this._getMiCloudConfigProp('username');
}
_getMiCloudPassword() {
return this._getMiCloudConfigProp('password');
}
_getMiCloudCountry() {
return this._getMiCloudConfigProp('country') || 'cn';
}
_getMiCloudTimeout() {
return this._getMiCloudConfigProp('timeout');
}
_getMiCloudForce() {
return this._getMiCloudConfigProp('forceMiCloud');
}
_getMiCloudUseUnencryptedRequests() {
return this._getMiCloudConfigProp('unencryptedRequests');
}
/*----------========== MIOT SPEC ==========----------*/
_processMiotSpecFetchResult(result) {
this.setMiotSpec(result);
this.logger.info(`Successfully got device miot spec!`);
this.logger.info(`Spec device type: ${this.getType()} description: ${this.getDescription()}`);
this.logger.deepDebug(`Miot spec: ${JSON.stringify(result, null, 2)}`);
this.emit(Events.MIOT_DEVICE_SPEC_FETCHED, this);
}
_parseMiotSpec() {
if (this.getMiotSpec()) {
this.logger.debug(`Parsing miot spec!`);
//reset variables
if (this.services && this.services.length > 0) {
this.logger.debug(`Already have parsed spec! Resetting!`);
this.services = [];
this.allPropertiesMap = {};
this.allActionsMap = {};
this.allEventsMap = {};
}
const {
services,
properties,
actions,
events
} = this.getMiotSpec();
if (services) {
services.forEach((serviceSpec) => {
this.createServiceBySpec(serviceSpec);
});
}
if (properties) {
Object.keys(properties).forEach(key => {
const propSpec = properties[key];
this.addPropertyBySpec(key, propSpec);
});
}
if (actions) {
Object.keys(actions).forEach(key => {
const actionSpec = actions[key];
this.addActionBySpec(key, actionSpec);
});
}
if (events) {
Object.keys(events).forEach(key => {
const eventSpec = events[key];
this.addEventBySpec(key, eventSpec);
});
}
}
}
/*----------========== METADATA ==========----------*/
// services
createService(siid, type, description) {
if (!MiotProtocolUtils.isValidMiotId(siid)) {
this.logger.warn(`Missing or invalid siid (${siid})! Cannot create service! Type: ${type} Description: ${description}`);
return;
}
if (this.hasServiceWithId(siid)) {
this.logger.warn(`Service with siid ${siid} already exists. Not creating!`);
return;
}
let newService = new MiotService(siid, type, description);
this.services.push(newService);
return newService;
}
createServiceBySpec(serviceSpec) {
return this.createService(serviceSpec.siid, serviceSpec.type, serviceSpec.description);
}
createServiceByString(specString) {
let service = undefined;
try {
const serviceSpec = JSON.parse(specString);
service = this.createServiceBySpec(serviceSpec);
} catch (err) {
this.logger.warn(`Failed to parse miot service by string! Spec string: ${specString}!`);
this.logger.debug(err);
}
return service;
}
getServiceById(siid) {
if (!siid) {
return null;
}
return this.services.find(service => service.getId() == siid);
}
getServiceByType(type) {
if (!type) {
return null;
}
return this.services.find(service => service.getType() === type);
}
getAllServicesByType(type) {
if (!type) {
return [];
}
return this.services.filter(service => service.getType() === type);
}
hasServiceWithId(siid) {
return !!this.getServiceById(siid);
}
hasServiceWithType(type) {
return !!this.getServiceByType(type);
}
getAllServices() {
return this.services;
}
getAllServiceTypes() {
return this.getAllServices().map(service => service.getType());
}
// properties
addProperty(name, siid, piid, type, description, format, access, unit, valueRange, valueList) {
if (!name) {
this.logger.warn(`Missing name! Cannot create property!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(siid)) {
this.logger.warn(`Missing or invalid siid (${siid}) for ${name} property! Cannot create!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(piid)) {
this.logger.warn(`Missing or invalid piid (${piid}) for ${name} property! Cannot create!`);
return;
}
if (this.hasPropertyByName(name)) {
this.logger.warn(`Property with name ${name} already exists. Not creating!`);
return;
}
let newProp = new MiotProperty(name, siid, piid, type, description, format, access, unit, valueRange, valueList);
this.allPropertiesMap[name] = newProp;
const service = this.getServiceById(siid);
if (service) {
service.addProperty(newProp);
} else {
this.logger.debug(`Could not find service with id ${siid}! Property not added to service!`);
}
return newProp;
}
addPropertyBySpec(name, propSpec) {
return this.addProperty(propSpec.name || name, propSpec.siid, propSpec.piid, propSpec.type, propSpec.description, propSpec.format, propSpec.access, propSpec.unit, propSpec.valueRange, propSpec.valueList);
}
addPropertyByString(name, specString) {
let prop = undefined;
try {
const propSpec = JSON.parse(specString);
prop = this.addPropertyBySpec(name, propSpec);
} catch (err) {
this.logger.warn(`Failed to parse miot property by string ${name}!`);
}
return prop;
}
hasProperty(miotProp) {
return this.getProperty(miotProp) !== null;
}
hasPropertyByName(propName) {
return this.getPropertyByName(propName) !== null;
}
hasPropertyById(propId) {
return this.getPropertyById(propId) !== null;
}
getProperty(miotProp) {
if (this._isPropertyObj(miotProp)) {
return miotProp;
}
if (this._isString(miotProp)) {
if (this.hasPropertyByName(miotProp)) {
return this.getPropertyByName(miotProp);
} else if (this.hasPropertyById(miotProp)) {
return this.getPropertyById(miotProp);
}
}
return null;
}
getPropertyByName(propName) {
return this.allPropertiesMap[propName] || null;
}
getPropertyById(propId) {
if (propId && propId.includes('.')) {
const siid = propId.split('.')[0];
const piid = propId.split('.')[1];
const tmpService = this.getAllServices().find(service => service.getId() == siid);
if (tmpService) {
return tmpService.getPropertyById(piid);
}
}
return null;
}
findPropertyByType(propType) {
return Object.values(this.getAllProperties()).find(tmpProp => tmpProp.getType() === propType);
}
getAllProperties() {
return this.allPropertiesMap;
}
getAllPropertyNames() {
return Object.keys(this.allPropertiesMap);
}
getBeautifiedAllPropNameValues() {
// only readable properties
let readablePropNames = this.getAllPropertyNames().filter(name => this.getPropertyByName(name).isReadable());
let propNameValues = readablePropNames.map(name => this.getPropertyByName(name).getNameValueString());
return JSON.stringify(propNameValues, null, 2);
}
// actions
addAction(name, siid, aiid, type, description, inDef) {
if (!name) {
this.logger.warn(`Missing name! Cannot create action!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(siid)) {
this.logger.warn(`Missing or invalid siid (${siid}) for ${name} property! Cannot create!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(aiid)) {
this.logger.warn(`Missing or invalid aiid (${aiid}) for ${name} action! Cannot create!`);
return;
}
if (this.hasPropertyByName(name)) {
this.logger.warn(`Action with name ${name} already exists. Not creating!`);
return;
}
let newAction = new MiotAction(name, siid, aiid, type, description, inDef);
this.allActionsMap[name] = newAction;
const service = this.getServiceById(siid);
if (service) {
service.addAction(newAction);
} else {
this.logger.debug(`Could not find service with id ${siid}! Action not added to service!`);
}
return newAction;
}
addActionBySpec(name, actionSpec) {
return this.addAction(actionSpec.name || name, actionSpec.siid, actionSpec.aiid, actionSpec.type, actionSpec.description, actionSpec.in);
}
addActionByString(name, specString) {
let action = undefined;
try {
const actionSpec = JSON.parse(specString);
action = this.addActionBySpec(name, actionSpec);
} catch (err) {
this.logger.warn(`Failed to parse miot action by string ${name}!`);
}
return action;
}
hasAction(miotAction) {
return this.getAction(miotAction) !== null;
}
hasActionByName(actionName) {
return this.getActionByName(actionName) !== null;
}
hasActionById(actionId) {
return this.getActionById(actionId) !== null;
}
hasActions() {
return this.allActionsMap && Object.keys(this.allActionsMap).length > 0;
}
getAction(miotAction) {
if (this._isActionObj(miotAction)) {
return miotAction;
}
if (this._isString(miotAction)) {
if (this.hasActionByName(miotAction)) {
return this.getActionByName(miotAction);
} else if (this.hasActionById(miotAction)) {
return this.getActionById(miotAction);
}
}
return null;
}
getActionByName(actionName) {
return this.allActionsMap[actionName] || null;
}
getActionById(actionId) {
if (actionId && actionId.includes('.')) {
const siid = actionId.split('.')[0];
const aiid = actionId.split('.')[1];
const tmpService = this.getAllServices().find(service => service.getId() == siid);
if (tmpService) {
return tmpService.getActionById(aiid);
}
}
return null;
}
findActionByType(actionType) {
return Object.values(this.getAllActions()).find(tmpAction => tmpAction.getType() === actionType);
}
getAllActions() {
return this.allActionsMap;
}
getAllActionNames() {
return Object.keys(this.allActionsMap);
}
// events
addEvent(name, siid, eiid, type, description, argumentsDef) {
if (!name) {
this.logger.warn(`Missing name! Cannot create event!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(siid)) {
this.logger.warn(`Missing or invalid siid (${siid}) for ${name} property! Cannot create!`);
return;
}
if (!MiotProtocolUtils.isValidMiotId(eiid)) {
this.logger.warn(`Missing or invalid eiid (${eiid}) for ${name} event! Cannot create!`);
return;
}
if (this.hasPropertyByName(name)) {
this.logger.warn(`Event with name ${name} already exists. Not creating!`);
return;
}
let newEvent = new MiotEvent(name, siid, eiid, type, description, argumentsDef);
this.allEventsMap[name] = newEvent;
const service = this.getServiceById(siid);
if (service) {
service.addEvent(newEvent);
} else {
this.logger.debug(`Could not find service with id ${siid}! Event not added to service!`);
}
return newEvent;
}
addEventBySpec(name, eventSpec) {
return this.addEvent(eventSpec.name || name, eventSpec.siid, eventSpec.eiid, eventSpec.type, eventSpec.description, eventSpec.arguments);
}
addEventByString(name, specString) {
let action = undefined;
try {
const eventSpec = JSON.parse(specString);
action = this.addEventBySpec(name, eventSpec);
} catch (err) {
this.logger.warn(`Failed to parse miot event by string ${name}!`);
}
return action;
}
hasEvent(miotEvent) {
return this.getEvent(miotEvent) !== null;
}
hasEventByName(eventName) {
return this.getEventByName(eventName) !== null;
}
hasEventById(eventId) {
return this.getEventById(eventId) !== null;
}
hasEvents() {
return this.allEventsMap && Object.keys(this.allEventsMap).length > 0;
}
getEvent(miotEvent) {
if (this._isEventObj(miotEvent)) {
return miotEvent;
}
if (this._isString(miotEvent)) {
if (this.hasEventByName(miotEvent)) {
return this.getEventByName(miotEvent);
} else if (this.hasEventById(miotEvent)) {
return this.getEventById(miotEvent);
}
}
return null;
}
getEventByName(eventName) {
return this.allEventsMap[eventName] || null;
}
getEventById(eventId) {
if (eventId && eventId.includes('.')) {
const siid = eventId.split('.')[0];
const eiid = eventId.split('.')[1];
const tmpService = this.getAllServices().find(service => service.getId() == siid);
if (tmpService) {
return tmpService.getEventById(eiid);
}
}
return null;
}
findEventByType(evenType) {
return Object.values(this.getAllEvents()).find(tmpEvent => tmpEvent.getType() === evenType);
}
getAllEvents() {
return this.allEventsMap;
}
getAllEventNames() {
return Object.keys(this.allEventsMap);
}
/*----------========== PROTOCOL ==========----------*/
// properties
// seems like there is a limit on how many props the device can process at once, observed a limit of 16
// for this reason we are going to split all props into chunks and do sepearte requests for each chunk
async requestAllProperties() {
if (this.isConnected()) {
let propRequestPromises = [];
let propNameChunks = this._getAllReadablePropNameChunks();
this.logger.debug(`Preparing property poll!`);
this.logger.debug(`Splitting properties into chunks. Number of chunks: ${propNameChunks.length}. Chunk size: ${this.getAllPropRequestChunkSize()}`);
this.logger.deepDebug(`Chunks: ${JSON.stringify(propNameChunks, null, 1)}`);
propNameChunks.forEach((propChunk) => {
propRequestPromises.push(this.requestPropertyChunk(propChunk));
});
return Promise.all(propRequestPromises);
} else {
return this._createErrorPromise(`Cannot poll all properties! Device not connected!`);
}
}
async requestPropertyChunk(propKeys) {
if (!propKeys) {
return this._createErrorPromise(`Cannot request property chunk values! No property keys specified!`);
}
if (!this.isConnected()) {
return this._createErrorPromise(`Cannot request property chunk values! Device not connected!`);
}
let protcolProps = propKeys.map(key => this.getPropertyByName(key).getReadProtocolObjForDid(this.getDeviceId()));
const result = await this.getMiotProperties(protcolProps);
// if no response or response empty then throw an error so that the the poll will fail
if (!result || result.length === 0) {
throw new Error(`No response or response empty!`);
}
this.logger.deepDebug(`Successfully updated property chunk! RAW: ${JSON.stringify(result)}`);
// all good, process the data
const obj = {};
for (let i = 0; i < result.length; i++) {
this._updatePropertyValueFromDevice(obj, propKeys[i], result[i]);
}
return obj;
}
// currently not used, but can be used to retrieve a single property value
async requestProperty(prop) {
try {
if (!prop) {
throw new Error(`Missing property! Cannot execute read request!`);
}
if (!this.isConnected()) {
throw new Error(`Cannot update property ${prop.getName()}! Device not connected!`);
}
if (!this.isReadable()) {
throw new Error(`Cannot update property ${prop.getName()}! Property is not readable!`);
}
let propDef = prop.getReadProtocolObjForDid(this.getDeviceId());
this.logger.deepDebug(`Request ${prop.getName()} property! RAW: ${JSON.stringify(propDef)}`);
const result = await this.getMiotProperties([propDef]);
if (!result || result.length === 0) {
throw new Error(`No response or response empty!`);
}
this.logger.debug(`Successfully updated property ${prop} value! Result: ${JSON.stringify(result)}`);
const obj = {};
this._updatePropertyValueFromDevice(obj, prop.getName(), result[0]);
this.emit(Events.MIOT_DEVICE_PROPERTY_VALUE_UPDATED, prop);
return obj;
} catch (err) {
this.logger.debug(err);
}
}
// set property
async setProperty(prop, value) {
try {
if (!prop) {
throw new Error(`Missing property! Cannot set the value!`);
}
if (!this.isConnected()) {
throw new Error(`Cannot set property ${prop.getName()} to value ${value}! Device not connected!`);
}
let propDef = prop.getWriteProtocolObjForDid(this.getDeviceId(), value);
this.logger.deepDebug(`Set ${prop.getName()} property request! RAW: ${JSON.stringify(propDef)}`);
const result = await this.setMiotProperties([propDef]);
if (this._isResponseValid(result[0])) {
this.logger.debug(`Successfully set property ${prop.getName()} to value ${value}! Response: ${JSON.stringify(result)}`);
prop.updateInternalValue(value); // do not wait for poll, update the local prop and notifiy listeners after successful set
this.emit(Events.MIOT_DEVICE_PROPERTY_VALUE_SET, prop);
return result;
} else {
throw new Error(`Error while setting property ${prop.getName()} to value ${value}! Invalid response. Response: ${JSON.stringify(result)}`);
}
} catch (err) {
this.logger.debug(err);
}
}
// actions
async executeAction(action, paramValues = []) {
try {
if (!action) {
throw new Error(`Missing action! Cannot execute action request!`);
}
if (!this.isConnected()) {
throw new Error(`Cannot execute action ${action.getName()} with params ${JSON.stringify(paramValues)}! Device not connected!`);
}
let actionDef = action.getProtocolAction(this.getDeviceId(), paramValues, this.isParseActionParams());
this.logger.deepDebug(`Send action! RAW: ${JSON.stringify(actionDef)}`);
const result = await this.miotAction(actionDef);
action.setLastResult(result);
if (this._isResponseValid(result)) {
this.logger.debug(`Successfully executed action ${action.getName()} with params ${paramValues}! Result: ${JSON.stringify(result)}`);
this.emit(Events.MIOT_DEVICE_ACTION_EXECUTED, action);
return result;
} else {
throw new Error(`Error while executing action ${action.getName()} with params ${paramValues}! Invalid response from device. Response: ${JSON.stringify(result)}`);
}
} catch (err) {
this.logger.debug(err);
}
}
//method
async executeMethod(methodName, paramValues = []) {
if (!methodName) {
throw new Error(`Missing method! Cannot execute method request!`);
}
if (!this.isConnected()) {
throw new Error(`Cannot execute method ${methodName} with params ${JSON.stringify(paramValues)}! Device not connected!`);
}
this.logger.deepDebug(`Executing method! Method: ${methodName} Params: ${JSON.stringify(paramValues)}`);
const result = await this.miotSend(methodName, paramValues);
if (result && !result.includes("unknown_method")) {
this.logger.debug(`Successfully executed method ${methodName} with params ${paramValues}! Result: ${JSON.stringify(result)}`);
this.emit(Events.MIOT_DEVICE_METHOD_EXECUTED, methodName);
return result;
} else {
throw new Error(`Error while executing method ${methodName} with params ${JSON.stringify(paramValues)}! Invalid response from device. Response: ${JSON.stringify(result)}`);
}
}
/*----------========== PROTOCOL CALLS ==========----------*/
async getMiotProperties(params = []) {
if (this.shouldUseMiCloud() && this.isConnectedToCloud()) {
return this.miCloud.miotGetProps(params);
} else if (this.isConnectedToLocalDevice()) {
return this.miioProtocol.send(this.ip, COMMAND_GET, params);
} else {
throw new Error(`Not connected to device!`);
}
}
async setMiotProperties(params = []) {
if (this.shouldUseMiCloud() && this.isConnectedToCloud()) {
return this.miCloud.miotSetProps(params);
} else if (this.isConnectedToLocalDevice()) {
return this.miioProtocol.send(this.ip, COMMAND_SET, params);
} else {
throw new Error(`Not connected to device!`);
}
}
async miotAction(param = {}) {
if (this.shouldUseMiCloud() && this.isConnectedToCloud()) {
return this.miCloud.miotAction(param);
} else if (this.isConnectedToLocalDevice()) {
return this.miioProtocol.send(this.ip, COMMAND_ACTION, param);
} else {
throw new Error(`Not connected to device!`);
}
}
async miotSend(methodName, params = []) {
if (this.shouldUseMiCloud() && this.isConnectedToCloud()) {
return this.miCloud.miioCall(this.getDeviceId(), methodName, params);
} else if (this.isConnectedToLocalDevice()) {
return this.miioProtocol.send(this.ip, methodName, params);
} else {
throw new Error(`Not connected to device!`);
}
}
/*----------========== PROTOCOL HELPERS ==========----------*/
_canMonitorProperty(miotProp) {
let tmpProp = this.getProperty(miotProp);
if (tmpProp && tmpProp.isReadable()) {
return true;
}
return false;
}
_getPropKeysToMonitor() {
let tmpPropKeysToMonitor = this.getPropertiesToMonitor();
if (!tmpPropKeysToMonitor || tmpPropKeysToMonitor.length === 0) {
tmpPropKeysToMonitor = this.getAllPropertyNames();
}
return tmpPropKeysToMonitor;
}
// get only properties which are reabadle
_getReadablePropsToMonitor() {
let tmpReadablePropsToMonitor = {};
this._getPropKeysToMonitor().forEach((key) => {
let tmpProp = this.getPropertyByName(key);
if (tmpProp && tmpProp.isReadable()) {
tmpReadablePropsToMonitor[key] = tmpProp;
}
});
return tmpReadablePropsToMonitor;
}
// split all readable props into smaller chunks
_getAllReadablePropNameChunks() {
let propertyChunks = [];
const allReadableProps = this._getReadablePropsToMonitor();
const readablePropNames = Object.keys(allReadableProps);
for (let i = 0; i < readablePropNames.length; i += this.getAllPropRequestChunkSize()) {
let propChunk = readablePropNames.slice(i, i + this.getAllPropRequestChunkSize());
propertyChunks.push(propChunk);
}
return propertyChunks;
}
// updates the property value with the value retrieved from the device
_updatePropertyValueFromDevice(result, propName, response) {
if (this._isResponseValid(response)) {
this.getPropertyByName(propName).updateInternalValue(response.value);
result[propName] = response.value;
} else {
this.logger.debug(`Error while parsing response from device for property ${propName}. Response: ${JSON.stringify(response)}`);
}
}
_isResponseValid(response) {
// "code" == 0 -> request was successful
if (response && response.code === 0) {
return true;
}
// on some devices no "code" is send back in the response altrough the request was successful (only for getting properties, actions and property setters does not send back a value)
if (response && response.code == null && response.value != null) {
return true;
}
// code -704042011 -> device offline for cloud devices
if (response && MiCloudUtils.isDeviceOfflineResponseCode(response.code)) {
throw new Errors.DeviceNotOnline(this.getDeviceId());
return false;
}
return false;
}
/*----------========== HELPERS ==========----------*/
_createErrorPromise(msg) {
return new Promise((resolve, reject) => {
reject(new Error(msg));
}).catch(err => {
this.logger.debug(err);
});
}
_isString(object) {
if (typeof object === 'string' || object instanceof String) {
return true;
}
return false;
}
_isPropertyObj(object) {
if (object instanceof MiotProperty) {
return true;
}
return false;
}
_isActionObj(object) {
if (object instanceof MiotAction) {
return true;
}
return false;
}
_isEventObj(object) {
if (object instanceof MiotEvent) {
return true;
}
return false;
}
}
module.exports = MiotDevice;