UNPKG

pimatic

Version:

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

1,158 lines (1,039 loc) 43.8 kB
### Framework ========= ### assert = require 'cassert' fs = require "fs" JaySchema = require 'jayschema' RJSON = require 'relaxed-json' i18n = require 'i18n-pimatic' express = require "express" methodOverride = require 'method-override' connectTimeout = require 'connect-timeout' cookieParser = require 'cookie-parser' bodyParser = require 'body-parser' cookieSession = require 'cookie-session' basicAuth = require 'basic-auth' socketIo = require 'socket.io' # Require engine.io from socket.io engineIo = require.cache[require.resolve('socket.io')].require('engine.io') Promise = require 'bluebird' path = require 'path' S = require 'string' _ = require 'lodash' declapi = require 'decl-api' util = require 'util' jsonlint = require 'yet-another-jsonlint' events = require 'events' module.exports = (env) -> class Framework extends events.EventEmitter configFile: null app: null io: null ruleManager: null pluginManager: null variableManager: null deviceManager: null groupManager: null pageManager: null database: null config: null _publicPathes: {} constructor: (@configFile) -> assert @configFile? @maindir = path.resolve __dirname, '..' env.logger.winston.on("logged", (level, msg, meta) => @_emitMessageLoggedEvent(level, msg, meta) ) @pluginManager = new env.plugins.PluginManager(this) @pluginManager.on('updateProcessStatus', (status, info) => @_emitUpdateProcessStatus(status, info) ) @pluginManager.on('updateProcessMessage', (message, info) => @_emitUpdateProcessMessage(message, info) ) @packageJson = @pluginManager.getInstalledPackageInfo('pimatic') env.logger.info "Starting pimatic version #{@packageJson.version}" env.logger.info "Node.js version #{process.versions.node}" env.logger.info "OpenSSL version #{process.versions.openssl}" @_loadConfig() @pluginManager.pluginsConfig = @config.plugins @userManager = new env.users.UserManager(this, @config.users, @config.roles) @deviceManager = new env.devices.DeviceManager(this, @config.devices) @groupManager = new env.groups.GroupManager(this, @config.groups) @pageManager = new env.pages.PageManager(this, @config.pages) @variableManager = new env.variables.VariableManager(this, @config.variables) @ruleManager = new env.rules.RuleManager(this, @config.rules) @database = new env.database.Database(this, @config.settings.database) @deviceManager.on('deviceRemoved', (device) => group = @groupManager.getGroupOfDevice(device.id) @groupManager.removeDeviceFromGroup(group.id, device.id) if group? @pageManager.removeDeviceFromAllPages(device.id) ) @ruleManager.on('ruleRemoved', (rule) => group = @groupManager.getGroupOfRule(rule.id) @groupManager.removeRuleFromGroup(group.id, rule.id) if group? ) @variableManager.on('variableRemoved', (variable) => group = @groupManager.getGroupOfVariable(variable.name) @groupManager.removeVariableFromGroup(group.id, variable.name) if group? ) for discoverEvent in ['discover', 'discoverMessage', 'deviceDiscovered'] do (discoverEvent) => @deviceManager.on(discoverEvent, (eventData) => @io?.emit(discoverEvent, eventData) ) @_setupExpressApp() _normalizeScheme: (scheme) -> if scheme._normalized then return if scheme.type is "object" and typeof scheme.properties is "object" requiredProps = scheme.required or [] for own prop, s of scheme.properties isRequired = true if typeof s.required is "boolean" if s.required is false isRequired = false delete s.required if s.default? isRequired = false if isRequired and not (prop in requiredProps) requiredProps.push prop @_normalizeScheme(s) if s? if s.defines?.options? for own optName, opt of s.defines.options @_normalizeScheme(opt) if requiredProps.length > 0 scheme.required = requiredProps unless scheme.additionalProperties? scheme.additionalProperties = false if scheme.type is "array" @_normalizeScheme(scheme.items) if scheme.items? scheme._normalized = true _validateConfig: (config, schema, scope = "config") -> js = new JaySchema() errors = js.validate(config, schema) if errors.length > 0 errorMessage = "Invalid #{scope}: " for e, i in errors if i > 0 then errorMessage += ", " if e.kind is "ObjectValidationError" and e.constraintName is "required" errorMessage += e.desc.replace(/^missing: (.*)$/, 'Missing property "$1"') else if e.kind is "ObjectValidationError" and e.constraintName is "additionalProperties" and e.testedValue? errorMessage += "Property \"#{e.testedValue}\" is not a valid property" else if e.desc? errorMessage += e.desc else errorMessage += ( "Property \"#{e.instanceContext}\" Should have #{e.constraintName} " + "#{e.constraintValue}" ) if e.testedValue? then errorMessage += ", was: #{e.testedValue}" if e.instanceContext? and e.instanceContext.length > 1 errorMessage += " in " + e.instanceContext.replace('#', '') #throw new Error(errorMessage) env.logger.error(errorMessage) _loadConfig: () -> schema = require("../config-schema") contents = fs.readFileSync(@configFile).toString() instance = jsonlint.parse(RJSON.transform(contents)) # some legacy support for old single user auth = instance.settings?.authentication if auth?.username? and auth?.password? and (not instance.users?) unless instance.users? instance.users = [ { username: auth.username, password: auth.password, role: "admin" } ] delete auth.username delete auth.password env.logger.warn("Move user authentication setting to new users definition!") @_normalizeScheme(schema) @_validateConfig(instance, schema) @config = declapi.enhanceJsonSchemaWithDefaults(schema, instance) for role, i in @config.roles @config.roles[i] = declapi.enhanceJsonSchemaWithDefaults( schema.properties.roles.items, role ) assert Array.isArray @config.plugins assert Array.isArray @config.devices assert Array.isArray @config.pages assert Array.isArray @config.groups @_checkConfig(@config) # * Set the log level env.logger.winston.transports.taggedConsoleLogger.level = @config.settings.logLevel if @config.settings.debug env.logger.logDebug = true env.logger.debug("settings.debug is true, showing debug output for pimatic core.") i18n.configure({ locales:['en', 'de'], directory: __dirname + '/../locales', defaultLocale: @config.settings.locale, }) events.EventEmitter.defaultMaxListeners = @config.settings.defaultMaxListeners _checkConfig: (config)-> checkForDublicate = (type, collection, idProperty) => ids = {} for e in collection id = e[idProperty] if ids[id]? throw new Error( "Duplicate #{type} #{id} in config." ) ids[id] = yes checkForDublicate("plugin", config.plugins, 'plugin') checkForDublicate("device", config.devices, 'id') checkForDublicate("rules", config.rules, 'id') checkForDublicate("variables", config.variables, 'name') checkForDublicate("groups", config.groups, 'id') checkForDublicate("pages", config.pages, 'id') # Check groups, rules, variables, pages integrity logWarning = (type, id, name, collection = "group") -> env.logger.warn( """Could not find a #{type} with the ID "#{id}" from """ + """#{collection} "#{name}" in #{type}s config section.""" ) for group in config.groups for deviceId in group.devices found = _.find(config.devices, {id: deviceId}) unless found? logWarning('device', deviceId, group.id) for ruleId in group.rules found = _.find(config.rules, {id: ruleId}) unless found? logWarning('rule', ruleId, group.id) for variableName in group.variables found = _.find(config.variables, {name: variableName}) unless found? logWarning('variable', variableName, group.id) for page in config.pages for item in page.devices found = _.find(config.devices, {id: item.deviceId}) unless found? logWarning('device', item.deviceId, page.id, 'page') _setupExpressApp: () -> # Setup express # ------------- @app = express() @app.use(methodOverride('X-HTTP-Method-Override')) @app.use(connectTimeout("5min", respond: false)) extraHeaders = {} @corsEnabled = @config.settings.cors? and not _.isEmpty(@config.settings.cors.allowedOrigin) if @corsEnabled extraHeaders["Access-Control-Allow-Origin"] = @config.settings.cors.allowedOrigin extraHeaders["Access-Control-Allow-Credentials"] = true extraHeaders["Access-Control-Allow-Methods"] = "GET,PUT,POST,DELETE" extraHeaders["Access-Control-Allow-Headers"] = "Content-Type, Authorization" @app.use( (req, res, next) => for own key, value of extraHeaders res.header(key, value) if @corsEnabled and req.method is 'OPTIONS' return res.sendStatus 200 req.on("timeout", => env.logger.warn( "http request handler timeout. Possible unhandled request: #{req.method} #{req.url}" ) env.logger.debug(req.body) if req.body? ) next() ) #@app.use express.logger() @app.use cookieParser() @app.use bodyParser.urlencoded(limit: '10mb', extended: true) @app.use bodyParser.json(limit: '10mb') auth = @config.settings.authentication validSecret = ( auth.secret? and typeof auth.secret is "string" and auth.secret.length >= 32 ) unless validSecret auth.secret = require('crypto').randomBytes(64).toString('base64') assert typeof auth.secret is "string" assert auth.secret.length >= 32 @app.use cookieSession({ secret: auth.secret key: 'pimatic.sess' cookie: { maxAge: null } }) # Setup authentication # ---------------------- # Use http-basicAuth if authentication is not disabled. assert auth.enabled in [yes, no] if auth.enabled is yes for user in @config.users #Check authentication. validUsername = ( user.username? and typeof user.username is "string" and user.username.length isnt 0 ) unless validUsername throw new Error( "Authentication is enabled, but no username has been defined for the user. " + "Please define a username in the user section of the config.json file." ) validPassword = ( user.password? and typeof user.password is "string" and user.password.length isnt 0 ) unless validPassword throw new Error( "Authentication is enabled, but no password has been defined for the user " + "\"#{user.username}\". Please define a password for \"#{user.username}\" " + "in the users section of the config.json file or disable authentication." ) #req.path @app.use( (req, res, next) => if req.path is "/login" then return next() # auth is deactivated so we allways continue if auth.enabled is no req.session.username = '' return next() if @userManager.isPublicAccessAllowed(req) return next() # if already logged in so just continue loggedIn = ( typeof req.session.username is "string" and typeof req.session.loginToken is "string" and req.session.username.length > 0 and req.session.loginToken.length > 0 and @userManager.checkLoginToken(auth.secret, req.session.username, req.session.loginToken) ) if loggedIn return next() # else use basic authorization unauthorized = (res) => res.set('WWW-Authenticate', 'Basic realm=Authorization Required') return res.status(401).send("Unauthorized") authInfo = basicAuth(req) if !authInfo or !authInfo.name or !authInfo.pass return unauthorized(res) if @userManager.checkLogin(authInfo.name, authInfo.pass) role = @userManager.getUserByUsername(authInfo.name).role assert role? and typeof role is "string" and role.length > 0 req.session.username = authInfo.name req.session.loginToken = @userManager.getLoginTokenForUsername( auth.secret, authInfo.name ) req.session.role = role next() else delete req.session.username delete req.session.loginToken delete req.session.role unauthorized(res) ) @app.post('/login', (req, res) => user = req.body.username password = req.body.password rememberMe = req.body.rememberMe if rememberMe is 'true' then rememberMe = yes if rememberMe is 'false' then rememberMe = no rememberMe = !!rememberMe if @userManager.checkLogin(user, password) role = @userManager.getUserByUsername(user).role assert role? and typeof role is "string" and role.length > 0 req.session.username = user req.session.loginToken = @userManager.getLoginTokenForUsername(auth.secret, user) req.session.role = role req.session.rememberMe = rememberMe if rememberMe and auth.loginTime isnt 0 req.sessionOptions.maxAge = auth.loginTime else req.sessionOptions.maxAge = null #env.logger.info "User login: #{user}" res.send({ success: yes username: user role: role rememberMe: rememberMe }) else delete req.session.username delete req.session.loginToken delete req.session.role delete req.session.rememberMe env.logger.error "User login failed: Wrong username or password" res.status(401).send({ success: false message: __("Wrong username or password.") }) ) @app.get('/logout', (req, res) => #if req.session?.username? # env.logger.info "User logout: #{req.session.username}" req.session = null res.status(401).send("You are now logged out.") return ) serverEnabled = ( @config.settings.httpsServer?.enabled or @config.settings.httpServer?.enabled ) unless serverEnabled env.logger.warn "You have no HTTPS and no HTTP server enabled!" @_initRestApi() socketIoPath = '/socket.io' engine = new engineIo.Server({path: socketIoPath}) @io = new socketIo() @io.use( (socket, next) => if auth.enabled is no return next() req = socket.request if req.query.username? and req.query.password? if @userManager.checkLogin(req.query.username, req.query.password) socket.username = req.query.username return next() else return next(new Error('unauthorizied')) else if req.session? loggedIn = ( typeof req.session.username is "string" and typeof req.session.loginToken is "string" and req.session.username.length > 0 and req.session.loginToken.length > 0 and @userManager.checkLoginToken( auth.secret, req.session.username, req.session.loginToken ) ) if loggedIn socket.username = req.session.username return next() else return next(new Error('Authentication error')) else return next(new Error('Unauthorized')) ) @io.bind(engine) @app.all( '/socket.io/socket.io.js', (req, res) => @io.serve(req, res) ) @app.all( '/socket.io/*', (req, res) => engine.handleRequest(req, res) ) @app.use( (err, req, res, next) => env.logger.error("Error on incoming http request to #{req.path}: #{err.message}") env.logger.debug(err) unless res.headersSent res.status(500).send(err.stack) ) onUpgrade = (req, socket, head) => if socketIoPath is req.url.substr(0, socketIoPath.length) engine.handleUpgrade(req, socket, head) else socket.end() return # Start the HTTPS-server if it is enabled. if @config.settings.httpsServer?.enabled httpsConfig = @config.settings.httpsServer assert httpsConfig instanceof Object assert typeof httpsConfig.keyFile is 'string' and httpsConfig.keyFile.length isnt 0 assert typeof httpsConfig.certFile is 'string' and httpsConfig.certFile.length isnt 0 httpsOptions = {} httpsOptions[name] = value for name, value of httpsConfig httpsOptions.key = fs.readFileSync path.resolve(@maindir, '../..', httpsConfig.keyFile) httpsOptions.cert = fs.readFileSync path.resolve(@maindir, '../..', httpsConfig.certFile) https = require "https" @app.httpsServer = https.createServer httpsOptions, @app @app.httpsServer.on('upgrade', onUpgrade) # Start the HTTP-server if it is enabled. if @config.settings.httpServer?.enabled http = require "http" @app.httpServer = http.createServer @app @app.httpServer.on('upgrade', onUpgrade) actionsWithBindings = [ [env.api.framework.actions, this] [env.api.rules.actions, @ruleManager] [env.api.variables.actions, @variableManager] [env.api.plugins.actions, @pluginManager] [env.api.database.actions, @database] [env.api.groups.actions, @groupManager] [env.api.pages.actions, @pageManager] [env.api.devices.actions, @deviceManager] ] onError = (error) => env.logger.error(error.message) env.logger.debug(error) checkPermissions = (socket, action) => if auth.enabled is no then return true hasPermission = no if action.permission? and action.permission.scope? hasPermission = @userManager.hasPermission( socket.username, action.permission.scope, action.permission.access ) else if action.permission? and action.permission.action? hasPermission = @userManager.hasPermissionBoolean( socket.username, action.permission.action ) else hasPermission = yes return hasPermission @io.on('connection', (socket) => declapi.createSocketIoApi(socket, actionsWithBindings, onError, checkPermissions) if auth.enabled is yes username = socket.username role = @userManager.getUserByUsername(username).role permissions = @userManager.getPermissionsByUsername(username) else username = 'nobody' role = 'no' permissions = { pages: "write" rules: "write" variables: "write" messages: "write" events: "write" devices: "write" groups: "write" plugins: "write" updates: "write" controlDevices: true restart: true } socket.emit('hello', { username role permissions }) if ( auth.enabled is no or @userManager.hasPermission(username, 'devices', 'read') or @userManager.hasPermission(username, 'pages', 'read') ) socket.emit('devices', (d.toJson() for d in @deviceManager.getDevices()) ) else socket.emit('devices', []) if auth.enabled is no or @userManager.hasPermission(username, 'rules', 'read') socket.emit('rules', (r.toJson() for r in @ruleManager.getRules()) ) else socket.emit('rules', []) if auth.enabled is no or @userManager.hasPermission(username, 'rules', 'read') socket.emit('variables', (v.toJson() for v in @variableManager.getVariables()) ) else socket.emit('variables', []) if auth.enabled is no or @userManager.hasPermission(username, 'pages', 'read') socket.emit('pages', @pageManager.getPages(role) ) else socket.emit('pages', []) needsRules = ( auth.enabled is no or @userManager.hasPermission(username, 'devices', 'read') or @userManager.hasPermission(username, 'rules', 'read') or @userManager.hasPermission(username, 'variables', 'read') or @userManager.hasPermission(username, 'pages', 'read') or @userManager.hasPermission(username, 'groups', 'read') ) if needsRules socket.emit('groups', @groupManager.getGroups() ) else socket.emit('groups', []) ) listen: () -> genErrFunc = (serverConfig) => return (err) => msg = "Could not listen on port #{serverConfig.port}. Error: #{err.message}. " switch err.code when "EACCES" then msg += "Are you root?." when "EADDRINUSE" then msg += "Is a server already running?" env.logger.error msg env.logger.debug err.stack err.silent = yes throw err listenPromises = [] if @app.httpsServer? httpsServerConfig = @config.settings.httpsServer @app.httpsServer.on 'error', genErrFunc(httpsServerConfig) awaiting = Promise.fromCallback( (callback) => @app.httpsServer.listen(httpsServerConfig.port, httpsServerConfig.hostname, callback) ) listenPromises.push awaiting.then( => env.logger.info "Listening for HTTPS-request on port #{httpsServerConfig.port}..." ) if @app.httpServer? httpServerConfig = @config.settings.httpServer @app.httpServer.on 'error', genErrFunc(@config.settings.httpServer) awaiting = Promise.fromCallback( (callback) => if !!httpServerConfig.socket httpServer = @app.httpServer fs.stat httpServerConfig.socket, (err, stat) -> if err httpServer.listen(httpServerConfig.socket, callback) else env.logger.info 'Removing leftover socket.' fs.unlink httpServerConfig.socket, (err) -> if err env.logger.info 'Cannot remove socket file, exiting.' process.exit(0) else httpServer.listen(httpServerConfig.socket, callback) else @app.httpServer.listen(httpServerConfig.port, httpServerConfig.hostname, callback) ) listenPromises.push awaiting.then( => if !!httpServerConfig.socket env.logger.info "Listening for HTTP-request on #{httpServerConfig.socket}..." else env.logger.info "Listening for HTTP-request on port #{httpServerConfig.port}..." ) Promise.all(listenPromises).then( => @emit "server listen", "startup" ) restart: () -> daemonized = process.env['PIMATIC_DAEMONIZED'] unless daemonized? throw new Error( 'Can not restart self, when not daemonized. ' + 'Please run pimatic with: "node ' + process.argv[1] + ' start" to use this feature.' ) env.logger.info("Restarting...") # first we call destroy to be able to release resources allocated by the current process. # next, we launch the restart script with the daemonizer, which will send the kill signal # to this process proxy = new events() isRejected = false @destroy() .catch (err) -> isRejected = true env.logger.error("Error during orderly shutdown of pimatic: #{err.message}") env.logger.debug(err.stack) .finally -> if daemonized is 'pm2-docker' process.exit(unless isRejected then 0 else 1) else daemon = require 'daemon' scriptName = process.argv[1] args = process.argv[2..]; args[0] = 'restart' child = daemon.daemon(scriptName, args, {cwd: process.cwd()}) child.on 'error', (error) -> proxy.emit 'error', error child.on 'close', (code) -> proxy.emit 'close', code # Catch errors executing the restart script return new Promise( (resolve, reject) => proxy.on('error', reject) proxy.on('close', (code) -> if code is 0 then resolve() else reject(new Error("Error restarting pimatic, exit code #{code}")) ) ).catch( (err) => env.logger.error("Error restarting pimatic: #{err.message}") env.logger.debug(err.stack) ) getGuiSettings: () -> { config: @config.settings.gui defaults: @config.settings.gui.__proto__ } _emitDeviceAttributeEvent: (device, attributeName, attribute, time, value) -> @emit 'deviceAttributeChanged', {device, attributeName, attribute, time, value} @io?.emit( 'deviceAttributeChanged', {deviceId: device.id, attributeName, time: time.getTime(), value} ) _emitDeviceEvent: (eventType, device) -> @emit(eventType, device) @io?.emit(eventType, device.toJson()) _emitDeviceAdded: (device) -> @_emitDeviceEvent('deviceAdded', device) _emitDeviceChanged: (device) -> @_emitDeviceEvent('deviceChanged', device) _emitDeviceRemoved: (device) -> @_emitDeviceEvent('deviceRemoved', device) _emitDeviceOrderChanged: (deviceOrder) -> @_emitOrderChanged('deviceOrderChanged', deviceOrder) _emitMessageLoggedEvent: (level, msg, meta) -> @emit 'messageLogged', {level, msg, meta} @io?.emit 'messageLogged', {level, msg, meta} _emitOrderChanged: (eventName, order) -> @emit(eventName, order) @io?.emit(eventName, order) _emitPageEvent: (eventType, page) -> @emit(eventType, page) @io?.emit(eventType, page) _emitPageAdded: (page) -> @_emitPageEvent('pageAdded', page) _emitPageChanged: (page) -> @_emitPageEvent('pageChanged', page) _emitPageRemoved: (page) -> @_emitPageEvent('pageRemoved', page) _emitPageOrderChanged: (pageOrder) -> @_emitOrderChanged('pageOrderChanged', pageOrder) _emitGroupEvent: (eventType, group) -> @emit(eventType, group) @io?.emit(eventType, group) _emitGroupAdded: (group) -> @_emitGroupEvent('groupAdded', group) _emitGroupChanged: (group) -> @_emitGroupEvent('groupChanged', group) _emitGroupRemoved: (group) -> @_emitGroupEvent('groupRemoved', group) _emitGroupOrderChanged: (proupOrder) -> @_emitOrderChanged('groupOrderChanged', proupOrder) _emitRuleEvent: (eventType, rule) -> @emit(eventType, rule) @io?.emit(eventType, rule.toJson()) _emitRuleAdded: (rule) -> @_emitRuleEvent('ruleAdded', rule) _emitRuleRemoved: (rule) -> @_emitRuleEvent('ruleRemoved', rule) _emitRuleChanged: (rule) -> @_emitRuleEvent('ruleChanged', rule) _emitRuleOrderChanged: (ruleOrder) -> @_emitOrderChanged('ruleOrderChanged', ruleOrder) _emitVariableEvent: (eventType, variable) -> @emit(eventType, variable) @io?.emit(eventType, variable.toJson()) _emitVariableAdded: (variable) -> @_emitVariableEvent('variableAdded', variable) _emitVariableRemoved: (variable) -> @_emitVariableEvent('variableRemoved', variable) _emitVariableChanged: (variable) -> @_emitVariableEvent('variableChanged', variable) _emitVariableValueChanged: (variable, value) -> @emit("variableValueChanged", variable, value) @io?.emit("variableValueChanged", { variableName: variable.name variableValue: value }) _emitVariableOrderChanged: (variableOrder) -> @_emitOrderChanged('variableOrderChanged', variableOrder) _emitUpdateProcessStatus: (status, info) -> @emit 'updateProcessStatus', status, info @io?.emit("updateProcessStatus", { status: status modules: info.modules }) _emitUpdateProcessMessage: (message, info) -> @emit 'updateProcessMessages', message, info @io?.emit("updateProcessMessage", { message: message modules: info.modules }) init: -> initVariables = => @variableManager.init() @variableManager.on("variableChanged", (changedVar) => for variable in @config.variables if variable.name is changedVar.name delete variable.value delete variable.expression switch changedVar.type when 'value' then variable.value = changedVar.value when 'expression' then variable.expression = changedVar.exprInputStr break @_emitVariableChanged(changedVar) @emit "config" ) @variableManager.on("variableValueChanged", (changedVar, value) => if changedVar.type is 'value' for variable in @config.variables if variable.name is changedVar.name variable.value = value break @emit "config" @_emitVariableValueChanged(changedVar, value) ) @variableManager.on("variableAdded", (addedVar) => switch addedVar.type when 'value' then @config.variables.push({ name: addedVar.name, value: addedVar.value }) when 'expression' then @config.variables.push({ name: addedVar.name, expression: addedVar.exprInputStr }) @_emitVariableAdded(addedVar) @emit "config" ) @variableManager.on("variableRemoved", (removedVar) => for variable, i in @config.variables if variable.name is removedVar.name @config.variables.splice(i, 1) break @_emitVariableRemoved(removedVar) @emit "config" ) initDevices = => @deviceManager.on("deviceRemoved", (removedDevice) => for device, i in @config.devices if device.id is removedDevice.id @config.devices.splice(i, 1) break @_emitDeviceRemoved(removedDevice) @emit "config" ) @deviceManager.on("deviceChanged", (changedDevice) => for device, i in @config.devices if device.id is changedDevice.id @config.devices[i] = changedDevice.config break @_emitDeviceChanged(changedDevice) @emit "config" ) initActionProvider = => defaultActionProvider = [ env.actions.SetPresenceActionProvider env.actions.ContactActionProvider env.actions.SwitchActionProvider env.actions.DimmerActionProvider env.actions.LogActionProvider env.actions.SetVariableActionProvider env.actions.ShutterActionProvider env.actions.StopShutterActionProvider env.actions.ButtonActionProvider env.actions.ToggleActionProvider env.actions.HeatingThermostatModeActionProvider env.actions.HeatingThermostatSetpointActionProvider env.actions.TimerActionProvider env.actions.AVPlayerPauseActionProvider env.actions.AVPlayerStopActionProvider env.actions.AVPlayerPlayActionProvider env.actions.AVPlayerVolumeActionProvider env.actions.AVPlayerNextActionProvider env.actions.AVPlayerPrevActionProvider ] for actProv in defaultActionProvider actProvInst = new actProv(this) @ruleManager.addActionProvider(actProvInst) initPredicateProvider = => defaultPredicateProvider = [ env.predicates.PresencePredicateProvider env.predicates.SwitchPredicateProvider env.predicates.DeviceAttributePredicateProvider env.predicates.VariablePredicateProvider env.predicates.VariableUpdatedPredicateProvider env.predicates.ContactPredicateProvider env.predicates.ButtonPredicateProvider env.predicates.DeviceAttributeWatchdogProvider env.predicates.StartupPredicateProvider ] for predProv in defaultPredicateProvider predProvInst = new predProv(this) @ruleManager.addPredicateProvider(predProvInst) initRules = => addRulePromises = (for rule in @config.rules do (rule) => unless rule.active? then rule.active = yes unless rule.id.match /^[a-z0-9\-_]+$/i newId = S(rule.id).slugify().s env.logger.warn """ The ID of the rule "#{rule.id}" contains a non alphanumeric letter or symbol. Changing the ID of the rule to "#{newId}". """ rule.id = newId if rule.rule.match /^if .+/ env.logger.warn """ Converting old rule "#{rule.id}" from "if ... then ..." to "when ... then ..."! """ rule.rule = rule.rule.replace(/^if/, "when") unless rule.name? then rule.name = S(rule.id).humanize().s @ruleManager.addRuleByString(rule.id, { name: rule.name, ruleString: rule.rule, active: rule.active logging: rule.logging }, force = true).catch( (err) => env.logger.error "Could not parse rule \"#{rule.rule}\": " + err.message env.logger.debug err.stack ) ) return Promise.all(addRulePromises).then(=> # Save rule updates to the config file: # # * If a new rule was added then... @ruleManager.on "ruleAdded", (rule) => # ...add it to the rules Array in the config.json file inConfig = (_.findIndex(@config.rules , {id: rule.id}) isnt -1) unless inConfig @config.rules.push { id: rule.id name: rule.name rule: rule.string active: rule.active logging: rule.logging } @_emitRuleAdded(rule) @emit "config" # * If a rule was changed then... @ruleManager.on "ruleChanged", (rule) => # ...change the rule with the right id in the config.json file @config.rules = for r in @config.rules if r.id is rule.id { id: rule.id, name: rule.name, rule: rule.string, active: rule.active, logging: rule.logging } else r @_emitRuleChanged(rule) @emit "config" # * If a rule was removed then @ruleManager.on "ruleRemoved", (rule) => # ...Remove the rule with the right ID in the config.json file @config.rules = (r for r in @config.rules when r.id isnt rule.id) @_emitRuleRemoved(rule) @emit "config" ) return @database.init() .then( => @pluginManager.checkNpmVersion() ) .then( => @pluginManager.loadPlugins() ) .then( => @pluginManager.initPlugins() ) .then( => @deviceManager.initDevices() ) .then( => @deviceManager.loadDevices() ) .then(initVariables) .then(initDevices) .then(initActionProvider) .then(initPredicateProvider) .then(initRules) .then( => # Save the config on "config" event @on "config", => @saveConfig() context = waitFor: [] waitForIt: (promise) -> @waitFor.push promise @emit "after init", context Promise.all(context.waitFor).then => @listen() ) _initRestApi: -> auth = @config.settings.authentication onError = (error) => if error instanceof Error message = error.message env.logger.error error.message env.logger.debug error.stack @app.get("/api", (req, res, nest) => res.send(declapi.stringifyApi(env.api.all)) ) @app.get("/api/decl-api-client.js", declapi.serveClient) createPermissionCheck = (app, actions) => for actionName, action of actions do (actionName, action) => if action.rest? and action.permission? type = (action.rest.type or 'get').toLowerCase() url = action.rest.url app[type](url, (req, res, next) => if auth.enabled is yes username = req.session.username if action.permission.scope? hasPermission = @userManager.hasPermission( username, action.permission.scope, action.permission.access ) else if action.permission.action? hasPermission = @userManager.hasPermissionBoolean( username, action.permission.action ) else throw new Error("Unknown permissions declaration for action #{action}") else username = "nobody" hasPermission = yes if hasPermission is yes @userManager.requestUsername = username next() @userManager.requestUsername = null else res.status(403).send() ) createPermissionCheck(@app, env.api.framework.actions) createPermissionCheck(@app, env.api.rules.actions) createPermissionCheck(@app, env.api.variables.actions) createPermissionCheck(@app, env.api.plugins.actions) createPermissionCheck(@app, env.api.database.actions) createPermissionCheck(@app, env.api.groups.actions) createPermissionCheck(@app, env.api.pages.actions) createPermissionCheck(@app, env.api.devices.actions) declapi.createExpressRestApi(@app, env.api.framework.actions, this, onError) declapi.createExpressRestApi(@app, env.api.rules.actions, this.ruleManager, onError) declapi.createExpressRestApi(@app, env.api.variables.actions, this.variableManager, onError) declapi.createExpressRestApi(@app, env.api.plugins.actions, this.pluginManager, onError) declapi.createExpressRestApi(@app, env.api.database.actions, this.database, onError) declapi.createExpressRestApi(@app, env.api.groups.actions, this.groupManager, onError) declapi.createExpressRestApi(@app, env.api.pages.actions, this.pageManager, onError) declapi.createExpressRestApi(@app, env.api.devices.actions, this.deviceManager, onError) getConfig: (password) -> #blank passwords blankSecrets = (schema, obj) -> switch schema.type when "object" if schema.properties? for n, p of schema.properties if p.secret and obj[n]? obj[n] = 'xxxxxxxxxx' blankSecrets(p, obj[n]) if obj[n]? when "array" if schema.items? and obj? for e in obj blankSecrets schema.items, e schema = require("../config-schema") configCopy = _.cloneDeep(@config) delete configCopy['//'] assert @userManager.requestUsername if password? unless typeof password is "string" throw new Error("Password is not a string") unless @userManager.checkLogin(@userManager.requestUsername, password) throw new Error("Invalid password") else blankSecrets schema, configCopy return configCopy updateConfig: (config) -> schema = require("../config-schema") @_normalizeScheme(schema) @_validateConfig(config, schema) assert Array.isArray config.plugins assert Array.isArray config.devices assert Array.isArray config.pages assert Array.isArray config.groups @_checkConfig(config) for pConf in config.plugins fullPluginName = "pimatic-#{pConf.plugin}" packageInfo = null try packageInfo = @pluginManager.getInstalledPackageInfo(fullPluginName) catch err env.logger.warn( "Could not open package.json for \"#{fullPluginName}\": #{err.message} " + "Could not validate config." ) continue if packageInfo?.configSchema? pathToSchema = path.resolve( @pluginManager.pathToPlugin(fullPluginName), packageInfo.configSchema ) pluginConfigSchema = require(pathToSchema) @_normalizeScheme(pluginConfigSchema) @_validateConfig(pConf, pluginConfigSchema, "config of #{fullPluginName}") else env.logger.warn( "package.json of \"#{fullPluginName}\" has no \"configSchema\" property. " + "Could not validate config." ) for deviceConfig in config.devices classInfo = @deviceManager.deviceClasses[deviceConfig.class] unless classInfo? env.logger.debug("Unknown device class \"#{deviceConfig.class}\"") continue warnings = [] classInfo.prepareConfig(deviceConfig) if classInfo.prepareConfig? @_normalizeScheme(classInfo.configDef) @_validateConfig( deviceConfig, classInfo.configDef, "config of device #{deviceConfig.id}" ) @config = config @saveConfig() @restart() return destroy: -> if @_destroying? then return @_destroying if @app.httpServer? httpServerConfig = @config.settings.httpServer if !!httpServerConfig.socket httpServer = @app.httpServer fs.stat httpServerConfig.socket, (err, stat) -> if not err env.logger.info 'Removing socket...' fs.unlink httpServerConfig.socket, (err) -> if err env.logger.info 'Cannot remove socket file.' return @_destroying = Promise.resolve().then( => context = waitFor: [] waitForIt: (promise) -> @waitFor.push promise @emit "destroy", context @saveConfig() return Promise.all(context.waitFor) ) saveConfig: -> assert @config? try fs.writeFileSync @configFile, JSON.stringify(@config, null, 2) catch err env.logger.error "Could not write config file: ", err.message env.logger.debug err env.logger.info "config.json updated" return { Framework }