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.
This commit is contained in:
Michael Teeuw 2016-03-30 12:20:46 +02:00
parent 15856574d7
commit 899d05bc32
12 changed files with 531 additions and 152 deletions

View File

@ -16,9 +16,7 @@ Things that still have to be implemented or changed.
####Loader ####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). - 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.

View File

@ -7,6 +7,7 @@
*/ */
var config = { var config = {
port: 80,
language: 'en', language: 'en',
timeFormat: 24, timeFormat: 24,
@ -47,4 +48,8 @@ var config = {
}, },
] ]
}; };
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== 'undefined') {module.exports = config;}

View File

@ -27,6 +27,7 @@
<div class="region bottom right"><div class="container"></div></div> <div class="region bottom right"><div class="container"></div></div>
</div> </div>
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src="js/defaults.js"></script> <script type="text/javascript" src="js/defaults.js"></script>
<script type="text/javascript" src="config/config.js"></script> <script type="text/javascript" src="config/config.js"></script>
<script type="text/javascript" src="vendor/vendor.js"></script> <script type="text/javascript" src="vendor/vendor.js"></script>
@ -34,6 +35,7 @@
<script type="text/javascript" src="js/class.js"></script> <script type="text/javascript" src="js/class.js"></script>
<script type="text/javascript" src="js/module.js"></script> <script type="text/javascript" src="js/module.js"></script>
<script type="text/javascript" src="js/loader.js"></script> <script type="text/javascript" src="js/loader.js"></script>
<script type="text/javascript" src="js/socketclient.js"></script>
<script type="text/javascript" src="js/main.js"></script> <script type="text/javascript" src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -8,6 +8,7 @@
*/ */
var defaults = { var defaults = {
port: 80,
language: 'en', language: 'en',
timeFormat: 24, timeFormat: 24,
@ -50,4 +51,8 @@ var defaults = {
modules: 'modules', modules: 'modules',
vendor: 'vendor' vendor: 'vendor'
}, },
}; };
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== 'undefined') {module.exports = defaults;}

View File

@ -1,10 +1,15 @@
'use strict'; 'use strict';
//for searching modules //load modules
const walk = require('walk'); const walk = require('walk');
const fs = require('fs');
const Server = require(__dirname + '/server.js');
const spawn = require('child_process').spawn; const spawn = require('child_process').spawn;
const electron = require('electron'); const electron = require('electron');
// Config
var config = {};
// Module to control application life. // Module to control application life.
const app = electron.app; const app = electron.app;
// Module to create native browser window. // Module to create native browser window.
@ -15,79 +20,115 @@ const BrowserWindow = electron.BrowserWindow;
let mainWindow; let mainWindow;
function createWindow () { function createWindow () {
// Create the browser window. // Create the browser window.
mainWindow = new BrowserWindow({width: 800, height: 600, fullscreen: true, "auto-hide-menu-bar": true, "node-integration": false}); 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. // and load the index.html of the app.
mainWindow.loadURL('file://' + __dirname + '../../index.html'); //mainWindow.loadURL('file://' + __dirname + '../../index.html');
mainWindow.loadURL('http://localhost:' + config.port);
// Open the DevTools. // Open the DevTools.
//mainWindow.webContents.openDevTools(); //mainWindow.webContents.openDevTools();
// Emitted when the window is closed. // Emitted when the window is closed.
mainWindow.on('closed', function() { mainWindow.on('closed', function() {
// Dereference the window object, usually you would store windows // Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time // in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element. // when you should delete the corresponding element.
mainWindow = null; mainWindow = null;
}); });
} }
//Walk module folder and get file names function loadConfig (callback) {
var module_loader = walk.walk(__dirname + '/../modules', { followLinks: false }); console.log("Loading config ...");
var defaults = require(__dirname + '/defaults.js');
var configFilename = __dirname + '/../config/config.js';
//for each file in modules try {
module_loader.on('file', function(root, stat, next) { fs.accessSync(configFilename, fs.R_OK);
//if file is called node_helper.js load it var c = require(configFilename);
if (stat.name == "node_helper.js"){ var config = Object.assign(defaults, c);
var module = (root + '/' + stat.name).split("/"); callback(config);
var moduleName = module[module.length-2]; } catch (e) {
callback(defaults);
}
}
//start module as child function loadModule(moduleName) {
var child = spawn('node', [root + '/' + stat.name]) var helperPath = __dirname + '/../modules/' + moduleName + '/node_helper.js';
// Make sure the output is logged. try {
child.stdout.on('data', function(data) { fs.accessSync(helperPath, fs.R_OK);
process.stdout.write(moduleName + ': ' + data);
});
child.stderr.on('data', function(data) { var child = spawn('node', [helperPath]);
process.stdout.write(moduleName + ': ' + data);
});
child.on('close', function(code) {
console.log(moduleName + ' closing code: ' + code);
});
// Make sure the output is logged.
child.stdout.on('data', function(data) {
process.stdout.write('[' + moduleName + '] ' + data);
});
//Log module name child.stderr.on('data', function(data) {
process.stdout.write('[' + moduleName + '] ' + data);
console.log("Started helper script for module " + moduleName + "."); });
}
next(); 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 // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // 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. // Quit when all windows are closed.
app.on('window-all-closed', function () { app.on('window-all-closed', function () {
// On OS X it is common for applications and their menu bar // On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q // to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
app.on('activate', function () { app.on('activate', function () {
// On OS X it's common to re-create a window in the app when the // 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. // dock icon is clicked and there are no other windows open.
if (mainWindow === null) { if (mainWindow === null) {
createWindow(); createWindow();
} }
}); });

View File

@ -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); 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) /* file(file)
* Retrieve the path to a module fike. * Retrieve the path to a module fike.
* *
@ -170,6 +195,16 @@ var Module = Class.extend({
*/ */
sendNotification: function(notification, payload) { sendNotification: function(notification, payload) {
MM.sendNotification(notification, payload, this); 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);
} }
}); });

