Developing a new module
A module adds a feature to Emoncms in its own folder under Modules/. Only the controller is required. The core schedule module is a small, complete example.
Files
For a module called mymodule:
Modules/mymodule/
module.json name and version
mymodule_controller.php handles requests (required)
mymodule_model.php data and logic, as a class
mymodule_menu.php menu entries
mymodule_schema.php database tables
Views/ HTML views, with their JS and CSS
locale/ translations, <lang>.json
module.json:
{
"name": "My module",
"version": "1.0.0"
}
A module with background scripts is usually installed in /opt/emoncms/modules/mymodule, with the web part symlinked into Modules/.
Requests
All requests go through index.php. The URL is:
http://server/controller/action/subaction.format?name=value
controller: the module, for examplemymoduleloadsModules/mymodule/mymodule_controller.php.action,subaction: what to do. Both optional.subaction2is also available.format:html(default),json,text,mdorcsv.
For example, input/post.json?node=5&fulljson={"power":200} runs the post action of the input module and returns JSON.
See Global variables for $route and $session.
Controller
The controller is a function named after the module. It returns the page content or data. index.php sends a json result as JSON, and wraps an html result in the theme.
<?php
defined('EMONCMS_EXEC') or die('Restricted access');
function mymodule_controller()
{
global $mysqli, $redis, $session, $route;
require_once "Modules/mymodule/mymodule_model.php";
$mymodule = new MyModule($mysqli, $redis);
if ($route->format == 'html') {
if ($route->action == 'view' && $session['write']) {
return view("Modules/mymodule/Views/mymodule_view.php", array("title" => "My module"));
}
}
if ($route->format == 'json') {
if ($route->action == 'list' && $session['read']) {
return $mymodule->get_list($session['userid']);
}
}
return false;
}
A controller can return a value or array('content' => $value). Other values are wrapped as content. A request that matches no controller gets a “not found” response.
Check $session['read'], $session['write'] or $session['admin'] before each action. The session may come from a web login or from an API key.
Actions that change data and are called from the web interface must be POST only. A GET can be fired by any tag that loads a URL, including stored content such as a dashboard, and the browser sends the session cookie with it. Check the method in the controller:
if ($route->action == 'delete' && $session['write'] && $route->method == 'POST') {
return $mymodule->delete($session['userid'], (int) post('id'));
}
Actions called by devices and scripts with an API key in the request may stay GET.
Direct access
Controllers, models and views start with:
defined('EMONCMS_EXEC') or die('Restricted access');
index.php defines EMONCMS_EXEC, so the file does nothing when requested directly, for example http://server/Modules/mymodule/mymodule_controller.php. Menu and schema files are only included by core.
Request parameters
Read parameters with the helpers in core.php:
get($name, $error_if_missing = false, $default = null)post($name, $error_if_missing = false, $default = null)prop($name, ...): from GET or POSTput($name),delete($name): from the request body
Validate every parameter before use. Cast ids with (int). Filter names to the characters you allow, for example:
$name = preg_replace('/[^\p{N}\p{L}_\s\-:]/u', '', get('name'));
Use prepared statements for all SQL:
$stmt = $this->mysqli->prepare("SELECT id, name FROM mymodule WHERE userid = ?");
$stmt->bind_param("i", $userid);
$stmt->execute();
Model
Put reusable logic in a model class. Pass the connections it needs to the constructor:
class MyModule
{
private $mysqli;
private $redis;
public function __construct($mysqli, $redis)
{
$this->mysqli = $mysqli;
$this->redis = $redis;
}
}
$redis is false when Redis is disabled in settings. Code that uses Redis must also work without it. See Modules/feed/feed_model.php for an example that uses both.
To install Redis: sudo apt install redis-server php-redis.
Views
view($filepath, $args) renders a PHP file and returns the output. Each key in $args becomes a variable in the view. $path, the base URL, is always set.
Load scripts and styles with load_js() and load_css(). They add a version to the URL from the file time, so browsers load the new file after an update.
A view is a PHP file for the markup and a JS file for the behaviour. PHP renders the static text and one block of initial data. Vue renders everything that changes.
<?php
defined('EMONCMS_EXEC') or die('Restricted access');
load_js("Lib/js/vue.global.prod-3.5.22.min.js");
load_css("Modules/mymodule/Views/mymodule.css");
?>
<div id="mymodule-app" class="panel-page" v-cloak>
<h3><?php echo htmlspecialchars(tr("My module")); ?></h3>
<div v-for="item in items">
{{ item.name }}
<button class="btn btn-default btn-sm" @click="remove(item)"><?php echo htmlspecialchars(tr("Delete")); ?></button>
</div>
</div>
<script>
var mymodule_init = {
items: <?php echo json_encode($items); ?>,
strings: <?php echo json_encode(array("Request failed" => tr("Request failed"))); ?>
};
</script>
<?php load_js("Modules/mymodule/Views/mymodule.js"); ?>
mymodule.js:
Vue.createApp({
data: function() {
return { items: mymodule_init.items };
},
methods: {
remove: function(item) {
fetch(path + "mymodule/delete.json", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "id=" + encodeURIComponent(item.id)
}).then((response) => response.json()).then((result) => {
if (!result.success) return alert(result.message);
this.items = this.items.filter((i) => i.id !== item.id);
}).catch(() => alert(mymodule_init.strings["Request failed"]));
}
}
}).mount("#mymodule-app");
Rules for views:
One owner per page. Mount Vue on the page root, with the page’s modals inside it. Do not change the DOM inside the Vue root with jQuery. Pages without Vue may use jQuery.
PHP output. Escape text with
htmlspecialchars(). Pass data, and the translated strings that JS needs, withjson_encode(). Do not addJSON_UNESCAPED_SLASHESin a script block, as escaped slashes stop a</script>in the data from ending the block. Do not build JS code or Vue attributes in PHP strings.JS in
.jsfiles. Only the data block is inline. Inline scripts are not linted.State in Vue data. Do not keep state in DOM elements or in globals.
Requests. Use
fetch. Do not use synchronous requests. Checksuccessin the response and showmessageon failure.Writes. Send writes as POST, see Controller.
Text from the server or the user. Show it with
{{ }}. Do not insert it withv-htmlor jQuery.html().Components. Use Bootstrap 5 components and the theme classes. Open modals with
bootstrap.Modal. See the CSS guide.Check. Load the page in a browser and check the console for errors.
Modules/user/profile/ is an example of a page that follows these rules.
Database
mymodule_schema.php defines the tables:
<?php
$schema['mymodule'] = array(
'id' => array('type' => 'int', 'Null' => 'NO', 'Key' => 'PRI', 'Extra' => 'auto_increment'),
'userid' => array('type' => 'int'),
'name' => array('type' => 'varchar(64)')
);
After installing a module or changing its schema, run Setup > Admin > Update > Update Database and apply the changes, or run ./scripts/emoncms-cli admin:dbupdate. See Modules/schedule/schedule_schema.php and Modules/user/user_schema.php.
Translations
Wrap text in tr():
echo tr("My module");
Add translations to Modules/mymodule/locale/<lang>.json, for example de_DE.json:
{
"My module": "Mein Modul"
}
The controller loads the module’s locale files. For strings shared with a JavaScript file, load them with a context and use ctx_tr():
load_language_files("Modules/mymodule/locale", "mymodule_messages");
echo ctx_tr("mymodule_messages", "My module");
See Translation.
Logging
$log = new EmonLogger(__FILE__);
$log->info("Started");
$log->warn("Something went wrong");
Logs go to emoncms.log in $settings['log']['location'], by default /var/log/emoncms.
Settings
Module settings go in settings.ini, under a section for the module. process_settings.php merges settings.ini over default-settings.php. Read them from the global $settings.
Examples
Modules/schedule: small core module with a model, Vue view, menu, schema and API.Modules/feed/feed_model.phpandModules/input/input_model.php: larger models using MySQL and Redis.