Skip to content

Commit ae8eba3

Browse files
committed
[udf] Unlock Lua for user-defined functions
1 parent 857e55d commit ae8eba3

File tree

9 files changed

+160
-6
lines changed

9 files changed

+160
-6
lines changed

CHANGES.rst

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ in progress
1313
- Tests: Add more test cases to increase mqttwarn core coverage to ~100%
1414
- Improve example "Forward OwnTracks low-battery warnings to ntfy"
1515
- [udf] Unlock JavaScript for user-defined functions. Thanks, @extremeheat.
16+
- [udf] Unlock Lua for user-defined functions. Thanks, @scoder.
1617

1718

1819
2023-04-28 0.34.0

Dockerfile

+1-1
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[javascript]'
37+
&& pip install --use-pep517 --prefer-binary '/src[javascript,lua]'
3838

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

docs/configure/transformation.md

+23
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,19 @@ export its main entry point symbol, configure mqttwarn to use `functions = myclo
447447
and adjust its settings to use your MQTT broker endpoint at the beginning of the data
448448
pipeline, invoke mqttwarn, and turn off Kafka. It works!
449449

450+
On the next day, after investigating if you need to migrate any other system components,
451+
you realize that there is an Nginx instance, which receives a certain share of telemetry
452+
traffic using HTTP, and processes it using Lua. One quick `mosquitto_pub` later, you are
453+
sure those telemetry messages are _also_ available on the MQTT bus already. Another set
454+
of transformation rules written in Lua was quickly identified, and, after applying the
455+
same procedure of inlining it into a single-file version, and configuring another mqttwarn
456+
instance with `functions = mycloud.lua`, you are ready to turn off your whole cloud
457+
infrastructure, and save valuable resources.
458+
459+
After a while, you are able to hire back half of your previous engineering team, and,
460+
based on the new architecture, you will happily start contributing back to mqttwarn,
461+
both in terms of maintenance, and by adding new features.
462+
450463
:::{note}
451464
Rest assured we are overexaggerating a bit, and [Kafka] can only be compared to [MQTT]
452465
if you are also willing to compare apples with oranges, but you will get the point that
@@ -467,6 +480,15 @@ available [OCI images](#using-oci-image).
467480
You can find an example implementation for a `filter` function written in JavaScript
468481
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).
469482

483+
#### Lua
484+
485+
For running user-defined functions code written in Lua, mqttwarn uses the excellent
486+
[lupa] package. For adding JavaScript support to mqttwarn, install it using pip like
487+
`pip install --upgrade 'mqttwarn[lua]'`, or use one of the available
488+
[OCI images](#using-oci-image).
489+
490+
You can find an example implementation for a `filter` function written in Lua
491+
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).
470492

471493

472494
## User-defined function examples
@@ -707,6 +729,7 @@ weather,topic=tasmota/temp/ds/1 temperature=19.7 1517525319000
707729
[Jinja2 templates]: https://jinja.palletsprojects.com/templates/
708730
[JSPyBridge]: https://pypi.org/project/javascript/
709731
[Kafka]: https://en.wikipedia.org/wiki/Apache_Kafka
732+
[lupa]: https://github.com/scoder/lupa
710733
[MQTT]: https://en.wikipedia.org/wiki/MQTT
711734
[Node.js]: https://en.wikipedia.org/wiki/Node.js
712735
[OwnTracks]: https://owntracks.org

docs/usage/pip.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ that.
1111
pip install --upgrade mqttwarn
1212
```
1313

14-
Add JavaScript support for user-defined functions.
14+
Add JavaScript and Lua support for user-defined functions.
1515
```bash
16-
pip install --upgrade 'mqttwarn[javascript]'
16+
pip install --upgrade 'mqttwarn[javascript,lua]'
1717
```
1818

1919
You can also add support for a specific service plugin.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--[[
2+
Forward OwnTracks low-battery warnings to ntfy.
3+
https://mqttwarn.readthedocs.io/en/latest/examples/owntracks-battery/readme.html
4+
--]]
5+
6+
-- mqttwarn filter function, returning true if the message should be ignored.
7+
-- In this case, ignore all battery level telemetry values above a certain threshold.
8+
function owntracks_batteryfilter(topic, message)
9+
local ignore = true
10+
11+
-- Decode inbound message.
12+
local data = json.decode(message)
13+
14+
-- Evaluate filtering rule.
15+
if data ~= nil and data.batt ~= nil then
16+
ignore = tonumber(data.batt) > 20
17+
end
18+
19+
return ignore
20+
end
21+
22+
-- Status message.
23+
print("Loaded Lua module.")
24+
25+
-- Export symbols.
26+
return {
27+
owntracks_batteryfilter = owntracks_batteryfilter,
28+
}

examples/owntracks-ntfy/readme-variants.md

+25-3
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ targets = {'testdrive': 'http://localhost:5555/testdrive'}
5050