83
js/server.js Normal file
View File

@ -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;

40
js/socket.js Normal file
View File

@ -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
});
}
};
};

83
js/socketclient.js Normal file
View File

@ -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;
}

View File

@ -16,11 +16,6 @@ Module.create({
reloadInterval: 10 * 60 * 1000, // every 10 minutes reloadInterval: 10 * 60 * 1000, // every 10 minutes
updateInterval: 7.5 * 1000, updateInterval: 7.5 * 1000,
animationSpeed: 2.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. // Define required scripts.
@ -37,14 +32,28 @@ Module.create({
this.newsItems = []; this.newsItems = [];
this.loaded = false; this.loaded = false;
this.scheduleFetch(this.config.initialLoadDelay);
this.fetchTimer = null;
this.activeItem = 0; 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. // Override dom generator.
getDom: function() { getDom: function() {
var wrapper = document.createElement("div"); var wrapper = document.createElement("div");
if (this.activeItem >= this.newsItems.length) { if (this.activeItem >= this.newsItems.length) {
@ -57,6 +66,7 @@ Module.create({
var timestamp = document.createElement("div"); var timestamp = document.createElement("div");
timestamp.className = "light small dimmed"; timestamp.className = "light small dimmed";
timestamp.innerHTML = this.capitalizeFirstLetter(moment(new Date(this.newsItems[this.activeItem].pubdate)).fromNow() + ':'); timestamp.innerHTML = this.capitalizeFirstLetter(moment(new Date(this.newsItems[this.activeItem].pubdate)).fromNow() + ':');
//timestamp.innerHTML = this.config.feedUrl;
wrapper.appendChild(timestamp); wrapper.appendChild(timestamp);
} }
@ -77,30 +87,11 @@ Module.create({
* Requests new data from news proxy. * Requests new data from news proxy.
*/ */
fetchNews: function() { fetchNews: function() {
var url = this.config.proxyUrl + encodeURIComponent(this.config.feedUrl); Log.log('Add news feed to fetcher: ' + this.config.feedUrl);
var self = this; this.sendSocketNotification('ADD_FEED', {
url: this.config.feedUrl,
var newsRequest = new XMLHttpRequest(); reloadInterval: this.config.reloadInterval
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();
}, },
/* scheduleUpdateInterval() /* scheduleUpdateInterval()
@ -117,24 +108,6 @@ Module.create({
}, this.config.updateInterval); }, 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) /* capitalizeFirstLetter(string)
* Capitalizes the first character of a string. * Capitalizes the first character of a string.
* *

View File

@ -1,17 +1,142 @@
// Configuration.
var config = {
port: 8080
};
// Load modules. // Load modules.
var express = require('express');
var request = require('request'); var request = require('request');
var FeedMe = require('feedme'); var FeedMe = require('feedme');
var validUrl = require('valid-url'); var validUrl = require('valid-url');
var app = express(); var MMSocket = require('../../js/socketclient.js');
var socket = new MMSocket('newsfeed');
// Create NewsFetcher. var fetchers = {};
var NewsFetcher = (function() {
// 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; var self = this;
self.successCallback = function(){}; self.successCallback = function(){};
@ -33,41 +158,27 @@ var NewsFetcher = (function() {
self.successCallback(self.items); self.successCallback(self.items);
}); });
parser.on('error', function(item) { parser.on('error', function(error) {
self.errorCallback(); self.errorCallback(error);
}); });
return { /* public methods */
fetchNews: function(url, success, error) {
self.successCallback = success; /* fetchNews()
self.errorCallback = error; * Fetch the new news items.
request(url).pipe(parser); *
} * 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);

View File

@ -27,10 +27,13 @@
"electron-prebuilt": "latest" "electron-prebuilt": "latest"
}, },
"dependencies": { "dependencies": {
"socket.io":"latest",
"express":"latest", "express":"latest",
"request":"latest", "request":"latest",
"walk": "latest", "walk": "latest",
"feedme": "latest", "feedme": "latest",
"valid-url": "latest" "valid-url": "latest",
"ical": "latest",
"moment": "latest"
} }
} }