From 899d05bc3295bd3e8191952e3e28343bc168a863 Mon Sep 17 00:00:00 2001 From: Michael Teeuw Date: Wed, 30 Mar 2016 12:20:46 +0200 Subject: [PATCH] Add server (web/socket), create socket system, better helper loader. - The Magic Mirror is now hosted via a express server, allowing you to load it from an external client (for debugging.) - It now includes a socket system to communicate between the node_helper and the client module. - node_helpers are now only loaded if the module is configured in the config. --- README.md | 6 +- config/config.js.sample | 7 +- index.html | 2 + js/defaults.js | 7 +- js/electron.js | 153 ++++++++++++++++---------- js/module.js | 37 ++++++- js/server.js | 83 ++++++++++++++ js/socket.js | 40 +++++++ js/socketclient.js | 83 ++++++++++++++ modules/newsfeed/newsfeed.js | 73 ++++--------- modules/newsfeed/node_helper.js | 187 +++++++++++++++++++++++++------- package.json | 5 +- 12 files changed, 531 insertions(+), 152 deletions(-) create mode 100644 js/server.js create mode 100644 js/socket.js create mode 100644 js/socketclient.js diff --git a/README.md b/README.md index 7d70cb88..5d5ffcdf 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ Things that still have to be implemented or changed. ####Loader - Loading of module uses `eval()`. We might want to look into a better solution. [loader.js#L112](https://github.com/MichMich/MagicMirror/blob/v2-beta/js/loader.js#L112). -####Helper scripts -- Only start helper scripts of modules that are actually loaded in the UI (config.js) -- Notification system, so that not every helper scripts needs it's own socket to the UI. -- `modules/newsfeed/node_helper.js` now spawns it's own epxress webserver on port 8080. We need to create a solution for every module that needs a server side url. + + diff --git a/config/config.js.sample b/config/config.js.sample index 9e301459..03059a38 100644 --- a/config/config.js.sample +++ b/config/config.js.sample @@ -7,6 +7,7 @@ */ var config = { + port: 80, language: 'en', timeFormat: 24, @@ -47,4 +48,8 @@ var config = { }, ] -}; \ No newline at end of file +}; + + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== 'undefined') {module.exports = config;} \ No newline at end of file diff --git a/index.html b/index.html index 23cf889e..70b73040 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@
+ @@ -34,6 +35,7 @@ + diff --git a/js/defaults.js b/js/defaults.js index 97ac99f0..33707784 100644 --- a/js/defaults.js +++ b/js/defaults.js @@ -8,6 +8,7 @@ */ var defaults = { + port: 80, language: 'en', timeFormat: 24, @@ -50,4 +51,8 @@ var defaults = { modules: 'modules', vendor: 'vendor' }, -}; \ No newline at end of file +}; + + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== 'undefined') {module.exports = defaults;} \ No newline at end of file diff --git a/js/electron.js b/js/electron.js index cd2a4179..1ce98a0e 100755 --- a/js/electron.js +++ b/js/electron.js @@ -1,10 +1,15 @@ 'use strict'; -//for searching modules +//load modules const walk = require('walk'); +const fs = require('fs'); +const Server = require(__dirname + '/server.js'); const spawn = require('child_process').spawn; - const electron = require('electron'); + + +// Config +var config = {}; // Module to control application life. const app = electron.app; // Module to create native browser window. @@ -15,79 +20,115 @@ const BrowserWindow = electron.BrowserWindow; let mainWindow; function createWindow () { - // Create the browser window. - mainWindow = new BrowserWindow({width: 800, height: 600, fullscreen: true, "auto-hide-menu-bar": true, "node-integration": false}); + // Create the browser window. + mainWindow = new BrowserWindow({width: 800, height: 600, fullscreen: true, "auto-hide-menu-bar": true, "node-integration": false}); - // and load the index.html of the app. - mainWindow.loadURL('file://' + __dirname + '../../index.html'); + // and load the index.html of the app. + //mainWindow.loadURL('file://' + __dirname + '../../index.html'); + mainWindow.loadURL('http://localhost:' + config.port); - // Open the DevTools. - //mainWindow.webContents.openDevTools(); + // Open the DevTools. + //mainWindow.webContents.openDevTools(); - // Emitted when the window is closed. - mainWindow.on('closed', function() { - // Dereference the window object, usually you would store windows - // in an array if your app supports multi windows, this is the time - // when you should delete the corresponding element. - mainWindow = null; - }); + // Emitted when the window is closed. + mainWindow.on('closed', function() { + // Dereference the window object, usually you would store windows + // in an array if your app supports multi windows, this is the time + // when you should delete the corresponding element. + mainWindow = null; + }); } -//Walk module folder and get file names -var module_loader = walk.walk(__dirname + '/../modules', { followLinks: false }); +function loadConfig (callback) { + console.log("Loading config ..."); + var defaults = require(__dirname + '/defaults.js'); + var configFilename = __dirname + '/../config/config.js'; -//for each file in modules -module_loader.on('file', function(root, stat, next) { - //if file is called node_helper.js load it - if (stat.name == "node_helper.js"){ - var module = (root + '/' + stat.name).split("/"); - var moduleName = module[module.length-2]; + try { + fs.accessSync(configFilename, fs.R_OK); + var c = require(configFilename); + var config = Object.assign(defaults, c); + callback(config); + } catch (e) { + callback(defaults); + } +} - //start module as child - var child = spawn('node', [root + '/' + stat.name]) +function loadModule(moduleName) { + var helperPath = __dirname + '/../modules/' + moduleName + '/node_helper.js'; - // Make sure the output is logged. - child.stdout.on('data', function(data) { - process.stdout.write(moduleName + ': ' + data); - }); + try { + fs.accessSync(helperPath, fs.R_OK); - child.stderr.on('data', function(data) { - process.stdout.write(moduleName + ': ' + data); - }); - - child.on('close', function(code) { - console.log(moduleName + ' closing code: ' + code); - }); + var child = spawn('node', [helperPath]); + // Make sure the output is logged. + child.stdout.on('data', function(data) { + process.stdout.write('[' + moduleName + '] ' + data); + }); - //Log module name - - console.log("Started helper script for module " + moduleName + "."); - } - next(); + child.stderr.on('data', function(data) { + process.stdout.write('[' + moduleName + '] ' + data); + }); + + child.on('close', function(code) { + console.log(moduleName + ' closing code: ' + code); + }); + + //Log module name + console.log("Started helper script for module: " + moduleName + "."); + + } catch (e) { + console.log("No helper found for module: " + moduleName + "."); + } +} + +function loadModules(modules) { + console.log("Loading module helpers ..."); + + for (var m in modules) { + loadModule(modules[m]); + } + + console.log("All module helpers loaded."); +} + +loadConfig(function(c) { + config = c; + + var modules = []; + + for (var m in config.modules) { + var module = config.modules[m]; + if (modules.indexOf(module.module) === -1) { + modules.push(module.module); + } + } + + loadModules(modules); }); -module_loader.on('end', function() { - console.log("All helpers started."); -}); - // This method will be called when Electron has finished // initialization and is ready to create browser windows. -app.on('ready', createWindow); +app.on('ready', function() { + var server = new Server(config, function() { + createWindow(); + }); +}); // Quit when all windows are closed. app.on('window-all-closed', function () { - // On OS X it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin') { - app.quit(); - } + // On OS X it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit(); + } }); app.on('activate', function () { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (mainWindow === null) { - createWindow(); - } + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (mainWindow === null) { + createWindow(); + } }); diff --git a/js/module.js b/js/module.js index e18c6e61..34ae02e6 100644 --- a/js/module.js +++ b/js/module.js @@ -88,7 +88,15 @@ var Module = Class.extend({ } }, - + /* socketNotificationReceived(notification, payload) + * This method is called when a socket notification arrives. + * + * argument notification string - The identifier of the noitication. + * argument payload mixed - The payload of the notification. + */ + socketNotificationReceived: function(notification, payload) { + Log.log(this.name + ' received a socket notification: ' + notification + ' - Payload: ' + payload); + }, @@ -118,6 +126,23 @@ var Module = Class.extend({ this.config = Object.assign(this.defaults, config); }, + /* socket() + * Returns a socket object. If it doesn't exsist, it's created. + * It also registers the notification callback. + */ + socket: function() { + if (typeof this._socket === 'undefined') { + this._socket = this._socket = new MMSocket(this.name); + } + + var self = this; + this._socket.setNotificationCallback(function(notification, payload) { + self.socketNotificationReceived(notification, payload); + }); + + return this._socket; + }, + /* file(file) * Retrieve the path to a module fike. * @@ -170,6 +195,16 @@ var Module = Class.extend({ */ sendNotification: function(notification, payload) { MM.sendNotification(notification, payload, this); + }, + + /* sendSocketNotification(notification, payload) + * Send a socket notification to the node helper. + * + * argument notification string - The identifier of the noitication. + * argument payload mixed - The payload of the notification. + */ + sendSocketNotification: function(notification, payload) { + this.socket().sendNotification(notification, payload); } }); diff --git a/js/server.js b/js/server.js new file mode 100644 index 00000000..32670a46 --- /dev/null +++ b/js/server.js @@ -0,0 +1,83 @@ +/* Magic Mirror + * Server + * + * By Michael Teeuw http://michaelteeuw.nl + * MIT Licensed. + */ + +var express = require('express'); +var app = require('express')(); +var server = require('http').Server(app); +var io = require('socket.io')(server); +var path = require('path'); + +var Server = function(config, callback) { + + /* createNamespace(namespace) + * Creates a namespace with a wildcard event. + * + * argument namespace string - The name of the namespace. + */ + var createNamespace = function(namespace) { + console.log('Creating socket namespace: ' + namespace); + + io.of(namespace).on('connection', function (socket) { + console.log("New socket connection on namespace: " + namespace); + + // add a catch all event. + var onevent = socket.onevent; + socket.onevent = function (packet) { + var args = packet.data || []; + onevent.call (this, packet); // original call + packet.data = ["*"].concat(args); + onevent.call(this, packet); // additional call to catch-all + }; + + // register catch all. + socket.on('*', function (event, data) { + io.of(namespace).emit(event, data); + }); + }); + }; + + /* createNamespaces() + * Creates a namespace for all modules in the config. + */ + var createNamespaces = function() { + var modules = []; + var m; + + for (m in config.modules) { + var module = config.modules[m]; + if (modules.indexOf(module.module) === -1) { + modules.push(module.module); + } + } + + for (m in modules) { + createNamespace(modules[m]); + } + }; + + console.log("Starting server op port " + config.port + " ... "); + + server.listen(config.port); + app.use('/js', express.static(__dirname)); + app.use('/config', express.static(path.resolve(__dirname + '/../config'))); + app.use('/css', express.static(path.resolve(__dirname + '/../css'))); + app.use('/fonts', express.static(path.resolve(__dirname + '/../fonts'))); + app.use('/modules', express.static(path.resolve(__dirname + '/../modules'))); + app.use('/vendor', express.static(path.resolve(__dirname + '/../vendor'))); + + app.get('/', function (req, res) { + res.sendFile(path.resolve(__dirname + '/../index.html')); + }); + + createNamespaces(); + + if (typeof callback === 'function') { + callback(); + } +}; + +module.exports = Server; \ No newline at end of file diff --git a/js/socket.js b/js/socket.js new file mode 100644 index 00000000..4badc5f8 --- /dev/null +++ b/js/socket.js @@ -0,0 +1,40 @@ +/* exported Log */ + +/* Magic Mirror + * Socket Connection + * + * By Michael Teeuw http://michaelteeuw.nl + * MIT Licensed. + */ + + + +var MMSocket = function(moduleName) { + + var self = this; + + if (typeof moduleName !== 'string') { + throw new Error('Please set the module name for the MMSocket.'); + } + + self.moduleName = moduleName; + + + self.socket = io('http://localhost:8080'); + self.socket.on('notification', function (data) { + MM.sendNotification(data.notification, data.payload, Socket); + }); + + return { + sendMessage: function(notification, payload, sender) { + Log.log('Send socket message: ' + notification); + self.socket.emit('notification', { + notification: notification, + sender: sender, + payload: payload + }); + } + }; +}; + + \ No newline at end of file diff --git a/js/socketclient.js b/js/socketclient.js new file mode 100644 index 00000000..a3cb57bd --- /dev/null +++ b/js/socketclient.js @@ -0,0 +1,83 @@ +if (typeof window === 'undefined') { + // Only perfom this part if is isn't running in the browser. + + // Load socket client + var io = require('socket.io-client'); + + // Load config + var fs = require('fs'); + + var config = {}; + + var defaults = require(__dirname + '/defaults.js'); + var configFilename = __dirname + '/../config/config.js'; + + try { + fs.accessSync(configFilename, fs.R_OK); + var c = require(configFilename); + config = Object.assign(defaults, c); + } catch (e) { + config = defaults; + } +} + + +var MMSocket = function(moduleName) { + + var self = this; + + if (typeof moduleName !== 'string') { + throw new Error('Please set the module name for the MMSocket.'); + } + + self.moduleName = moduleName; + + // Private Methods + var socketBase = (typeof window === 'undefined') ? 'http://localhost:'+config.port : ''; + socket = io(socketBase + '/' + self.moduleName); + + var notificationCallback = function() {}; + + socket.on('connect', function(s) { + + // add a catch all event. + var onevent = socket.onevent; + socket.onevent = function (packet) { + var args = packet.data || []; + onevent.call (this, packet); // original call + packet.data = ["*"].concat(args); + onevent.call(this, packet); // additional call to catch-all + }; + + // register catch all. + socket.on('*', function (notification, payload) { + if (notification !== '*') { + //console.log('Received notification: ' + notification +', payload: ' + payload); + notificationCallback(notification, payload); + } + }); + + + }); + + var sendNotification = function(notification, payload) { + //console.log('Send notification: ' + notification +', payload: ' + payload); + socket.emit(notification, payload); + }; + + // Public Methods + this.setNotificationCallback = function(callback) { + notificationCallback = callback; + }; + + this.sendNotification = function(notification, payload) { + if (typeof payload === 'undefined') { + payload = {}; + } + sendNotification(notification, payload); + }; +}; + +if (typeof module !== 'undefined') { + module.exports = MMSocket; +} \ No newline at end of file diff --git a/modules/newsfeed/newsfeed.js b/modules/newsfeed/newsfeed.js index f4bbccd7..2c84eda8 100644 --- a/modules/newsfeed/newsfeed.js +++ b/modules/newsfeed/newsfeed.js @@ -16,11 +16,6 @@ Module.create({ reloadInterval: 10 * 60 * 1000, // every 10 minutes updateInterval: 7.5 * 1000, animationSpeed: 2.5 * 1000, - - - proxyUrl: 'http://localhost:8080/?url=', - initialLoadDelay: 0, // 5 seconds delay. This delay is used to keep the OpenWeather API happy. - retryDelay: 2500, }, // Define required scripts. @@ -37,14 +32,28 @@ Module.create({ this.newsItems = []; this.loaded = false; - this.scheduleFetch(this.config.initialLoadDelay); - - this.fetchTimer = null; this.activeItem = 0; + + this.fetchNews(); + }, + + // Override socket notification handler. + socketNotificationReceived: function(notification, payload) { + if (notification === 'NEWS_ITEMS') { + if (payload.url === this.config.feedUrl) { + this.newsItems = payload.items; + if (!this.loaded) { + this.scheduleUpdateInterval(); + } + + this.loaded = true; + } + } }, // Override dom generator. getDom: function() { + var wrapper = document.createElement("div"); if (this.activeItem >= this.newsItems.length) { @@ -57,6 +66,7 @@ Module.create({ var timestamp = document.createElement("div"); timestamp.className = "light small dimmed"; timestamp.innerHTML = this.capitalizeFirstLetter(moment(new Date(this.newsItems[this.activeItem].pubdate)).fromNow() + ':'); + //timestamp.innerHTML = this.config.feedUrl; wrapper.appendChild(timestamp); } @@ -77,30 +87,11 @@ Module.create({ * Requests new data from news proxy. */ fetchNews: function() { - var url = this.config.proxyUrl + encodeURIComponent(this.config.feedUrl); - var self = this; - - var newsRequest = new XMLHttpRequest(); - newsRequest.open("GET", url, true); - newsRequest.onreadystatechange = function() { - if(this.readyState === 4) { - if(this.status === 200) { - self.newsItems = JSON.parse(this.response); - - if (!self.loaded) { - self.scheduleUpdateInterval(); - } - - self.loaded = true; - } else { - Log.error(self.name + ": Could not load news."); - } - - self.scheduleFetch((self.loaded) ? -1 : self.config.retryDelay); - - } - }; - newsRequest.send(); + Log.log('Add news feed to fetcher: ' + this.config.feedUrl); + this.sendSocketNotification('ADD_FEED', { + url: this.config.feedUrl, + reloadInterval: this.config.reloadInterval + }); }, /* scheduleUpdateInterval() @@ -117,24 +108,6 @@ Module.create({ }, this.config.updateInterval); }, - /* scheduleFetch() - * Schedule next news fetch. - * - * argument delay number - Milliseconds before next update. If empty, this.config.reloadInterval is used. - */ - scheduleFetch: function(delay) { - var nextLoad = this.config.reloadInterval; - if (typeof delay !== 'undefined' && delay >= 0) { - nextLoad = delay; - } - - var self = this; - clearTimeout(this.fetchTimer); - this.fetchTimer = setTimeout(function() { - self.fetchNews(); - }, nextLoad); - }, - /* capitalizeFirstLetter(string) * Capitalizes the first character of a string. * diff --git a/modules/newsfeed/node_helper.js b/modules/newsfeed/node_helper.js index cfe00e41..f1423736 100644 --- a/modules/newsfeed/node_helper.js +++ b/modules/newsfeed/node_helper.js @@ -1,17 +1,142 @@ -// Configuration. -var config = { - port: 8080 -}; // Load modules. -var express = require('express'); var request = require('request'); var FeedMe = require('feedme'); var validUrl = require('valid-url'); -var app = express(); +var MMSocket = require('../../js/socketclient.js'); +var socket = new MMSocket('newsfeed'); -// Create NewsFetcher. -var NewsFetcher = (function() { +var fetchers = {}; + +// Register the notification callback. +socket.setNotificationCallback(function(notification, payload) { + if(notification === 'ADD_FEED') { + createFetcher(payload.url, payload.reloadInterval); + } +}); + +/* createFetcher(url, reloadInterval) + * Creates a fetcher for a new url if it doesn't exsist yet. + * Otherwise it reoses the exsisting one. + * + * attribute url string - URL of the news feed. + * attribute reloadInterval number - Reload interval in milliseconds. + */ + +var createFetcher = function(url, reloadInterval) { + if (!validUrl.isUri(url)){ + socket.sendNotification('INCORRECT_URL', url); + return; + } + + var fetcher; + if (typeof fetchers[url] === 'undefined') { + console.log('Create new news fetcher for url: ' + url + ' - Interval: ' + reloadInterval); + fetcher = new Fetcher(url, reloadInterval); + fetchers[url] = fetcher; + } else { + console.log('Use exsisting news fetcher for url: ' + url); + fetcher = fetchers[url]; + fetcher.setReloadInterval(reloadInterval); + fetcher.broadcastItems(); + } + + fetcher.startFetch(); +}; + + +/* Fetcher + * Responsible for requesting an update on the set interval and broadcasting the data. + * + * attribute url string - URL of the news feed. + * attribute reloadInterval number - Reload interval in milliseconds. + */ + +var Fetcher = function(url, reloadInterval) { + var self = this; + var newsFetcher = new NewsFetcher(); + if (reloadInterval < 1000) { + reloadInterval = 1000; + } + + var reloadTimer = null; + var items = []; + + /* private methods */ + + /* fetchNews() + * Request the new items from the newsFetcher. + */ + + var fetchNews = function() { + //console.log('Fetch news.'); + clearTimeout(reloadTimer); + reloadTimer = null; + newsFetcher.fetchNews(url, function(fetchedItems) { + //console.log(fetchedItems.length + ' items received.'); + items = fetchedItems; + self.broadcastItems(); + scheduleTimer(); + }, function(error) { + //console.log('Unable to load news: ' + error); + socket.sendNotification('UNABLE_TO_LOAD_NEWS', {url:url, error:error}); + scheduleTimer(); + }); + }; + + /* scheduleTimer() + * Schedule the timer for the next update. + */ + + var scheduleTimer = function() { + //console.log('Schedule update timer.'); + clearTimeout(reloadTimer); + reloadTimer = setTimeout(function() { + fetchNews(); + }, reloadInterval); + }; + + /* public methods */ + + /* setReloadInterval() + * Update the reload interval, but only if we need to increase the speed. + * + * attribute interval number - Interval for the update in milliseconds. + */ + this.setReloadInterval = function(interval) { + if (interval > 1000 && interval < reloadInterval) { + reloadInterval = interval; + } + }; + + /* startFetch() + * Initiate fetchNews(); + */ + this.startFetch = function() { + fetchNews(); + }; + + /* broadcastItems() + * Broadcast the exsisting items. + */ + this.broadcastItems = function() { + if (items.length <= 0) { + //console.log('No items to broadcast yet.'); + return; + } + //console.log('Broadcasting ' + items.length + ' items.'); + socket.sendNotification('NEWS_ITEMS', { + url: url, + items: items + }); + }; +}; + +/* NewsFetcher + * Responsible for requesting retrieving the data. + */ + +var NewsFetcher = function() { var self = this; self.successCallback = function(){}; @@ -33,41 +158,27 @@ var NewsFetcher = (function() { self.successCallback(self.items); }); - parser.on('error', function(item) { - self.errorCallback(); + parser.on('error', function(error) { + self.errorCallback(error); }); - return { - fetchNews: function(url, success, error) { - self.successCallback = success; - self.errorCallback = error; - request(url).pipe(parser); - } + /* public methods */ + + /* fetchNews() + * Fetch the new news items. + * + * attribute url string - The url to fetch. + * attribute success function(items) - Callback on succes. + * attribute error function(error) - Callback on error. + */ + self.fetchNews = function(url, success, error) { + self.successCallback = success; + self.errorCallback = error; + request(url).pipe(parser); }; -})(); +}; -// Create route for fetcher. -app.get('/', function (req, res) { - if (!validUrl.isUri(req.query.url)){ - res.status(404).send('No valid feed URL.'); - return; - } - - NewsFetcher.fetchNews(req.query.url, function(items) { - res.send(items); - }, function() { - res.status(400).send('Could not parse feed.'); - }); -}); - -// Listen on port. -app.listen(config.port, function () { - console.log('Feed proxy is running on port: ' + config.port); -}); - -console.log('Starting feed proxy on port: ' + config.port); - diff --git a/package.json b/package.json index 2ed940dd..61a1c64c 100755 --- a/package.json +++ b/package.json @@ -27,10 +27,13 @@ "electron-prebuilt": "latest" }, "dependencies": { + "socket.io":"latest", "express":"latest", "request":"latest", "walk": "latest", "feedme": "latest", - "valid-url": "latest" + "valid-url": "latest", + "ical": "latest", + "moment": "latest" } }