pimatic
Version:
A home automation server and framework for the Raspberry PI running on node.js
807 lines (738 loc) • 27.5 kB
text/coffeescript
###
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: (, , , , ) ->
assert ?
assert instanceof VariableManager
assert typeof is "string"
assert typeof is "string"
assert typeof is "boolean"
getCurrentValue: ->
_setValue: (value) ->
if isNumber value
numValue = parseFloat(value)
value = numValue unless isNaN(numValue)
= value
._emitVariableValueChanged(this, )
return true
toJson: -> {
name:
readonly:
type:
value:
unit: or ''
}
class DeviceAttributeVariable extends Variable
constructor: (vars, , ) ->
super(
vars,
"#{@_device.id}.#{@_attrName}",
'attribute',
.attributes[].unit,
yes
)
_addListener: () ->
.on(, = (value) => )
.on('changed', = (newDevice) =>
if newDevice.hasAttribute()
= newDevice.attributes[].unit
= newDevice
else
._removeDeviceAttributeVariable()
)
.on('destroyed', = =>
._removeDeviceAttributeVariable()
)
_removeListener: () ->
.removeListener(, )
.removeListener("changed", )
.removeListener("destroyed", )
getUpdatedValue: (varsInEvaluation = {}) ->
return .getUpdatedAttributeValue(, varsInEvaluation)
destroy: =>
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
when 'expression' then
else assert false
setToValue: (value, unit) ->
= "value"
= null
= null
= null
= unit
return
setToExpression: (expression, unit) ->
{tokens, datatype} = .parseVariableExpression(expression)
= expression
= tokens
= datatype
= "expression"
= unit
variablesInExpr = (t.substring(1) for t in tokens when .isAVariable(t))
doUpdate = ( =>
.then( (value) =>
).catch( (error) =>
env.logger.error("Error updating expression value:", error.message)
env.logger.debug error
return error
)
)
.on('variableValueChanged', = (changedVar, value) =>
unless changedVar.name in variablesInExpr then return
doUpdate()
)
return doUpdate()
_removeListener: ->
if ?
.removeListener('variableValueChanged', )
= null
getUpdatedValue: (varsInEvaluation = {})->
if is "value" then return Promise.resolve()
else
assert ?
return .evaluateExpression(, varsInEvaluation)
toJson: ->
jsonObject = super()
if is "expression"
jsonObject.exprInputStr =
jsonObject.exprTokens =
return jsonObject
destroy: ->
###
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: (, ) ->
# For each each attribute of a new device add a variable
.on 'deviceAdded', (device) =>
for attrName, attr of device.attributes
# For each new attribute of a changed device add a variable
.on 'deviceChanged', (device) =>
for attrName, attr of device.attributes
if not ["#{device.id}.#{attrName}"]?
init: () ->
# Import variables
setExpressions = []
for variable in
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
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
setExpr() for setExpr in setExpressions
= true
'init'
waitForInit: () ->
return new Promise( (resolve) =>
if then return resolve()
)
_addVariable: (variable) ->
assert variable instanceof Variable
assert (not [variable.name]?)
[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)
)
return
_emitVariableValueChanged: (variable, value) ->
_emitVariableAdded: (variable) ->
_emitVariableChanged: (variable) ->
_emitVariableRemoved: (variable) ->
getVariablesAndFunctions: (ops) ->
unless ops?
return {variables: , functions: }
else
filteredVars = _.filter(, ops)
variables = {}
for v in filteredVars
variables[v.name] = v
return {
variables,
functions:
}
parseVariableExpression: (expression) ->
tokens = null
context = M.createParseContext(, )
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 [name]?
else
variable = [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)
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"
unless [name]?
else
variable = [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)
else if variable.type is "value"
variable.setToValue(value, unit)
return variable
updateVariable: (name, type, valueOrExpr, unit) ->
assert type in ["value", "expression"]
unless
throw new Error("No variable with the name \"#{name}\" found.")
return (
switch type
when "value" then
when "expression" then
)
addVariable: (name, type, valueOrExpr, unit) ->
assert type in ["value", "expression"]
if
throw new Error("There is already a variable with the name \"#{name}\"")
return (
switch type
when "value" then
when "expression" then
)
isVariableDefined: (name) ->
assert name? and typeof name is "string"
return [name]?
getVariableValue: (name) -> [name]?.value
getVariableUpdatedValue: (name, varsInEvaluation = {}) ->
assert name? and typeof name is "string"
if [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 [name].getUpdatedValue(varsInEvaluation).then( (value) =>
varsInEvaluation[name].value = value
return value
)
else
return null
removeVariable: (name) ->
assert name? and typeof name is "string"
variable = [name]
if variable?
if variable.type is 'attribute'
throw new Error("Can not delete a variable for a device attribute.")
variable.destroy()
delete [name]
_removeDeviceAttributeVariable: (name) ->
assert name? and typeof name is "string"
variable = [name]
if variable?
if variable.type isnt 'attribute'
throw new Error("Not a device attribute.")
variable.destroy()
delete [name]
getVariables: () ->
variables = (v for name, v of )
# sort in config order
variablesInConfig = _.map(.config.variables, (r) => r.name )
return _.sortBy(variables, (r) => variablesInConfig.indexOf r.name )
getFunctions: () ->
getVariableByName: (name) ->
v = [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 ))
notifyOnChange: (tokens, listener) ->
variablesInExpr =
listener.__variableChangeListener = changeListener
cancelNotifyOnChange: (listener) ->
assert typeof listener.__variableChangeListener is "function"
evaluateExpression: (tokens, varsInEvaluation = {}) ->
builder = new varsAst.ExpressionTreeBuilder(, )
# do building async
return Promise.resolve().then( =>
expr = builder.build(tokens)
return expr.evaluate(varsInEvaluation)
)
evaluateExpressionWithUnits: (tokens, varsInEvaluation = {}) ->
builder = new varsAst.ExpressionTreeBuilder(, )
# 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(, )
expr = builder.build(tokens)
return expr.getUnit()
evaluateNumericExpression: (tokens, varsInEvaluation = {}) ->
return
evaluateStringExpression: (tokens, varsInEvaluation = {}) ->
return
updateVariableOrder: (variableOrder) ->
assert variableOrder? and Array.isArray variableOrder
.config.variables = = _.sortBy(
,
(variable) =>
index = variableOrder.indexOf variable.name
return if index is -1 then 99999 else index # push it to the end if not found
)
.saveConfig()
._emitVariableOrderChanged(variableOrder)
return variableOrder
return exports = { VariableManager }