Skip to content

Commit 3b47f82

Browse files
committed
[udf] Unlock JavaScript for user-defined functions
1 parent 772f9f5 commit 3b47f82

File tree

11 files changed

+284
-35
lines changed

11 files changed

+284
-35
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mqttwarn changelog
55

66
in progress
77
===========
8+
- [udf] Unlock JavaScript for user-defined functions. Thanks, @extremeheat.
89

910

1011
2023-10-15 0.35.0

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ RUN --mount=type=cache,id=pip,target=/root/.cache/pip \
3434
true \
3535
&& pip install --upgrade pip \
3636
&& pip install --prefer-binary versioningit wheel \
37-
&& pip install --use-pep517 --prefer-binary '/src'
37+
&& pip install --use-pep517 --prefer-binary '/src[javascript]'
3838

3939
# Uninstall build prerequisites again.
4040
RUN apt-get --yes remove --purge git && apt-get --yes autoremove

docs/configure/transformation.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,68 @@ the invocation of your function, you will create an endless loop.
407407
:::
408408

409409

410+
(user-defined-functions-languages)=
411+
### Using other languages
412+
413+
This section is about using languages other than Python within the mqttwarn transformation
414+
subsystem.
415+
416+
#### Introduction
417+
Imagine you are running a large cloud infrastructure for [4IR] purposes, where hardware
418+
appliances or industrial machines submit telemetry data to concentrators or hubs, in order
419+
to converge them to a message bus like [Kafka], where, because you favoured a [FaaS]
420+
solution, your [serverless] function handler will consume the telemetry messages, in
421+
order to conduct further processing, like to filter, transform, and store the data.
422+
Chances are that you used the [Serverless Framework], built on [Node.js], so your application
423+
infrastructure is effectively based on a bunch of [JavaScript] code.
424+
425+
Now, you know how the story ends. The infrastructure costs exploded, you had to fire your
426+
engineering team, and now you are sitting in front of a mixture of infrastructure setup
427+
recipes and actual application code, and you are not able to make any sense of that at all.
428+
After some digging, you are able to identify the spot where the real application logic
429+
evaluates a set of transformation rules, in order to mangle inbound data appropriately
430+
into outbound data, written in JavaScript.
431+
432+
You quickly realize that the same thing could effectively be solved using [MQTT] and Python,
433+
and operated on a single server machine, because you are doing only a few hundred requests
434+
per second anyway. However, you don't have the time, because, well, the customer quickly
435+
needs a fix because they added another telemetry data field waiting to be consumed and
436+
handled appropriately. Well, and it's Kafka anyway, how would one get rid of _that_?
437+
438+
After a glimpse of desperation, you are reading the fine manual again, and catch up with
439+
the fact that telemetry data _is actually ingested using MQTT!_ That is useful, indeed!
440+
Remembering conversations about a thing called _mqttwarn_ at the Späti the other day,
441+
and that it gained the ability to run JavaScript code recently, in order to bring cloud
442+
computing techniques back to personal computing, you think it would be feasible to change
443+
that architecture of your system in the blink of an eye.
444+
445+
There you go: You rip out the JavaScript transformation rules into a single-file version,
446+
export its main entry point symbol, configure mqttwarn to use `functions = mycloud.js`,
447+
and adjust its settings to use your MQTT broker endpoint at the beginning of the data
448+
pipeline, invoke mqttwarn, and turn off Kafka. It works!
449+
450+
:::{note}
451+
Rest assured we are overexaggerating a bit, and [Kafka] can only be compared to [MQTT]
452+
if you are also willing to compare apples with oranges, but you will get the point that
453+
we believe simpler systems are more often than not able to solve problems equally well,
454+
if not more efficient, both at runtime, and on details of maintenance and operation.
455+
456+
Other than this, every kind of system migration should be conducted with better planning
457+
than outlined in our rant above.
458+
:::
459+
460+
#### JavaScript
461+
462+
For running user-defined functions code written in [JavaScript], mqttwarn uses the
463+
excellent [JSPyBridge] package. For adding JavaScript support to mqttwarn, install
464+
it using pip like `pip install --upgrade 'mqttwarn[javascript]'`, or use one of the
465+
available [OCI images](#using-oci-image).
466+
467+
You can find an example implementation for a `filter` function written in JavaScript
468+
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).
469+
470+
471+
410472
## User-defined function examples
411473

