UNPKG

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
### 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