UNPKG

pimatic

Version:

A home automation server and framework for the Raspberry PI running on node.js

807 lines (738 loc) 27.5 kB
### Variable Manager =========== ### assert = require 'cassert' util = require 'util' Promise = require 'bluebird' _ = require 'lodash' S = require 'string' M = require './matcher' humanFormat = require 'human-format' isNumber = (n) -> "#{n}".match(/^-?[0-9]+\.?[0-9]*$/)? varsAst = require './variables-ast-builder' module.exports = (env) -> class Variable name: null value: null type: 'value' readonly: no unit: null constructor: (@_vars, @name, @type, @unit, @readonly) -> assert @_vars? assert @_vars instanceof VariableManager assert typeof @name is "string" assert typeof @type is "string" assert typeof @readonly is "boolean" getCurrentValue: -> @value _setValue: (value) -> if isNumber value numValue = parseFloat(value) value = numValue unless isNaN(numValue) @value = value @_vars._emitVariableValueChanged(this, @value) return true toJson: -> { name: @name readonly: @readonly type: @type value: @value unit: @unit or '' } class DeviceAttributeVariable extends Variable constructor: (vars, @_device, @_attrName) -> super( vars, "#{@_device.id}.#{@_attrName}", 'attribute', @_device.attributes[@_attrName].unit, yes ) @_addListener() _addListener: () -> @_device.on(@_attrName, @_attrListener = (value) => @_setValue(value) ) @_device.on('changed', @_deviceChangedListener = (newDevice) => if newDevice.hasAttribute(@_attrName) @unit = newDevice.attributes[@_attrName].unit @_removeListener() @_device = newDevice @_addListener() else @_vars._removeDeviceAttributeVariable(@name) ) @_device.on('destroyed', @_deviceDestroyedListener = => @_vars._removeDeviceAttributeVariable(@name) ) _removeListener: () -> @_device.removeListener(@_attrName, @_attrListener) @_device.removeListener("changed", @_deviceChangedListener) @_device.removeListener("destroyed", @_deviceDestroyedListener) getUpdatedValue: (varsInEvaluation = {}) -> return @_device.getUpdatedAttributeValue(@_attrName, varsInEvaluation) destroy: => @_removeListener() return class ExpressionValueVariable extends Variable constructor: (vars, name, type, unit, valueOrExpr = null) -> super(vars, name, type, unit, no) assert type in ['value', 'expression'] if valueOrExpr? switch type when 'value' then @setToValue(valueOrExpr, unit) when 'expression' then @setToExpression(valueOrExpr, unit) else assert false setToValue: (value, unit) -> @_removeListener() @type = "value" @_datatype = null @exprInputStr = null @exprTokens = null @unit = unit return @_setValue(value) setToExpression: (expression, unit) -> {tokens, datatype} = @_vars.parseVariableExpression(expression) @exprInputStr = expression @exprTokens = tokens @_datatype = datatype @_removeListener() @type = "expression" @unit = unit variablesInExpr = (t.substring(1) for t in tokens when @_vars.isAVariable(t)) doUpdate = ( => @getUpdatedValue().then( (value) => @_setValue(value) ).catch( (error) => env.logger.error("Error updating expression value:", error.message) env.logger.debug error return error ) ) @_vars.on('variableValueChanged', @_changeListener = (changedVar, value) => unless changedVar.name in variablesInExpr then return doUpdate() ) return doUpdate() _removeListener: -> if @_changeListener? @_vars.removeListener('variableValueChanged', @_changeListener) @changeListener = null getUpdatedValue: (varsInEvaluation = {})-> if @type is "value" then return Promise.resolve(@value) else assert @exprTokens? return @_vars.evaluateExpression(@exprTokens, varsInEvaluation) toJson: -> jsonObject = super() if @type is "expression" jsonObject.exprInputStr = @exprInputStr jsonObject.exprTokens = @exprTokens return jsonObject destroy: -> @_removeListener() ### The Variable Manager ---------------- ### class VariableManager extends require('events').EventEmitter variables: {} functions: { min: description: """ Returns the lowest-valued number of the numeric expressions passed to it. If any parameter isn't a number and can't be converted into one the "null" value is returned. """ args: numbers: description: """ Zero or more numeric expressions among which the lowest value will be selected and returned. If no expression is provided the null value is returned """ type: "number" multiple: yes exec: (args...) -> _.reduce(_.map(args, parseFloat), (a, b) -> Math.min(a,b) ) max: description: """ Returns the highest-valued number of the numeric expressions passed to it. If any parameter isn't a number and can't be converted into one the "null" value is returned """ args: numbers: description: """ Zero or more numeric expressions among which the highest value will be selected and returned. If no expression is provided the null value is returned """ type: "number" multiple: yes exec: (args...) -> _.reduce(_.map(args, parseFloat), (a, b) -> Math.max(a,b) ) avg: description: """ Returns the average (arithmetic mean) for the numeric expressions passed to it. If any parameter isn't a number and can't be converted into one the "null" value is returned """ args: numbers: description: """ Zero or more numeric expressions among which the average is calculated. If no expression is provided the null value is returned """ type: "number" multiple: yes exec: (args...) -> _.reduce(_.map(args, parseFloat), (a, b) -> a+b) / args.length random: args: min: type: "number" max: type: "number" exec: (min, max) -> minf = parseFloat(min) maxf = parseFloat(max) return Math.floor( Math.random() * (maxf+1-minf) ) + minf pow: description: "Returns the base to the exponent power" args: base: description: "A numeric expression for base number" type: "number" exponent: description: "A numeric expression for the exponent. If omitted exponent 2 is applied" type: "number" optional: yes exec: (base, exponent=2) -> return Math.pow(base, exponent) abs: description: """ Returns the absolute value of a number """ args: x: description: "A numeric expression" type: "number" exec: (x) -> return Math.abs(x) sign: description: """ Returns the sign of the value evaluated from the given numeric expression, indicating whether the number is positive (1), negative (-1) or zero (0) """ args: x: description: "A numeric expression" type: "number" exec: (x) -> return Math.sign(x) sqrt: description: "Returns the square root of a number" args: x: description: "A numeric expression" type: "number" exec: (x) -> return Math.sqrt(x) cos: description: "Returns the cosine of a number" args: x: description: "A numeric expression for the radians" type: "number" exec: (x) -> return Math.cos(x) acos: description: """ Returns the arccosine (in radians) of a number if it's between -1 and 1; otherwise, NaN """ args: x: description: "A numeric expression" type: "number" exec: (x) -> return Math.acos(x) log: description: """ Returns the logarithm for a given number and base. If no base is provided, the logarithmus naturalis (base e) is assumed. """ args: x: description: "A numeric expression" type: "number" base: description: "A numeric expression" type: "number" optional: yes exec: (x, base) -> return Math.log(x) / if base? then Math.log(base) else 1 round: args: number: type: "number" decimals: type: "number" optional: yes exec: (value, decimals) -> multiplier = Math.pow(10, decimals ? 0) return Math.round(value * multiplier) / multiplier roundToNearest: args: number: type: "number" steps: type: "number" exec: (number, steps) -> steps = String(steps) decimals = (if steps % 1 != 0 then steps.substr(steps.indexOf(".") + 1).length else 0) return Number((Math.round(number / steps) * steps).toFixed(decimals)) trunc: description: """ Returns the given number truncated at at the given decimal place. If the decimal place is omitted or the value 0 is set, the integer part is returned by removing any fractional digits. Note, this function is equivalent to symmetrical rounding towards zero. """ args: number: type: "number" decimals: type: "number" optional: yes exec: (value, decimals) -> multiplier = Math.pow(10, decimals ? 0) return Math.trunc(value * multiplier) / multiplier timeFormat: args: number: type: "number" exec: (number) -> hours = parseInt(number) decimalMinutes = (number-hours) * 60 minutes = Math.floor(decimalMinutes) seconds = Math.round((decimalMinutes % 1) * 60) if seconds == 60 minutes += 1 seconds = "0" if minutes == 60 hours += 1 minutes = "0" hours = "0" + hours if hours < 10 minutes = "0" + minutes if minutes < 10 seconds = "0" + seconds if seconds < 10 return "#{hours}:#{minutes}:#{seconds}" timeDecimal: args: time: type: "string" exec: (time) -> hours = time.substr(0, time.indexOf(':')) minutes = time.substr(hours.length + 1, 2) seconds = time.substr(hours.length + minutes.length + 2, 2) return parseInt(hours) + parseFloat(minutes / 60) + parseFloat(seconds / 3600) date: args: format: type: "string" optional: yes exec: (format) -> (new Date()).format(if format? then format else 'YYYY-MM-DD hh:mm:ss') diffDate: description: """ Returns the difference between to given date strings in milliseconds. Optionally, a format string can be provided to return the difference in "seconds", "minutes", "hours", "days". In this case the result is a real number (float) is returned. """ args: startDate: type: "string" optional: no endDate: type: "string" optional: no format: type: "string" optional: yes exec: (startDate, endDate, format) -> diff = Date.parse(endDate) - Date.parse(startDate) switch format when "seconds" diff = diff / 1000 when "minutes" diff = diff / 1000 / 60 when "hours" diff = diff / 1000 / 60 / 60 when "days" diff = diff / 1000 / 60 / 60 / 24 return diff formatNumber: args: number: type: "number" decimals: type: "number" optional: yes unit: type: "string" optional: yes exec: (number, decimals, unit) -> unless unit? unit = this.units[0] info = humanFormat.raw(number, unit: unit) formatted = (if decimals? then Number(info.value).toFixed(decimals) else info.value) return "#{formatted}#{info.prefix}#{unit}" else unless decimals? decimals = 2 formatted = Number(number).toFixed(decimals) return "#{formatted}#{unit}" hexString: description: """ Converts a given number to a hex string """ args: number: description: """ The input number. Negative numbers will be treated as 32-bit signed integers. Thus, numbers smaller than -2147483648 will be cut off which is due to limitation of using bitwise operators in JavaScript. Positive integers will be handled up to 53-bit as JavaScript uses IEEE 754 double-precision floating point numbers, internally """ type: "number" padding: description: """ Specifies the (minimum) number of digits the resulting string shall contain. The string will be padded by prepending leading "0" digits, accordingly. By default, padding is set to 0 which means no padding is performed """ type: "number" optional: yes prefix: description: """ Specifies a prefix string which will be prepended to the resulting hex number. By default, no prefix is set """ type: "string" optional: yes exec: (number, padding=0, prefix="") -> try padding = Math.max(Math.min(padding, 10), 0) hex = Number(if number < 0 then number >>> 0 else number).toString(16).toUpperCase() if hex.length < padding hex = Array(padding + 1 - hex.length).join('0') + hex return prefix + hex catch error env.logger.error "Error in hexString expression: #{error.message}" throw error subString: description: """ Returns the substring of the given string matching the given regular expression and flags. If the global flag is used the resulting substring is a concatenation of all matches. If the expression contains capture groups the group matches will be concatenated to provide the resulting substring. If there is no match the empty string is returned """ args: string: description: """ The input string which is a string expression which may also contain variable references and function calls """ type: "string" expression: description: "A string value which may contain a regular expression" type: "string" flags: description: """ A string with flags for a regular expression: g: global match, i: ignore case """ type: "string" optional: yes exec: (string, expression, flags) -> try matchResult = string.match new RegExp(expression, flags) catch error env.logger.error "Error in subString expression: #{error.message}" throw error if matchResult? if flags? and flags.includes('g') # concatenate all global matches _.reduce(matchResult, (fullMatch, val) -> fullMatch = fullMatch + val) else # concatenate all matched capture groups (if any) or prompt the match result if _.isString matchResult[1] matchResult.shift() _.reduce(matchResult, (fullMatch, val) -> if _.isString val then fullMatch = fullMatch + val) else matchResult[0] else env.logger.debug "subString expression did not match" return "" } inited: false constructor: (@framework, @variablesConfig) -> # For each each attribute of a new device add a variable @framework.on 'deviceAdded', (device) => for attrName, attr of device.attributes @_addVariable( new DeviceAttributeVariable(this, device, attrName) ) # For each new attribute of a changed device add a variable @framework.on 'deviceChanged', (device) => for attrName, attr of device.attributes if not @variables["#{device.id}.#{attrName}"]? @_addVariable( new DeviceAttributeVariable(this, device, attrName) ) init: () -> # Import variables setExpressions = [] for variable in @variablesConfig do (variable) => assert variable.name? and variable.name.length > 0 variable.name = variable.name.substring(1) if variable.name[0] is '$' if variable.expression? try exprVar = new ExpressionValueVariable( this, variable.name, 'expression', variable.unit ) # We first add the variable, but parse the expression later, because it could # contain other variables, added later @_addVariable(exprVar) setExpressions.push( -> try exprVar.setToExpression(variable.expression.trim()) catch e env.logger.error( "Error parsing expression variable #{variable.name}:", e.message ) env.logger.debug e ) catch e env.logger.error( "Error adding expression variable #{variable.name}:", e.message ) env.logger.debug e.stack else @_addVariable( new ExpressionValueVariable( this, variable.name, 'value', variable.unit, variable.value ) ) setExpr() for setExpr in setExpressions @inited = true @emit 'init' waitForInit: () -> return new Promise( (resolve) => if @inited then return resolve() @once('init', resolve) ) _addVariable: (variable) -> assert variable instanceof Variable assert (not @variables[variable.name]?) @variables[variable.name] = variable Promise.resolve().then( -> variable.getUpdatedValue().then( (value) -> variable._setValue(value) ) ).catch( (error) -> env.logger.warn("Could not update variable #{variable.name}: #{error.message}") env.logger.debug(error) ) @_emitVariableAdded(variable) return _emitVariableValueChanged: (variable, value) -> @emit('variableValueChanged', variable, value) _emitVariableAdded: (variable) -> @emit('variableAdded', variable) _emitVariableChanged: (variable) -> @emit('variableChanged', variable) _emitVariableRemoved: (variable) -> @emit('variableRemoved', variable) getVariablesAndFunctions: (ops) -> unless ops? return {variables: @variables, functions: @functions} else filteredVars = _.filter(@variables, ops) variables = {} for v in filteredVars variables[v.name] = v return { variables, functions: @functions } parseVariableExpression: (expression) -> tokens = null context = M.createParseContext(@variables, @functions) m = M(expression, context).matchAnyExpression( (m, ts) => tokens = ts) unless m.hadMatch() and m.getFullMatch() is expression throw new Error("Could not parse expression") datatype = (if tokens[0][0] is '"' then "string" else "numeric") return {tokens, datatype} setVariableToExpr: (name, inputStr, unit) -> assert name? and typeof name is "string" assert typeof inputStr is "string" and inputStr.length > 0 unless @variables[name]? @_addVariable( variable = new ExpressionValueVariable(this, name, 'expression', unit, inputStr) ) else variable = @variables[name] unless variable.type in ["expression", "value"] throw new Error("Can not set a non expression or value var to an expression") variable.setToExpression(inputStr, unit) @_emitVariableChanged(variable) return variable _checkVariableName: (name) -> unless name.match /^[a-z0-9\-_]+$/i throw new Error( "Variable name must only contain alpha numerical symbols, \"-\" and \"_\"" ) setVariableToValue: (name, value, unit) -> assert name? and typeof name is "string" @_checkVariableName(name) unless @variables[name]? @_addVariable( variable = new ExpressionValueVariable(this, name, 'value', unit, value) ) else variable = @variables[name] unless variable.type in ["expression", "value"] throw new Error("Can not set a non expression or value var to an expression") if variable.type is "expression" variable.setToValue(value, unit) @_emitVariableChanged(variable) else if variable.type is "value" variable.setToValue(value, unit) return variable updateVariable: (name, type, valueOrExpr, unit) -> assert type in ["value", "expression"] unless @isVariableDefined(name) throw new Error("No variable with the name \"#{name}\" found.") return ( switch type when "value" then @setVariableToValue(name, valueOrExpr, unit) when "expression" then @setVariableToExpr(name, valueOrExpr, unit) ) addVariable: (name, type, valueOrExpr, unit) -> assert type in ["value", "expression"] if @isVariableDefined(name) throw new Error("There is already a variable with the name \"#{name}\"") return ( switch type when "value" then @setVariableToValue(name, valueOrExpr, unit) when "expression" then @setVariableToExpr(name, valueOrExpr, unit) ) isVariableDefined: (name) -> assert name? and typeof name is "string" return @variables[name]? getVariableValue: (name) -> @variables[name]?.value getVariableUpdatedValue: (name, varsInEvaluation = {}) -> assert name? and typeof name is "string" if @variables[name]? if varsInEvaluation[name]? if varsInEvaluation[name].value? return Promise.resolve(varsInEvaluation[name].value) else return Promise.try => throw new Error("Dependency cycle detected for variable #{name}") else varsInEvaluation[name] = {} return @variables[name].getUpdatedValue(varsInEvaluation).then( (value) => varsInEvaluation[name].value = value return value ) else return null removeVariable: (name) -> assert name? and typeof name is "string" variable = @variables[name] if variable? if variable.type is 'attribute' throw new Error("Can not delete a variable for a device attribute.") variable.destroy() delete @variables[name] @_emitVariableRemoved(variable) _removeDeviceAttributeVariable: (name) -> assert name? and typeof name is "string" variable = @variables[name] if variable? if variable.type isnt 'attribute' throw new Error("Not a device attribute.") variable.destroy() delete @variables[name] @_emitVariableRemoved(variable) getVariables: () -> variables = (v for name, v of @variables) # sort in config order variablesInConfig = _.map(@framework.config.variables, (r) => r.name ) return _.sortBy(variables, (r) => variablesInConfig.indexOf r.name ) getFunctions: () -> @functions getVariableByName: (name) -> v = @variables[name] unless v? then return null return v isAVariable: (token) -> token.length > 0 and token[0] is '$' extractVariables: (tokens) -> return (vars = (t.substring(1) for t in tokens when @isAVariable(t))) notifyOnChange: (tokens, listener) -> variablesInExpr = @extractVariables(tokens) @on('variableValueChanged', changeListener = (changedVar, value) => unless changedVar.name in variablesInExpr then return listener(changedVar) ) listener.__variableChangeListener = changeListener cancelNotifyOnChange: (listener) -> assert typeof listener.__variableChangeListener is "function" @removeListener('variableValueChanged', listener.__variableChangeListener) evaluateExpression: (tokens, varsInEvaluation = {}) -> builder = new varsAst.ExpressionTreeBuilder(@variables, @functions) # do building async return Promise.resolve().then( => expr = builder.build(tokens) return expr.evaluate(varsInEvaluation) ) evaluateExpressionWithUnits: (tokens, varsInEvaluation = {}) -> builder = new varsAst.ExpressionTreeBuilder(@variables, @functions) # do building async return Promise.resolve().then( => expr = builder.build(tokens) return expr.evaluate(varsInEvaluation).then( (value) => return { value: value, unit: expr.getUnit() } ) ) inferUnitOfExpression: (tokens) -> builder = new varsAst.ExpressionTreeBuilder(@variables, @functions) expr = builder.build(tokens) return expr.getUnit() evaluateNumericExpression: (tokens, varsInEvaluation = {}) -> return @evaluateExpression(tokens, varsInEvaluation) evaluateStringExpression: (tokens, varsInEvaluation = {}) -> return @evaluateExpression(tokens, varsInEvaluation) updateVariableOrder: (variableOrder) -> assert variableOrder? and Array.isArray variableOrder @framework.config.variables = @variablesConfig = _.sortBy( @variablesConfig, (variable) => index = variableOrder.indexOf variable.name return if index is -1 then 99999 else index # push it to the end if not found ) @framework.saveConfig() @framework._emitVariableOrderChanged(variableOrder) return variableOrder return exports = { VariableManager }