5151
### JavaScript
5252

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.
53+
In order to explore JavaScript user-defined functions using the OwnTracks-to-ntfy recipe,
54+
use the alternative `mqttwarn-owntracks.js` implementation by adjusting the `functions`
55+
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
5656
```ini
5757
[defaults]
5858
functions = mqttwarn-owntracks.js
@@ -69,3 +69,25 @@ previous one, which was written in Python.
6969
The feature to run JavaScript code is currently considered to be experimental.
7070
Please use it responsibly.
7171
:::
72+
73+
### Lua
74+
75+
In order to explore Lua user-defined functions using the OwnTracks-to-ntfy recipe,
76+
use the alternative `mqttwarn-owntracks.lua` implementation by adjusting the `functions`
77+
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
78+
```ini
79+
[defaults]
80+
functions = mqttwarn-owntracks.lua
81+
```
82+
83+
The Lua function `owntracks_batteryfilter()` implements the same rule as the
84+
previous ones, which was written in Python and JavaScript.
85+
86+
:::{literalinclude} mqttwarn-owntracks.lua
87+
:language: lua
88+
:::
89+
90+
:::{attention}
91+
The feature to run Lua code is currently considered to be experimental.
92+
Please use it responsibly.
93+
:::

mqttwarn/util.py

+42
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import string
10+
import sys
1011
import threading
1112
import types
1213
import typing as t
@@ -204,6 +205,9 @@ def load_functions(filepath: t.Optional[t.Union[str, Path]] = None) -> t.Optiona
204205
elif file_ext.lower() in [".js", ".javascript"]:
205206
py_mod = load_source_js(mod_name, filepath)
206207

208+
elif file_ext.lower() in [".lua"]:
209+
py_mod = load_source_lua(mod_name, filepath)
210+
207211
else:
208212
raise RuntimeError(f"Unable to interpret module file: {filepath}")
209213

@@ -285,3 +289,41 @@ def load_source_js(mod_name, filepath):
285289
javascript.eval_js(js_code)
286290
threading.Event().wait(0.01)
287291
return module_factory(mod_name, module["exports"])
292+
293+
294+
class LuaJsonAdapter:
295+
"""
296+
Support Lua as if it had its `json` module.
297+
298+
Wasn't able to make Lua's `json` module work, so this provides minimal functionality
299+
instead. It will be injected into the Lua context's global `json` symbol.
300+
"""
301+
302+
@staticmethod
303+
def decode(data):
304+
if data is None:
305+
return None
306+
return json.loads(data)
307+
308+
309+
def load_source_lua(mod_name, filepath):
310+
"""
311+
Load a Lua module, and import its exported symbols into a synthetic Python module.
312+
"""
313+
import lupa
314+
315+
lua = lupa.LuaRuntime(unpack_returned_tuples=True)
316+
317+
# Lua modules want to be loaded without suffix, but the interpreter would like to know about their path.
318+
modfile = Path(filepath).with_suffix("").name
319+
modpath = Path(filepath).parent
320+
# Yeah, Windows.
321+
if sys.platform == "win32":
322+
modpath = str(modpath).replace("\\", "\\\\")
323+
lua.execute(rf'package.path = package.path .. ";{str(modpath)}/?.lua"')
324+
325+
logger.info(f"Loading Lua module {modfile} from path {modpath}")
326+
module, filepath = lua.require(modfile)
327+
# FIXME: Add support for common modules, as long as they are not available natively.
328+
lua.globals()["json"] = LuaJsonAdapter
329+
return module_factory(mod_name, module)

setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
"javascript": [
5656
"javascript==1!1.0.1; python_version>='3.7'",
5757
],
58+
"lua": [
59+
"lupa<3",
60+
],
5861
"mysql": [
5962
"mysql",
6063
],

tests/test_util.py

+35
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,41 @@ def test_load_functions_javascript_runtime_failure(tmp_path):
199199
assert ex.match("ReferenceError: bar is not defined")
200200

201201

202+
def test_load_functions_lua_success(tmp_path):
203+
"""
204+
Verify that Lua module loading, including symbol exporting and invocation, works well.
205+
"""
206+
luafile = tmp_path / "test.lua"
207+
luafile.write_text("return { forty_two = function() return 42 end }")
208+
luamod = load_functions(luafile)
209+
assert luamod.forty_two() == 42
210+
211+
212+
def test_load_functions_lua_compile_failure(tmp_path):
213+
"""
214+
Verify that Lua module loading, including symbol exporting and invocation, works well.
215+
"""
216+
luafile = tmp_path / "test.lua"
217+
luafile.write_text("Hotzenplotz")
218+
with pytest.raises(Exception) as ex:
219+
load_functions(luafile)
220+
assert ex.typename == "LuaError"
221+
assert ex.match("syntax error near <eof>")
222+
223+
224+
def test_load_functions_lua_runtime_failure(tmp_path):
225+
"""
226+
Verify that Lua module loading, including symbol exporting and invocation, works well.
227+
"""
228+
luafile = tmp_path / "test.lua"
229+
luafile.write_text("return { foo = function() bar() end }")
230+
luamod = load_functions(luafile)
231+
with pytest.raises(Exception) as ex:
232+
luamod.foo()
233+
assert ex.typename == "LuaError"
234+
assert ex.match(re.escape("attempt to call a nil value (global 'bar')"))
235+
236+
202237
def test_load_function():
203238

204239
# Load valid functions file

0 commit comments

Comments
 (0)