412474
In this section, you can explore a few example scenarios where user-defined
@@ -507,6 +569,12 @@ was received on, here `owntracks/jane/phone`. The name of the section will be
507569
the `section` argument, here `owntracks/#/phone`.
508570
:::
509571

572+
:::{note}
573+
The recipe about how to use [](#owntracks-ntfy-variants-udf) demonstrates
574+
corresponding examples for writing a `filter` function.
575+
:::
576+
577+
510578
(decode-topic)=
511579
### `alldata`: Decoding topic names
512580

@@ -631,10 +699,19 @@ weather,topic=tasmota/temp/ds/1 temperature=19.7 1517525319000
631699
```
632700

633701

702+
[4IR]: https://en.wikipedia.org/wiki/Fourth_Industrial_Revolution
703+
[FaaS]: https://en.wikipedia.org/wiki/Cloud_computing#Serverless_computing_or_Function-as-a-Service_(FaaS)
634704
[geo-fence]: https://en.wikipedia.org/wiki/Geo-fence
635705
[InfluxDB line format]: https://docs.influxdata.com/influxdb/v1.8/write_protocols/line_protocol_tutorial/
706+
[JavaScript]: https://en.wikipedia.org/wiki/JavaScript
636707
[Jinja2 templates]: https://jinja.palletsprojects.com/templates/
708+
[JSPyBridge]: https://pypi.org/project/javascript/
709+
[Kafka]: https://en.wikipedia.org/wiki/Apache_Kafka
710+
[MQTT]: https://en.wikipedia.org/wiki/MQTT
711+
[Node.js]: https://en.wikipedia.org/wiki/Node.js
712+
[OwnTracks]: https://owntracks.org
637713
[Tasmota]: https://github.com/arendst/Tasmota
638714
[Tasmota JSON status response for a DS18B20 sensor]: https://tasmota.github.io/docs/JSON-Status-Responses/#ds18b20
639-
[OwnTracks]: https://owntracks.org
715+
[serverless]: https://en.wikipedia.org/wiki/Serverless_computing
716+
[Serverless Framework]: https://github.com/serverless/serverless
640717
[waypoint]: https://en.wikipedia.org/wiki/Waypoint

docs/usage/pip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ that.
1111
pip install --upgrade mqttwarn
1212
```
1313

14+
Add JavaScript support for user-defined functions.
15+
```bash
16+
pip install --upgrade 'mqttwarn[javascript]'
17+
```
18+
1419
You can also add support for a specific service plugin.
1520

1621
```bash
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
*
3+
* Forward OwnTracks low-battery warnings to ntfy.
4+
* https://mqttwarn.readthedocs.io/en/latest/examples/owntracks-battery/readme.html
5+
*
6+
*/
7+
8+
// mqttwarn filter function, returning true if the message should be ignored.
9+
// In this case, ignore all battery level telemetry values above a certain threshold.
10+
function owntracks_batteryfilter(topic, message) {
11+
let ignore = true;
12+
let data;
13+
14+
// Decode inbound message.
15+
try {
16+
data = JSON.parse(message);
17+
} catch {
18+
data = null;
19+
}
20+
21+
// Evaluate filtering rule.
22+
if (data && "batt" in data && data.batt !== null) {
23+
ignore = Number.parseFloat(data.batt) > 20;
24+
}
25+
26+
return ignore;
27+
}
28+
29+
// Status message.
30+
console.log("Loaded JavaScript module.");
31+
32+
// Export symbols.
33+
module.exports = {
34+
"owntracks_batteryfilter": owntracks_batteryfilter,
35+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
orphan: true
3+
---
4+
5+
(owntracks-ntfy-variants)=
6+
7+
# OwnTracks-to-ntfy setup variants
8+
9+
10+
## About
11+
12+
This section informs you about additional configuration and operation variants of the
13+
[](#owntracks-ntfy-recipe) recipe. For example, you may want to use Docker or Podman
14+
to run both mqttwarn and ntfy, or you may want to use another language than Python to
15+
implement your filtering function.
16+
17+
18+
## Docker and Podman
19+
20+
### Running mqttwarn as container
21+
This command will run mqttwarn in a container, using the `docker` command to launch it.
22+
Alternatively, `podman` can be used. It expects an MQTT broker to be running on `localhost`,
23+
so it uses the `--network=host` option. The command will mount the configuration file and
24+
the user-defined functions file correctly, and will invoke mqttwarn with the corresponding
25+
`--config-file` option.
26+
```shell
27+
docker run --rm -it --network=host --volume=$PWD:/etc/mqttwarn \
28+
ghcr.io/jpmens/mqttwarn-standard \
29+
mqttwarn --config-file=mqttwarn-owntracks.ini
30+
```
31+
32+
### Running ntfy as container
33+
While this tutorial uses the ntfy service at ntfy.sh, it is possible to run your own
34+
instance. For example, use Docker or Podman.
35+
```shell
36+
docker run --name=ntfy --rm -it --publish=5555:80 \
37+
binwiederhier/ntfy serve --base-url="http://localhost:5555"
38+
```
39+
In this case, please adjust the ntfy configuration section `[config:ntfy]` to use
40+
a different URL, and make sure to restart mqttwarn afterwards.
41+
```ini
42+
[config:ntfy]
43+
targets = {'testdrive': 'http://localhost:5555/testdrive'}
44+
```
45+
46+
47+
(owntracks-ntfy-variants-udf)=
48+
49+
## Alternative languages for user-defined functions
50+
51+
### JavaScript
52+
53+
In order to try that on the OwnTracks-to-ntfy example, use the alternative
54+
`mqttwarn-owntracks.js` implementation by adjusting the `functions` setting within the
55+
`[defaults]` section of your configuration file, and restart mqttwarn.
56+
```ini
57+
[defaults]
58+
functions = mqttwarn-owntracks.js
59+
```
60+
61+
The JavaScript function `owntracks_batteryfilter()` implements the same rule as the
62+
previous one, which was written in Python.
63+
64+
:::{literalinclude} mqttwarn-owntracks.js
65+
:language: javascript
66+
:::
67+
68+
:::{attention}
69+
The feature to run JavaScript code is currently considered to be experimental.
70+
Please use it responsibly.
71+
:::

examples/owntracks-ntfy/readme.md

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -96,34 +96,13 @@ echo 'foobar' | mosquitto_pub -t 'owntracks/testdrive' -l
9696

9797
## Appendix
9898

99-
This section demonstrates a few alternative methods for solving different aspects of this
100-
recipe, and also includes administrative information.
101-
102-
### Running mqttwarn as container
103-
This command will run mqttwarn in a container, using the `docker` command to launch it.
104-
Alternatively, `podman` can be used. It expects an MQTT broker to be running on `localhost`,
105-
so it uses the `--network=host` option. The command will mount the configuration file and
106-
the user-defined functions file correctly, and will invoke mqttwarn with the corresponding
107-
`--config-file` option.
108-
```shell
109-
docker run --rm -it --network=host --volume=$PWD:/etc/mqttwarn \
110-
ghcr.io/jpmens/mqttwarn-standard \
111-
mqttwarn --config-file=mqttwarn-owntracks.ini
112-
```
99+
### Configuration and operation variants
113100

114-
### Running ntfy as container
115-
While this tutorial uses the ntfy service at ntfy.sh, it is possible to run your own
116-
instance. For example, use Docker or Podman.
117-
```shell
118-
docker run --name=ntfy --rm -it --publish=5555:80 \
119-
binwiederhier/ntfy serve --base-url="http://localhost:5555"
120-
```
121-
In this case, please adjust the ntfy configuration section `[config:ntfy]` to use
122-
a different URL, and make sure to restart mqttwarn afterwards.
123-
```ini
124-
[config:ntfy]
125-
targets = {'testdrive': 'http://localhost:5555/testdrive'}
126-
```
101+
There are different variants to configure and operate this setup. For example,
102+
you may want to use Docker or Podman to run both mqttwarn and ntfy, or you may
103+
want to use another language than Python to implement your filtering function.
104+
We summarized the available options on the [](#owntracks-ntfy-variants) page,
105+
together with corresponding guidelines how to use them.
127106

128107
### Backlog
129108
:::{todo}

mqttwarn/util.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import re
1515
import string
16+
import threading
1617
import types
1718
import typing as t
1819
from pathlib import Path
@@ -147,8 +148,10 @@ def load_module_from_file(path: t.Union[str, Path]) -> types.ModuleType:
147148
loader = importlib.machinery.SourceFileLoader(fullname=name, path=str(path))
148149
elif path.suffix == ".pyc":
149150
loader = importlib.machinery.SourcelessFileLoader(fullname=name, path=str(path))
151+
elif path.suffix in [".js", ".javascript"]:
152+
return load_source_js(name, str(path))
150153
else:
151-
raise ImportError(f"Loading file failed (only .py and .pyc): {path}")
154+
raise ImportError(f"Loading file type failed (only .py, .pyc, .js, .javascript): {path}")
152155
spec = importlib.util.spec_from_loader(loader.name, loader)
153156
if spec is None:
154157
raise ModuleNotFoundError(f"Failed loading module from file: {path}")
@@ -216,16 +219,17 @@ def import_symbol(name: str, parent: t.Optional[types.ModuleType] = None) -> typ
216219
return import_symbol(remaining_names, parent=module)
217220

218221

219-
def load_functions(filepath: t.Optional[str] = None) -> t.Optional[types.ModuleType]:
222+
def load_functions(filepath: t.Optional[t.Union[str, Path]] = None) -> t.Optional[types.ModuleType]:
220223

221224
if not filepath:
222225
return None
223226

227+
filepath = str(filepath)
228+
224229
if not os.path.isfile(filepath):
225230
raise IOError("'{}' not found".format(filepath))
226231

227-
py_mod = load_module_from_file(filepath)
228-
return py_mod
232+
return load_module_from_file(filepath)
229233

230234

231235
def load_function(name: str, py_mod: t.Optional[types.ModuleType]) -> t.Callable:
@@ -277,3 +281,31 @@ def load_file(path: t.Union[str, Path], retry_tries=None, retry_interval=0.075,
277281
except: # pragma: nocover
278282
pass
279283
return reader
284+
285+
286+
def module_factory(name, variables):
287+
"""
288+
Create a synthetic Python module object.
289+
290+
Derived from:
291+
https://www.oreilly.com/library/view/python-cookbook/0596001673/ch15s03.html
292+
"""
293+
import imp
294+
295+
module = imp.new_module(name)
296+
module.__dict__.update(variables)
297+
module.__file__ = "<synthesized>"
298+
return module
299+
300+
301+
def load_source_js(mod_name, filepath):
302+
"""
303+
Load a JavaScript module, and import its exported symbols into a synthetic Python module.
304+
"""
305+
import javascript
306+
307+
js_code = load_file(filepath, retry_tries=0).read().decode("utf-8")
308+
module = {}
309+
javascript.eval_js(js_code)
310+
threading.Event().wait(0.01)
311+
return module_factory(mod_name, module["exports"])

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ ignore = [
5656
extend-exclude = [
5757
# Always.
5858
".venv*",
59+
"example_*",
5960
"tests/etc/functions_bad.py",
6061
# Temporary.
6162
"examples",

setup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"gspread>=2.1.1",
5454
"oauth2client>=4.1.2",
5555
],
56+
"javascript": [
57+
"javascript==1!1.0.1; python_version>='3.7'",
58+
],
5659
"mysql": [
5760
"mysql",
5861
],
@@ -200,6 +203,8 @@
200203
"Operating System :: POSIX",
201204
"Operating System :: Unix",
202205
"Operating System :: MacOS",
206+
"Operating System :: Microsoft :: Windows",
207+
"Programming Language :: JavaScript",
203208
"Programming Language :: Python",
204209
"Programming Language :: Python :: 3.6",
205210
"Programming Language :: Python :: 3.7",
@@ -229,7 +234,7 @@
229234
author="Jan-Piet Mens, Ben Jones, Andreas Motl",
230235
author_email="[email protected]",
231236
url="https://github.com/mqtt-tools/mqttwarn",
232-
keywords="mqtt notification plugins data acquisition push transformation engine mosquitto",
237+
keywords="mqtt notification plugins data acquisition push transformation engine mosquitto python javascript",
233238
packages=find_packages(),
234239
include_package_data=True,
235240
package_data={

0 commit comments

Comments
 (0)