pimatic
Version:
A home automation server and framework for the Raspberry PI running on node.js
1,683 lines (1,404 loc) • 51.9 kB
text/coffeescript
###
Devices
=======
###
cassert = require 'cassert'
assert = require 'assert'
Promise = require 'bluebird'
_ = require 'lodash'
t = require('decl-api').types
declapi = require 'decl-api'
events = require 'events'
module.exports = (env) ->
###
Device
-----
The Device class is the common superclass for all devices like actuators or sensors.
###
class Device extends require('events').EventEmitter
# A unique id defined by the config or by the plugin that provides the device.
id: null
# The name of the actuator to display at the frontend.
name: null
# Defines the actions an device has.
actions: {}
# attributes the device has. For examples see devices below.
attributes: {}
template: "device"
config: {}
_checkAttributes: ->
for attr of @attributes
@_checkAttribute attr
_checkAttribute: (attrName) ->
attr = @attributes[attrName]
assert attr.description?, "No description for #{attrName} of #{@name} given"
assert attr.type?, "No type for #{attrName} of #{@name} given"
isValidType = (type) => type in _.values(t)
assert isValidType(attr.type), "#{attrName} of #{@name} has no valid type."
# If it is a Number it must have a unit
if attr.type is t.number and not attr.unit? then attr.unit = ''
# If it is a Boolean it must have labels
if attr.type is t.boolean and not attr.labels then attr.labels = ["true", "false"]
unless attr.label then attr.label = upperCaseFirst(attrName)
unless attr.discrete?
attr.discrete = (if attr.type is "number" then no else yes)
constructor: ->
assert @id?, "The device has no ID"
assert @name?, "The device has no name"
assert @id.length isnt 0, "The ID of the device is empty"
assert @name.length isnt 0, "The name of the device is empty"
@_checkAttributes()
@_constructorCalled = yes
@_attributesMeta = {}
@_initAttributeMeta(attrName, attr) for attrName, attr of @attributes
super()
_initAttributeMeta: (attrName, attr) ->
device = @
@_attributesMeta[attrName] = {
value: null
error: null
history: []
update: (value) ->
if attr.type in ["number", "integer"] and typeof value is "string"
env.logger.error(
"Got string value for attribute #{attrName} of #{device.constructor.name} but " +
"attribute type is #{attr.type}."
)
timestamp = (new Date()).getTime()
@value = value
@lastUpdate = timestamp
if @history.length is 30
@history.shift()
@history.push {t:timestamp, v:value}
}
attrListener = (value) => @_attributesMeta[attrName].update(value)
@_attributesMeta[attrName].attrListener = attrListener
@on(attrName, attrListener)
destroy: ->
@emit('destroy', @)
@removeAllListeners('destroy')
@removeAllListeners(attrName) for attrName of @attributes
@_destroyed = true
return
afterRegister: ->
for attrName of @attributes
do (attrName) =>
# force update of the device value
meta = @_attributesMeta[attrName]
unless meta.value?
@getUpdatedAttributeValue(attrName).then( (value) =>
if (not meta.lastUpdate?) or (new Date().getTime() - meta.lastUpdate)
@emit(attrName, value)
).catch( (err) =>
@logAttributeError(attrName, err)
).done()
# Checks if the actuator has a given action.
hasAction: (name) -> @actions[name]?
# Checks if the actuator has the attribute event.
hasAttribute: (name) -> @attributes[name]?
getLastAttributeValue: (attrName) ->
return @_attributesMeta[attrName].value
addAttribute: (name, attribute) ->
assert (not @_constructorCalled), "Attributes can only be added in the constructor"
if @attributes is @constructor.prototype.attributes
@attributes = _.clone(@attributes)
@attributes[name] = attribute
addAction: (name, action) ->
assert (not @_constructorCalled), "Actions can only be added in the constructor"
if @actions is @constructor.prototype.actions
@actions = _.clone(@actions)
@actions[name] = action
updateName: (name) ->
if name is @name then return
@name = name
@emit "nameChanged", this
getUpdatedAttributeValue: (attrName, arg) ->
getter = 'get' + upperCaseFirst(attrName)
# call the getter
assert @[getter]?, "Method #{getter} of #{@name} does not exist!"
result = Promise.resolve().then( => return @[getter](arg) )
return result
getUpdatedAttributeValueCached: (attrName, arg) ->
unless @_promiseCache then @_promiseCache = {}
if @_promiseCache[attrName]? then return @_promiseCache[attrName]
@_promiseCache[attrName] = @getUpdatedAttributeValue(attrName, arg).then( (value) =>
delete @_promiseCache[attrName]
return value
, (error) =>
delete @_promiseCache[attrName]
throw error
)
return @_promiseCache[attrName]
_createGetter: (attributeName, fn) ->
getterName = 'get' + attributeName[0].toUpperCase() + attributeName.slice(1)
@[getterName] = fn
return
toJson: ->
json = {
id: @id
name: @name
template: @template
attributes: []
actions: []
config: @config
configDefaults: @config.__proto__
}
for name, attr of @attributes
meta = @_attributesMeta[name]
attrJson = _.cloneDeep(attr)
attrJson.name = name
attrJson.value = meta.value
attrJson.history = meta.history
attrJson.lastUpdate = meta.lastUpdate
json.attributes.push attrJson
for name, action of @actions
actionJson = _.cloneDeep(action)
actionJson.name = name
json.actions.push actionJson
# experimental feature to list the capabilities of a device
predicate = (accumulator, value) =>
if (@ instanceof value) and (@constructor.name isnt value.name)
accumulator.push value.name
return accumulator
json.capabilities = [
ErrorDevice
Actuator
SwitchActuator
PowerSwitch
DimmerActuator
ShutterController
Sensor
TemperatureSensor
PresenceSensor
ContactSensor
HeatingThermostat
ButtonsDevice
InputDevice
VariablesDevice
VariableInputDevice
VariableTimeInputDevice
AVPlayer
Timer
].reduce predicate, []
return json
_setupPolling: (attrName, interval, additionalCallback) ->
cb = additionalCallback ? Promise.resolve
unless typeof interval is 'number'
throw new Error("Illegal polling interval #{interval}!")
unless interval > 0
throw new Error("Polling interval must be greater then 0, was #{interval}")
doPolling = () =>
if @_destroyed then return
Promise.resolve()
.then( =>
cb()
)
.then( =>
@getUpdatedAttributeValue(attrName)
)
.then( (value) =>
# may emit value, if it was not already emitted by getter
lastUpdate = @_attributesMeta[attrName].lastUpdate
if lastUpdate? and (new Date().getTime() - lastUpdate) < 500
return
@emit(attrName, value)
)
.catch( (err) =>
@logAttributeError(attrName, err) )
.finally( =>
if @_destroyed then return
setTimeout(doPolling, interval)
).done()
setTimeout(doPolling, interval)
logAttributeError: (attrName, err) ->
lastError = @_attributesMeta[attrName].error
if lastError? and err.message is lastError.message
@logger.debug("Suppressing repeated error for #{@id}.#{attrName} #{err.message}")
@logger.debug(err.stack)
return
# save attribute error
@_attributesMeta[attrName].error = err
# clear error on next success
@once(attrName, => @_attributesMeta[attrName].error = null )
@logger.error("Error getting attribute value #{@id}.#{attrName}: #{err.message}")
@logger.debug(err.stack)
###
ErrorDevice
-----
Devices of this type are created if the create operation
for the real type cant be created
###
class ErrorDevice extends Device
constructor: (@config, @error) ->
@name = @config.name
@id = @config.id
super()
destroy: () ->
super()
###
Actuator
-----
An Actuator is an physical or logical element you can control by triggering an action on it.
For example a power outlet, a light or door opener.
###
class Actuator extends Device
###
SwitchActuator
-----
A class for all devices you can switch on and off.
###
class SwitchActuator extends Actuator
_state: null
actions:
turnOn:
description: "Turns the switch on"
turnOff:
description: "Turns the switch off"
changeStateTo:
description: "Changes the switch to on or off"
params:
state:
type: t.boolean
toggle:
description: "Toggle the state of the switch"
getState:
description: "Returns the current state of the switch"
returns:
state:
type: t.boolean
attributes:
state:
description: "The current state of the switch"
type: t.boolean
labels: ['on', 'off']
template: "switch"
# Returns a promise
turnOn: -> @changeStateTo on
# Returns a promise
turnOff: -> @changeStateTo off
toggle: ->
@getState().then( (state) => @changeStateTo(!state) )
# Returns a promise that is fulfilled when done.
changeStateTo: (state) ->
throw new Error "Function \"changeStateTo\" is not implemented!"
# Returns a promise that will be fulfilled with the state
getState: -> Promise.resolve(@_state)
_setState: (state) ->
if @_state is state then return
@_state = state
@emit "state", state
###
PowerSwitch
----------
Just an alias for a SwitchActuator at the moment
###
class PowerSwitch extends SwitchActuator
###
DimmerActuator
-------------
Switch with additional dim functionality.
###
class DimmerActuator extends SwitchActuator
_dimlevel: null
actions:
changeDimlevelTo:
description: "Sets the level of the dimmer"
params:
dimlevel:
type: t.number
changeStateTo:
description: "Changes the switch to on or off"
params:
state:
type: t.boolean
turnOn:
description: "Turns the dim level to 100%"
turnOff:
description: "Turns the dim level to 0%"
toggle:
description: "Toggle the state of the dimmer"
attributes:
dimlevel:
description: "The current dim level"
type: t.number
unit: "%"
state:
description: "The current state of the switch"
type: t.boolean
labels: ['on', 'off']
template: "dimmer"
# Returns a promise
turnOn: -> @changeDimlevelTo 100
# Returns a promise
turnOff: -> @changeDimlevelTo 0
# Returns a promise that is fulfilled when done.
changeDimlevelTo: (state) ->
throw new Error "Function \"changeDimlevelTo\" is not implemented!"
# Returns a promise that is fulfilled when done.
changeStateTo: (state) ->
return if state then @turnOn() else @turnOff()
_setDimlevel: (level) =>
level = parseFloat(level)
assert(not isNaN(level))
cassert level >= 0
cassert level <= 100
if @_dimlevel is level then return
@_dimlevel = level
@emit "dimlevel", level
@_setState(level > 0)
# Returns a promise that will be fulfilled with the dim level
getDimlevel: -> Promise.resolve(@_dimlevel)
###
ShutterController
-----
A class for all devices you can move up and down.
###
class ShutterController extends Actuator
_position: null
# Approx. amount of time (in seconds) for shutter to close or open completely.
rollingTime = null
attributes:
position:
label: "Position"
description: "State of the shutter"
type: t.string
enum: ['up', 'down', 'stopped']
labels: { up: 'up', down: 'down', stopped: 'stopped'}
actions:
moveUp:
description: "Raise the shutter"
moveDown:
description: "Lower the shutter"
stop:
description: "Stops the shutter move"
moveToPosition:
description: "Changes the shutter state"
params:
state:
type: t.string
moveByPercentage:
description: "Move shutter by percentage relative to current position"
params:
percentage:
type: t.number
template: "shutter"
# Returns a promise
moveUp: -> @moveToPosition('up')
# Returns a promise
moveDown: -> @moveToPosition('down')
stop: ->
throw new Error "Function \"stop\" is not implemented!"
# Returns a promise that is fulfilled when done.
moveToPosition: (position) ->
throw new Error "Function \"moveToPosition\" is not implemented!"
moveByPercentage: (percentage) ->
duration = @_calculateRollingTime(Math.abs(percentage))
if duration is 0
return Promise.resolve()
promise = if percentage > 0 then @moveUp() else @moveDown()
promise = promise.delay(duration + 10).then( () =>
@stop()
)
return promise
# Returns a promise that will be fulfilled with the position
getPosition: -> Promise.resolve(@_position)
_setPosition: (position) ->
assert position in ['up', 'down', 'stopped']
if @_position is position then return
@_position = position
@emit "position", position
# calculates rolling time in ms for given percentage
_calculateRollingTime: (percentage) ->
assert 0 <= percentage <= 100, "percentage must be between 0 and 100"
return @rollingTime * 1000 * percentage / 100 if @rollingTime?
throw new Error "No rolling time configured."
###
Sensor
------
###
class Sensor extends Device
###
TemperatureSensor
------
###
class TemperatureSensor extends Sensor
_temperature: undefined
actions:
getTemperature:
description: "Returns the current temperature"
returns:
temperature:
type: t.number
attributes:
temperature:
description: "The measured temperature"
type: t.number
unit: '°C'
acronym: 'T'
_setTemperature: (value) ->
@_temperature = value
@emit 'temperature', value
getTemperature: -> Promise.resolve(@_temperature)
template: "temperature"
###
PresenceSensor
------
###
class PresenceSensor extends Sensor
_presence: undefined
actions:
getPresence:
description: "Returns the current presence state"
returns:
presence:
type: t.boolean
attributes:
presence:
description: "Presence of the human/device"
type: t.boolean
labels: ['present', 'absent']
_setPresence: (value) ->
if @_presence is value then return
@_presence = value
@emit 'presence', value
getPresence: -> Promise.resolve(@_presence)
template: "presence"
###
ContactSensor
------
###
class ContactSensor extends Sensor
_contact: undefined
actions:
getContact:
description: "Returns the current state of the contact"
returns:
contact:
type: t.boolean
attributes:
contact:
description: "State of the contact"
type: t.boolean
labels: ['closed', 'opened']
template: "contact"
_setContact: (value) ->
if @_contact is value then return
@_contact = value
@emit 'contact', value
getContact: -> Promise.resolve(@_contact)
upperCaseFirst = (string) ->
unless string.length is 0
string[0].toUpperCase() + string.slice(1)
else ""
class HeatingThermostat extends Device
attributes:
temperatureSetpoint:
label: "Temperature Setpoint"
description: "The temp that should be set"
type: "number"
discrete: true
unit: "°C"
valve:
description: "Position of the valve"
type: "number"
discrete: true
unit: "%"
mode:
description: "The current mode"
type: "string"
enum: ["auto", "manu", "boost"]
battery:
description: "Battery status"
type: "string"
enum: ["ok", "low"]
synced:
description: "Pimatic and thermostat in sync"
type: "boolean"
actions:
changeModeTo:
params:
mode:
type: "string"
changeTemperatureTo:
params:
temperatureSetpoint:
type: "number"
template: "thermostat"
_mode: null
_temperatureSetpoint: null
_valve: null
_battery: null
_synced: false
getMode: () -> Promise.resolve(@_mode)
getTemperatureSetpoint: () -> Promise.resolve(@_temperatureSetpoint)
getValve: () -> Promise.resolve(@_valve)
getBattery: () -> Promise.resolve(@_battery)
getSynced: () -> Promise.resolve(@_synced)
_setMode: (mode) ->
if mode is @_mode then return
@_mode = mode
@emit "mode", @_mode
_setSynced: (synced) ->
if synced is @_synced then return
@_synced = synced
@emit "synced", @_synced
_setSetpoint: (temperatureSetpoint) ->
if temperatureSetpoint is @_temperatureSetpoint then return
@_temperatureSetpoint = temperatureSetpoint
@emit "temperatureSetpoint", @_temperatureSetpoint
_setValve: (valve) ->
if valve is @_valve then return
@_valve= valve
@emit "valve", @_valve
_setBattery: (battery) ->
if battery is @_battery then return
@_battery = battery
@emit "battery", @_battery
changeModeTo: (mode) ->
throw new Error("changeModeTo must be implemented by a subclass")
changeTemperatureTo: (temperatureSetpoint) ->
throw new Error("changeTemperatureTo must be implemented by a subclass")
class AVPlayer extends Device
actions:
play:
description: "starts playing"
pause:
description: "pauses playing"
stop:
description: "stops playing"
next:
description: "play next song"
previous:
description: "play previous song"
volume:
description: "Change volume of player"
attributes:
currentArtist:
description: "the current playing track artist"
type: "string"
currentTitle:
description: "the current playing track title"
type: "string"
state:
description: "the current state of the player"
type: "string"
volume:
description: "the volume of the player"
type: "string"
_state: null
_currentTitle: null
_currentArtist: null
_volume: null
template: "musicplayer"
_setState: (state) ->
if @_state isnt state
@_state = state
@emit 'state', state
_setCurrentTitle: (title) ->
if @_currentTitle isnt title
@_currentTitle = title
@emit 'currentTitle', title
_setCurrentArtist: (artist) ->
if @_currentArtist isnt artist
@_currentArtist = artist
@emit 'currentArtist', artist
_setVolume: (volume) ->
if @_volume isnt volume
@_volume = volume
@emit 'volume', volume
getState: () -> Promise.resolve @_state
getCurrentTitle: () -> Promise.resolve(@_currentTitle)
getCurrentArtist: () -> Promise.resolve(@_currentTitle)
getVolume: () -> Promise.resolve(@_volume)
class ButtonsDevice extends Device
attributes:
button:
description: "The last pressed button"
type: t.string
actions:
buttonPressed:
params:
buttonId:
type: t.string
description: "Press a button"
template: "buttons"
_lastPressedButton: null
constructor: (@config)->
@id = @config.id
@name = @config.name
super()
getButton: -> Promise.resolve(@_lastPressedButton)
buttonPressed: (buttonId) ->
for b in @config.buttons
if b.id is buttonId
@_lastPressedButton = b.id
@emit 'button', b.id
return Promise.resolve()
throw new Error("No button with the id #{buttonId} found")
destroy: () ->
super()
class InputDevice extends Device
_input: ""
template: "input"
actions:
changeInputTo:
params:
value:
type: t.string
description: "Sets the input value"
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@_inputType = @config.type or "string"
@attributes = {
input:
description: "The value of the input field"
type: @_inputType
}
@_defaultValue = if @_inputType is "string" then "" else 0
@_input = lastState?.input?.value or @_defaultValue
super()
getInput: () -> Promise.resolve(@_input)
_setInput: (value) ->
unless @_input is value
@_input = value
@emit 'input', value
changeInputTo: (value) ->
if @config.type is "number"
if isNaN(value)
throw new Error("Input value is not a number")
else
@_setInput(parseFloat(value))
else
@_setInput value
return Promise.resolve()
destroy: ->
super()
class VariablesDevice extends Device
constructor: (@config, lastState, @framework) ->
@id = @config.id
@name = @config.name
@_vars = @framework.variableManager
@_exprChangeListeners = []
@attributes = {}
for variable in @config.variables
do (variable) =>
name = variable.name
info = null
if @attributes[name]?
throw new Error(
"Two variables with the same name in VariablesDevice config \"#{name}\""
)
@attributes[name] = {
description: name
label: (if variable.label? then variable.label else "$#{name}")
type: variable.type or "string"
}
if variable.unit? and variable.unit.length > 0
@attributes[name].unit = variable.unit
if variable.discrete?
@attributes[name].discrete = variable.discrete
if variable.acronym?
@attributes[name].acronym = variable.acronym
parseExprAndAddListener = ( () =>
info = @_vars.parseVariableExpression(variable.expression)
@_vars.notifyOnChange(info.tokens, onChangedVar)
@_exprChangeListeners.push onChangedVar
)
evaluateExpr = ( (varsInEvaluation) =>
if @attributes[name].type is "number"
unless @attributes[name].unit? and @attributes[name].unit.length > 0
@attributes[name].unit = @_vars.inferUnitOfExpression(info.tokens)
switch info.datatype
when "numeric" then @_vars.evaluateNumericExpression(info.tokens, varsInEvaluation)
when "string" then @_vars.evaluateStringExpression(info.tokens, varsInEvaluation)
else assert false
)
onChangedVar = ( (changedVar) =>
evaluateExpr().then( (val) =>
@emit name, val
)
)
getValue = ( (varsInEvaluation) =>
# wait till variableManager is ready
return @_vars.waitForInit().then( =>
unless info?
parseExprAndAddListener()
return evaluateExpr(varsInEvaluation)
).then( (val) =>
if val isnt @_attributesMeta[name].value
@emit name, val
return val
)
)
@_createGetter(name, getValue)
super()
destroy: ->
@_vars.cancelNotifyOnChange(cl) for cl in @_exprChangeListeners
super()
class VariableInputDevice extends InputDevice
actions:
changeInputTo:
params:
value:
type: t.string
description: "Sets the variable to the value"
constructor: (@config, lastState, @framework) ->
super(@config, lastState)
@_variableName = (@config.variable||'').replace /^[\s\$]+|[\s]$/g, ''
@framework.variableManager.on('variableValueChanged', @changeListener = (changedVar, value) =>
if @_variableName is changedVar.name
@_setInput value
)
changeInputTo: (value) ->
variable = @framework.variableManager.getVariableByName(@_variableName)
unless variable?
throw new Error("Could not find variable with name #{@_variableName}")
@framework.variableManager.setVariableToValue(@_variableName, value, variable.unit)
super(value)
destroy: ->
@framework.variableManager.removeListener('variableValueChanged', @changeListener)
super()
class VariableTimeInputDevice extends VariableInputDevice
template: "inputTime"
actions:
changeInputTo:
params:
value:
type: t.string
description: "Sets the variable to the value"
constructor: (@config, lastState, @framework) ->
super(@config, lastState, @framework)
changeInputTo: (value) ->
variable = @framework.variableManager.getVariableByName(@_variableName)
unless variable?
throw new Error("Could not find variable with name #{@_variableName}")
@framework.variableManager.setVariableToValue(@_variableName, value, variable.unit)
timePattern = /// ^([01]?[0-9]|2[0-3]):[0-5][0-9] ///
hourPattern = ///
^[01]?[0-9]|2[0-3]
///
if value.match timePattern
@_setInput value
else
if value.match hourPattern
@_setInput value "#{textValue}:00"
else
throw new Error("Input value is not a valid time")
return Promise.resolve()
destroy: ->
super()
class DummySwitch extends SwitchActuator
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@_state = lastState?.state?.value or off
super()
changeStateTo: (state) ->
@_setState(state)
return Promise.resolve()
destroy: () ->
super()
class DummyDimmer extends DimmerActuator
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@_dimlevel = lastState?.dimlevel?.value or 0
@_state = lastState?.state?.value or off
super()
# Returns a promise that is fulfilled when done.
changeDimlevelTo: (level) ->
@_setDimlevel(level)
return Promise.resolve()
destroy: () ->
super()
class DummyShutter extends ShutterController
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@rollingTime = @config.rollingTime
@_position = lastState?.position?.value or 'stopped'
super()
stop: ->
@_setPosition('stopped')
return Promise.resolve()
# Returns a promise that is fulfilled when done.
moveToPosition: (position) ->
@_setPosition(position)
return Promise.resolve()
destroy: () ->
super()
class DummyHeatingThermostat extends HeatingThermostat
actions:
changeModeTo:
params:
mode:
type: "string"
changeTemperatureTo:
params:
temperatureSetpoint:
type: "number"
changeValveTo:
params:
valve:
type: "number"
constructor: (@config, lastState) ->
@id = @config.id
@name = @config.name
@_temperatureSetpoint = lastState?.temperatureSetpoint?.value or 20
@_mode = lastState?.mode?.value or "auto"
@_battery = lastState?.battery?.value or "ok"
@_synced = true
super()
changeModeTo: (mode) ->
@_setMode(mode)
return Promise.resolve()
changeValveTo: (valve) ->
@_setValve(valve)
return Promise.resolve()
changeTemperatureTo: (temperatureSetpoint) ->
@_setSetpoint(temperatureSetpoint)
return Promise.resolve()
destroy: () ->
super()
class DummyPresenceSensor extends PresenceSensor
actions:
changePresenceTo:
params:
presence:
type: "boolean"
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@_presence = lastState?.presence?.value or off
@_triggerAutoReset()
super()
changePresenceTo: (presence) ->
@_setPresence(presence)
@_triggerAutoReset()
return Promise.resolve()
_triggerAutoReset: ->
if @config.autoReset and @_presence
clearTimeout(@_resetPresenceTimeout)
@_resetPresenceTimeout = setTimeout(@_resetPresence, @config.resetTime)
_resetPresence: =>
@_setPresence(no)
destroy: () ->
clearTimeout(@_resetPresenceTimeout)
super()
class DummyContactSensor extends ContactSensor
actions:
changeContactTo:
params:
contact:
type: "boolean"
constructor: (@config, lastState) ->
@name = @config.name
@id = @config.id
@_contact = lastState?.contact?.value or off
super()
changeContactTo: (contact) ->
@_setContact(contact)
return Promise.resolve()
destroy: () ->
super()
class DummyTemperatureSensor extends TemperatureSensor
_humidity: null
attributes:
temperature:
description: "The measured temperature"
type: t.number
unit: '°C'
acronym: 'T'
humidity:
description: "The actual degree of Humidity"
type: t.number
unit: '%'
actions:
changeTemperatureTo:
params:
temperature:
type: "number"
changeHumidityTo:
params:
humidity:
type: "number"
constructor: (@config, lastState) ->
@id = @config.id
@name = @config.name
@_temperature = lastState?.temperature?.value
@_humidity = lastState?.humidity?.value
super()
_setHumidity: (value) ->
@_humidity = value
@emit 'humidity', value
getHumidity: -> Promise.resolve(@_humidity)
changeTemperatureTo: (temperature) ->
@_setTemperature(temperature)
return Promise.resolve()
changeHumidityTo: (humidity) ->
@_setHumidity(humidity)
return Promise.resolve()
destroy: () ->
super()
class Timer extends Device
attributes:
time:
description: "The elapsed time"
type: "number"
unit: "s"
displaySparkline: no
running:
description: "Is the timer running?"
type: "boolean"
actions:
startTimer:
description: "Starts the timer"
stopTimer:
description: "stops the timer"
resetTimer:
description: "reset the timer"
template: "timer"
constructor: (@config, lastState) ->
@id = @config.id
@name = @config.name
@_time = lastState?.time?.value or 0
@_running = lastState?.running?.value or false
@_setupInterval() if _running?
super()
resetTimer: () ->
if @_time is 0
return Promise.resolve()
@_time = 0
@emit 'time', 0
return Promise.resolve()
startTimer: () ->
if @_running
return Promise.resolve()
@_running = true
@emit 'running', true
@_setupInterval()
return Promise.resolve()
stopTimer: () ->
unless @_running
return Promise.resolve()
@_destroyInterval()
@_running = false
@emit 'running', false
return Promise.resolve()
getTime: () ->
return Promise.resolve(@_time)
getRunning: () ->
return Promise.resolve(@_running)
_setupInterval: ->
if @_interval? then return
res = @config.resolution
onTick = =>
@_time += res
@emit 'time', @_time
@_interval = setInterval(onTick, res * 1000)
_destroyInterval: ->
clearInterval(@_interval)
@_interval = null
destroy: ->
@_destroyInterval()
super()
class DeviceConfigExtension
extendConfigShema: (schema) ->
unless schema.extensions? then return
for name, def of @configSchema
if name in schema.extensions
schema.properties[name] = _.clone(def)
applicable: (schema) ->
unless schema.extensions? then return
for name, def of @configSchema
if name in schema.extensions
return yes
return false
class ConfirmDeviceConfigExtention extends DeviceConfigExtension
configSchema:
xConfirm:
description: "Triggering a device action needs a confirmation"
type: "boolean"
required: no
apply: (config, device) -> #should be handled by the frontend
class LinkDeviceConfigExtention extends DeviceConfigExtension
configSchema:
xLink:
description: "Open this link if the device label is clicked on the frontend"
type: "string"
required: no
apply: (config, device) -> #should be handled by the frontend
class XButtonDeviceConfigExtension extends DeviceConfigExtension
configSchema:
xButton:
description: "Label for xButton device extension"
type: "string"
required: no
apply: (config, device) -> #should be handled by the frontend
class PresentLabelConfigExtension extends DeviceConfigExtension
configSchema:
xPresentLabel:
description: "The label for the present state"
type: "string"
required: no
xAbsentLabel:
description: "The label for the absent state"
type: "string"
required: no
apply: (config, device) ->
if config.xPresentLabel? or config.xAbsentLabel?
device.attributes = _.cloneDeep(device.attributes)
device.attributes.presence.labels[0] = config.xPresentLabel if config.xPresentLabel?
device.attributes.presence.labels[1] = config.xAbsentLabel if config.xAbsentLabel?
class SwitchLabelConfigExtension extends DeviceConfigExtension
configSchema:
xOnLabel:
description: "The label for the on state"
type: "string"
required: no
xOffLabel:
description: "The label for the off state"
type: "string"
required: no
apply: (config, device) ->
if config.xOnLabel? or config.xOffLabel?
device.attributes = _.cloneDeep(device.attributes)
device.attributes.state.labels[0] = config.xOnLabel if config.xOnLabel?
device.attributes.state.labels[1] = config.xOffLabel if config.xOffLabel?
class ContactLabelConfigExtension extends DeviceConfigExtension
configSchema:
xClosedLabel:
description: "The label for the closed state"
type: "string"
required: no
xOpenedLabel:
description: "The label for the opened state"
type: "string"
required: no
apply: (config, device) ->
if config.xOpenedLabel? or config.xClosedLabel?
device.attributes = _.cloneDeep(device.attributes)
device.attributes.contact.labels[0] = config.xClosedLabel if config.xClosedLabel?
device.attributes.contact.labels[1] = config.xOpenedLabel if config.xOpenedLabel?
class ShutterLabelConfigExtension extends DeviceConfigExtension
configSchema:
xUpLabel:
description: "The label for the up position"
type: "string"
required: no
xDownLabel:
description: "The label for the down position"
type: "string"
required: no
xStoppedLabel:
description: "The label for the stopped position"
type: "string"
required: no
apply: (config, device) ->
if config.xUpLabel? or config.xDownLabel? or config.xStoppedLabel?
device.attributes = _.cloneDeep(device.attributes)
device.attributes.position.labels.up = config.xUpLabel if config.xUpLabel?
device.attributes.position.labels.down = config.xDownLabel if config.xDownLabel?
device.attributes.position.labels.stopped =
config.xStoppedLabel if config.xStoppedLabel?
class AttributeOptionsConfigExtension extends DeviceConfigExtension
configSchema:
xAttributeOptions:
description: "Extra attribute options for one or more attributes"
type: "array"
required: no
items:
type: "object"
required: ["name"]
properties:
name:
description: "Name for the corresponding attribute."
type: "string"
displaySparkline:
description: "Show a sparkline behind the numeric attribute"
type: "boolean"
required: false
hidden:
description: "Hide the attribute in the gui"
type: "boolean"
required: false
displayFormat:
description: """
Override formatting conventions used by the GUI to display
the value. Format outputs are: raw, fixed, localeString,
and uptime
"""
type: "string"
required: false
apply: (config, device) ->
if config.xAttributeOptions?
device.attributes = _.cloneDeep(device.attributes)
for attrOpts in config.xAttributeOptions
name = attrOpts.name
attr = device.attributes[name]
unless attr?
env.logger.warn(
"Can't apply xAttributeOptions for \"#{name}\". Device #{device.name}
has no attribute with this name"
)
continue
attr.displaySparkline = attrOpts.displaySparkline if attrOpts.displaySparkline?
attr.hidden = attrOpts.hidden if attrOpts.hidden?
attr.displayFormat = attrOpts.displayFormat if attrOpts.displayFormat?
class DeviceManager extends events.EventEmitter
devices: {}
deviceClasses: {}
deviceConfigExtensions: []
constructor: (@framework, @devicesConfig) ->
@deviceConfigExtensions.push(new ConfirmDeviceConfigExtention())
@deviceConfigExtensions.push(new LinkDeviceConfigExtention())
@deviceConfigExtensions.push(new XButtonDeviceConfigExtension())
@deviceConfigExtensions.push(new PresentLabelConfigExtension())
@deviceConfigExtensions.push(new SwitchLabelConfigExtension())
@deviceConfigExtensions.push(new ContactLabelConfigExtension())
@deviceConfigExtensions.push(new ShutterLabelConfigExtension())
@deviceConfigExtensions.push(new AttributeOptionsConfigExtension())
registerDeviceClass: (className, {configDef, createCallback, prepareConfig}) ->
assert typeof className is "string", "className must be a string"
assert typeof configDef is "object", "configDef must be an object"
assert typeof createCallback is "function", "createCallback must be a function"
assert(if prepareConfig? then typeof prepareConfig is "function" else true)
assert typeof configDef.properties is "object", """
configDef must have a property "properties"
"""
configDef.properties.id = {
description: "The ID for the device"
type: "string"
}
configDef.properties.name = {
description: "The name for the device"
type: "string"
}
configDef.properties.class = {
description: "The class to use for the device"
type: "string"
}
pluginName = @framework.pluginManager.getCallingPlugin()
for extension in @deviceConfigExtensions
extension.extendConfigShema(configDef)
@framework._normalizeScheme(configDef)
@deviceClasses[className] = {
prepareConfig
configDef
createCallback
pluginName
}
updateDeviceOrder: (deviceOrder) ->
assert deviceOrder? and Array.isArray deviceOrder
@framework.config.devices = @devicesConfig = _.sortBy(@devicesConfig, (device) =>
index = deviceOrder.indexOf device.id
return if index is -1 then 99999 else index # push it to the end if not found
)
@framework.saveConfig()
@framework._emitDeviceOrderChanged(deviceOrder)
return deviceOrder
registerDevice: (device, isNew = true) ->
assert device?
assert device instanceof env.devices.Device
assert device._constructorCalled
unless device.logger?
classInfo = @deviceClasses[device.config.class]
if classInfo?
pluginName = classInfo.pluginName
else
pluginName = @framework.pluginManager.getCallingPlugin()
deviceLogger = env.logger.base.createSublogger([pluginName, device.config.class])
device.logger = deviceLogger
if isNew and @devices[device.id]?
throw new Error("Duplicate device id \"#{device.id}\"")
unless device.id.match /^[a-z0-9\-_]+$/i
env.logger.warn """
The id of #{device.id} contains a non alphanumeric letter or symbol.
This could lead to errors.
"""
for reservedWord in [" and ", " or "]
if device.name.indexOf(reservedWord) isnt -1
env.logger.warn """
Name of device "#{device.id}" contains an "#{reservedWord}".
This could lead to errors in rules.
"""
unless device instanceof ErrorDevice
if isNew
env.logger.info "New device \"#{device.name}\"..."
else
env.logger.info "Recreating \"#{device.name}\"..."
@devices[device.id]=device
for attrName, attr of device.attributes
do (attrName, attr) =>
device.on(attrName, onChange = (value) =>
@framework._emitDeviceAttributeEvent(device, attrName, attr, new Date(), value)
)
@_checkDestroyFunction(device)
device.afterRegister()
@framework._emitDeviceAdded(device) if isNew
return device
_loadDevice: (deviceConfig, lastDeviceState, oldDevice = null) ->
isNew = not oldDevice?
classInfo = @deviceClasses[deviceConfig.class]
unless classInfo?
throw new Error("Unknown device class \"#{deviceConfig.class}\"")
warnings = []
classInfo.prepareConfig(deviceConfig) if classInfo.prepareConfig?
@framework._normalizeScheme(classInfo.configDef)
@framework._validateConfig(
deviceConfig,
classInfo.configDef,
"config of device \"#{deviceConfig.id}\""
)
deviceConfig = declapi.enhanceJsonSchemaWithDefaults(classInfo.configDef, deviceConfig)
deviceLogger = env.logger.base.createSublogger([classInfo.pluginName, deviceConfig.class])
if oldDevice? and not oldDevice._destroyed
oldDevice.destroy()
assert(
oldDevice._destroyed,
"The device subclass #{oldDevice.config.class} did not call super() in destroy()"
)
device = classInfo.createCallback(deviceConfig, lastDeviceState, deviceLogger)
device.logger = deviceLogger
assert deviceConfig is device.config, """
You must assign the config to your device in the the constructor function of your device:
"@config = config"
"""
for name, valueAndTime of lastDeviceState
if device.attributes[name]?
meta = device._attributesMeta[name]
unless meta? then continue
# Do not set `meta.value` here, because internal state and meta could be divergent
# Should be better handled in a new pimatic "major" version
meta.history = [t:valueAndTime.time, v: valueAndTime.value]
for extension in @deviceConfigExtensions
if extension.applicable(classInfo.configDef)
extension.apply(device.config, device)
return @registerDevice(device, isNew)
_loadErrorDevice: (deviceConfig, error) ->
return @registerDevice(new ErrorDevice(deviceConfig, error))
loadDevices: ->
return Promise.each(@devicesConfig, (deviceConfig) =>
@framework.database.getLastDeviceState(deviceConfig.id).then( (lastDeviceState) =>
classInfo = @deviceClasses[deviceConfig.class]
if classInfo?
try
@_loadDevice(deviceConfig, lastDeviceState)
catch e
env.logger.error("Error loading device \"#{deviceConfig.id}\": #{e.message}")
env.logger.debug(e.stack)
@_loadErrorDevice(deviceConfig, e.message)
else
env.logger.warn("""
No plugin found for device "#{deviceConfig.id}" of class "#{deviceConfig.class}"!
""")
@_loadErrorDevice(deviceConfig, "Plugin not loaded")
)
)
getDeviceById: (id) -> @devices[id]
getDevices: -> (device for id, device of @devices)
getDeviceClasses: -> (className for className of @deviceClasses)
getDeviceConfigSchema: (className)-> @deviceClasses[className]?.configDef
addDeviceByConfig: (deviceConfig) ->
assert deviceConfig.id?
assert deviceConfig.class?
if @isDeviceInConfig(deviceConfig.id)
throw new Error(
"A device with the ID \"#{deviceConfig.id}\" is already in the config."
)
device = @_loadDevice(deviceConfig, {})
@addDeviceToConfig(deviceConfig)
return device
_checkDestroyFunction: (device) ->
if device.destroy is Device.prototype.destroy
deviceClass = device.config.class
unless @_alreadyWarnedFor?
@_alreadyWarnedFor = {}
if @_alreadyWarnedFor[deviceClass]?
return
@_alreadyWarnedFor[deviceClass] = true
env.logger.warn("The device type #{deviceClass} does not implement a destroy function")
recreateDevice: (oldDevice, newDeviceConfig) ->
return @framework.database.getLastDeviceState(oldDevice.id).then( (lastDeviceState) =>
loadDeviceError = null
try
newDevice = @_loadDevice(newDeviceConfig, lastDeviceState, oldDevice)
catch err
loadDeviceError = err
if oldDevice._destroyed
# the old device was destroyed but there was an error creating the new device,
# we have to recreate the original (old) device
try
newDevice = @_loadDevice(oldDevice.config, lastDeviceState, oldDevice)
catch err
# we failed to restore the old destroyed device, we log this error and
# rethrow the first error
logger = oldDevice.logger or env.logger
logger.error("Error restoring changed device #{oldDevice.id}: #{err.message}")
logger.debug(err.stack)
throw loadDeviceError
else
# the old device was not destroyed, so just throw the load device error
throw loadDeviceError
oldDevice.emit 'changed', newDevice
@emit 'deviceChanged', newDevice
# rethrow the error if the creation of the device with the new config failed
if loadDeviceError?
throw loadDeviceError
return newDevice
)
discoverDevices: (time = 20000) ->
env.logger.info("Starting device discovery for #{time}ms.")
@emit 'discover', {time}
discoverMessage: (pluginName, message) ->
env.logger.info("#{pluginName}: #{message}")
@emit 'discoverMessage', {pluginName, message}
discoveredDevice: (pluginName, deviceName, config) ->
env.logger.info("Device discovered: #{pluginName}: #{deviceName}")
@emit 'deviceDiscovered', {pluginName, deviceName, config}
updateDeviceByConfig: (deviceConfig) ->
unless deviceConfig.id?
throw new Error("No id given")
device = @getDeviceById(deviceConfig.id)
unless device?
throw new Error("device not found: #{deviceConfig.id}")
return @recreateDevice(device, deviceConfig)
removeDevice: (deviceId) ->
device = @getDeviceById(deviceId)
unless device? then return
delete @devices[deviceId]
@emit 'deviceRemoved', device
device.emit 'remove'
device.destroy()
assert(
device._destroyed,
"The device subclass #{device.config.class} did not call super() in destroy()"
)
device.emit 'destroyed'
return device
addDeviceToConfig: (deviceConfig) ->
assert deviceConfig.id?
assert deviceConfig.class?
# Check if device is already in the deviceConfig:
present = @isDeviceInConfig deviceConfig.id
if present
throw new Error(
"An device with the ID #{deviceConfig.id} is already in the config"
)
@devicesConfig.push deviceConfig
@framework.saveConfig()
callDeviceActionReq: (params, req) =>
deviceId = req.params.deviceId
actionName = req.params.actionName
device = @getDeviceById(deviceId)
unless device?
throw new Error("device not found: #{deviceId}")
unless device.hasAction(actionName)
throw new Error('device hasn\'t that action')
action = device.actions[actionName]
return declapi.callActionFromReq(actionName, action, device, req)
callDeviceActionSocket: (params, call) =>
deviceId = call.params.deviceId
actionName = call.params.actionName
device = @getDeviceBy