diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221730f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config/ +compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbd8261 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM denoland/deno:alpine-1.45.5 + +WORKDIR /app + +COPY main.ts . +COPY deno.lock . + +RUN deno cache main.ts + +CMD ["run", "--allow-net", "--allow-env", "--allow-read", "main.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d41bdd --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# `hrules` + +hrules processes MQTT messages based on a text configuration file. The configuration file defines rules for subscribing to MQTT topics, evaluating a user defined expression, and publishing responses to specified topics. + +## `hrules.rules` File Structure + +The configuration file consists of multiple lines, each defining a specific rule. Each rule is separated into three parts using the "->" delimiter. The parts are as follows: + +1. **MQTT Topic to Subscribe**: The MQTT topic that the system should subscribe to. +2. **Evaluation Expression**: An expression that is evaluated to determine if the response topic should be published. +3. **Response Topic**: The MQTT topic to which the system should publish the response. + +## Syntax + +``` + -> -> + | -> -> +``` + +### Example + +``` +zigbee/wc/sensor01 -> payload.occupancy == true -> hue/set { match: { room: "WC", device: "*" }, state: { on: { on: true } } } +``` + +### Detailed Breakdown + +1. **MQTT Topic to Subscribe**: `zigbee/wc/sensor01` + - This is the MQTT topic that the system will subscribe to. + +2. **Evaluation Expression**: `payload.occupancy == true` + - This expression evaluates the payload of the MQTT message. In this case, it checks if the `occupancy` field in the payload is `true`. + +3. **Response Topic**: `hue/set { match: { room: "WC", device: "*" }, state: { on: { on: true } } }` + - This is the MQTT topic to which the system will publish the response. The response includes a JSON object used as a payload + +## Examples + +### Occupancy Sensors + +``` +zigbee/bathroom/sensor01 -> payload.occupancy == true -> hue/set { match: { room: "Salle de bain", device: "*" }, state: { on: { on: true } } } +zigbee/bathroom/sensor01 -> payload.occupancy == false -> hue/set { match: { room: "Salle de bain", device: "*" }, state: { on: { on: false } } } +``` + +### Switches + +``` +zigbee/kitchen/switch -> payload.action == "on_press_release" -> hue/set { match: { room: "Cusine", device: "*" }, state: { on: { on: true } } } +zigbee/kitchen/switch -> payload.action == "off_press_release" -> hue/set { match: { room: "Cusine", device: "*" }, state: { on: { on: false } } } + +zigbee/living/switch -> payload.action == "down_hold_release" -> zigbee/living/powersocket02/set "OFF" +zigbee/living/switch -> payload.action == "up_hold_release" -> zigbee/living/powersocket02/set "ON" +``` + +### Time-Based Rules + +The configuration file also supports time-based rules using the | delimiter followed by a time range. This allows rules to be active only during specific times of the day. + +``` +zigbee/room/switch | 09:00-22:30 -> payload.action == "on_press_release" -> hue/set { match: { room: "Chambre", device: "*" }, state: { on: { on: true }, brightness: 100 } } +zigbee/room/switch | 22:30-09:00 -> payload.action == "on_press_release" -> hue/set { match: { room: "Chambre", device: "*" }, state: { on: { on: true }, brightness: 20 } } +``` + +## Notes + +- The evaluation expression must return `true` for the response topic to be published +- The response topic can include JSON objects or simple strings. If the payload is wrapped with quotes it will be treated as string and JSON otherwise. diff --git a/compose.example.yml b/compose.example.yml new file mode 100644 index 0000000..171bbb3 --- /dev/null +++ b/compose.example.yml @@ -0,0 +1,7 @@ +services: + tube: + image: yadomi/hrules + user: 1000:1000 + restart: unless-stopped + volumes: + - ./config:/app/config/ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..88fe66c --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "lock": "deno cache --lock=deno.lock --lock-write main.ts", + "build": "docker build -t yadomi/hrules ." + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..ff9a340 --- /dev/null +++ b/deno.lock @@ -0,0 +1,294 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/fmt@0.225.1": "jsr:@std/fmt@0.225.1", + "jsr:@std/io@0.224.0": "jsr:@std/io@0.224.0", + "jsr:@std/yaml": "jsr:@std/yaml@0.224.3", + "npm:@digifi/jexl": "npm:@digifi/jexl@1.1.14", + "npm:mqtt": "npm:mqtt@5.9.1" + }, + "jsr": { + "@std/fmt@0.225.1": { + "integrity": "44a8cb375d7344adb3cb0208b85ea0bde7cdc15224c11188c85e733834ffe356" + }, + "@std/io@0.224.0": { + "integrity": "0aff885d21d829c050b8a08b1d71b54aed5841aecf227f8d77e99ec529a11e8e" + }, + "@std/yaml@0.224.3": { + "integrity": "9da1ed0094f42ba24570b4d88a094b44a793ac7f2bc085c1939d3ac7e11cc0bb" + } + }, + "npm": { + "@babel/runtime@7.25.0": { + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", + "dependencies": { + "regenerator-runtime": "regenerator-runtime@0.14.1" + } + }, + "@digifi/jexl@1.1.14": { + "integrity": "sha512-uEBayoDw93ehCKsD+wXws7eW4+L69FWwEfVDnlJeLYVkgqLMPStQljbOmjqXw+OfXdSTkJVbeL5HpFW2HrWK0g==", + "dependencies": { + "@babel/runtime": "@babel/runtime@7.25.0" + } + }, + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + }, + "@types/readable-stream@4.0.15": { + "integrity": "sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw==", + "dependencies": { + "@types/node": "@types/node@18.16.19", + "safe-buffer": "safe-buffer@5.1.2" + } + }, + "@types/ws@8.5.12": { + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dependencies": { + "@types/node": "@types/node@18.16.19" + } + }, + "abort-controller@3.0.0": { + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "event-target-shim@5.0.1" + } + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dependencies": {} + }, + "bl@6.0.14": { + "integrity": "sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ==", + "dependencies": { + "@types/readable-stream": "@types/readable-stream@4.0.15", + "buffer": "buffer@6.0.3", + "inherits": "inherits@2.0.4", + "readable-stream": "readable-stream@4.5.2" + } + }, + "buffer-from@1.1.2": { + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dependencies": {} + }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": { + "base64-js": "base64-js@1.5.1", + "ieee754": "ieee754@1.2.1" + } + }, + "commist@3.2.0": { + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "dependencies": {} + }, + "concat-stream@2.0.0": { + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dependencies": { + "buffer-from": "buffer-from@1.1.2", + "inherits": "inherits@2.0.4", + "readable-stream": "readable-stream@3.6.2", + "typedarray": "typedarray@0.0.6" + } + }, + "debug@4.3.6": { + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "ms@2.1.2" + } + }, + "event-target-shim@5.0.1": { + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dependencies": {} + }, + "events@3.3.0": { + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dependencies": {} + }, + "fast-unique-numbers@8.0.13": { + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dependencies": { + "@babel/runtime": "@babel/runtime@7.25.0", + "tslib": "tslib@2.6.3" + } + }, + "help-me@5.0.0": { + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dependencies": {} + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dependencies": {} + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dependencies": {} + }, + "js-sdsl@4.3.0": { + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "dependencies": {} + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dependencies": {} + }, + "minimist@1.2.8": { + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dependencies": {} + }, + "mqtt-packet@9.0.0": { + "integrity": "sha512-8v+HkX+fwbodsWAZIZTI074XIoxVBOmPeggQuDFCGg1SqNcC+uoRMWu7J6QlJPqIUIJXmjNYYHxBBLr1Y/Df4w==", + "dependencies": { + "bl": "bl@6.0.14", + "debug": "debug@4.3.6", + "process-nextick-args": "process-nextick-args@2.0.1" + } + }, + "mqtt@5.9.1": { + "integrity": "sha512-FMENfSUMfCSUCnkuUVAL4U01795SUEfrX0NZ53HNr1r2VNpwKhR5Au9viq9WCFGtgrDAmsll4fkloqFCFgStYA==", + "dependencies": { + "@types/readable-stream": "@types/readable-stream@4.0.15", + "@types/ws": "@types/ws@8.5.12", + "commist": "commist@3.2.0", + "concat-stream": "concat-stream@2.0.0", + "debug": "debug@4.3.6", + "help-me": "help-me@5.0.0", + "lru-cache": "lru-cache@10.4.3", + "minimist": "minimist@1.2.8", + "mqtt-packet": "mqtt-packet@9.0.0", + "number-allocator": "number-allocator@1.0.14", + "readable-stream": "readable-stream@4.5.2", + "reinterval": "reinterval@1.1.0", + "rfdc": "rfdc@1.4.1", + "split2": "split2@4.2.0", + "worker-timers": "worker-timers@7.1.8", + "ws": "ws@8.18.0" + } + }, + "ms@2.1.2": { + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dependencies": {} + }, + "number-allocator@1.0.14": { + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "dependencies": { + "debug": "debug@4.3.6", + "js-sdsl": "js-sdsl@4.3.0" + } + }, + "process-nextick-args@2.0.1": { + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dependencies": {} + }, + "process@0.11.10": { + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dependencies": {} + }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "inherits@2.0.4", + "string_decoder": "string_decoder@1.3.0", + "util-deprecate": "util-deprecate@1.0.2" + } + }, + "readable-stream@4.5.2": { + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "abort-controller@3.0.0", + "buffer": "buffer@6.0.3", + "events": "events@3.3.0", + "process": "process@0.11.10", + "string_decoder": "string_decoder@1.3.0" + } + }, + "regenerator-runtime@0.14.1": { + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dependencies": {} + }, + "reinterval@1.1.0": { + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==", + "dependencies": {} + }, + "rfdc@1.4.1": { + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dependencies": {} + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dependencies": {} + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dependencies": {} + }, + "split2@4.2.0": { + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dependencies": {} + }, + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "safe-buffer@5.2.1" + } + }, + "tslib@2.6.3": { + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dependencies": {} + }, + "typedarray@0.0.6": { + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dependencies": {} + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dependencies": {} + }, + "worker-timers-broker@6.1.8": { + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dependencies": { + "@babel/runtime": "@babel/runtime@7.25.0", + "fast-unique-numbers": "fast-unique-numbers@8.0.13", + "tslib": "tslib@2.6.3", + "worker-timers-worker": "worker-timers-worker@7.0.71" + } + }, + "worker-timers-worker@7.0.71": { + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dependencies": { + "@babel/runtime": "@babel/runtime@7.25.0", + "tslib": "tslib@2.6.3" + } + }, + "worker-timers@7.1.8": { + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dependencies": { + "@babel/runtime": "@babel/runtime@7.25.0", + "tslib": "tslib@2.6.3", + "worker-timers-broker": "worker-timers-broker@6.1.8", + "worker-timers-worker": "worker-timers-worker@7.0.71" + } + }, + "ws@8.18.0": { + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dependencies": {} + } + } + }, + "redirects": { + "https://deno.land/x/logger/logger.ts": "https://deno.land/x/logger@v1.1.6/logger.ts" + }, + "remote": { + "https://deno.land/x/logger@v1.1.6/date.ts": "e0e2246350afbbad09a40fb0755c0820e52154a0e576719f685fe9a0103d29f3", + "https://deno.land/x/logger@v1.1.6/deps.ts": "85ac81fd5e6738541dee3fddf0a7ff7f1ca943822c6ef9e212bbea2baee92c67", + "https://deno.land/x/logger@v1.1.6/eol.ts": "8da67b2cb62c1cee6920e88797f1d9f647425df7f4decafc4dd3775e29d0125d", + "https://deno.land/x/logger@v1.1.6/fs.ts": "36fdfc2b162a586e7b829b42244f7a54513d75e9a382e12f1379a7aad8446d18", + "https://deno.land/x/logger@v1.1.6/interface.ts": "9f8cf829271816b5b842aa89ce44adefdf35619c6c8e62c36fd2e6fe458948b3", + "https://deno.land/x/logger@v1.1.6/logger.ts": "5b12605357c35f7c29f56d62cf685a6f1ba40a1f4311b3543654666860cee7a8", + "https://deno.land/x/logger@v1.1.6/stdout.ts": "28ff623094e1719d86a093c589558fba095caba4f30832493d07e82ea2d4ae59", + "https://deno.land/x/logger@v1.1.6/types.ts": "7769b3a4059d25090a55079ee1ef77ff76adbcab22d017fe0a8a4a96e1eac8a0", + "https://deno.land/x/logger@v1.1.6/writable.ts": "fe7be1f590d01c9ed92f46120c847b8bcf7b19799ae139b8c5a42875952653e7", + "https://deno.land/x/logger@v1.1.6/writer.ts": "414a938e50a69fbd248e3247774f32e546bf0e80ff6e99c2d8aa70dcda97d2e3" + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..9dfbfe4 --- /dev/null +++ b/main.ts @@ -0,0 +1,154 @@ +import mqtt from "npm:mqtt"; +import jexl from "npm:@digifi/jexl"; +import { parse } from "jsr:@std/yaml"; +import Logger from "https://deno.land/x/logger/logger.ts"; + +const logger = new Logger(); +const settings = parse(await Deno.readTextFile(Deno.args[0] || "./config/hrules.yml")); + +const client = mqtt.connect(settings.mqtt.host, settings.mqtt.options); +const definitions = await Deno.readTextFile("./config/hrules.rules"); + +const Utils = { + now() { + const weekdays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + const currentDate = new Date(); + return { + weekday: weekdays[currentDate.getDay()], + minutes: 60 * currentDate.getHours() + currentDate.getMinutes(), + }; + }, + withinHoursExpression(timeRange: string): boolean { + const now = Utils.now(); + + for (const hourRange of timeRange.split(",")) { + const [startTime, endTime] = hourRange.split("-").map(time => { + const [hours, minutes] = time.split(":"); + return Number(hours) * 60 + Number(minutes); + }); + + if (now.minutes >= startTime && now.minutes <= endTime) { + return true; + } + } + return false; + }, + withinTimeExpression(timeExpression: string) { + const regex = /((?(?:[A-Z][a-z]-?)+)\s?)?(?.+)/; + let isWithinRange = false; + + for (const part of timeExpression.split(";")) { + const trimmedPart = part.trim(); + const match = regex.exec(trimmedPart); + + if (!match) continue; + if (!match.groups) continue; + + const { weekday, hoursExpression } = match.groups; + + if (weekday) { + const specifiedWeekdays = weekday.split("-"); + if (specifiedWeekdays.includes(Utils.now().weekday)) { + isWithinRange = Utils.withinHoursExpression(hoursExpression); + } + } else { + isWithinRange = Utils.withinHoursExpression(hoursExpression); + } + } + + return isWithinRange; + }, + parseReplyExpression(str: string, originalPayload: string | Record) { + const [topic, ...responsePayload] = str.split(" ") ?? []; + const payload = new Function("const [payload] = arguments; return " + responsePayload.join(" "))(originalPayload); + + return [topic.trim(), typeof payload === "string" ? payload : JSON.stringify(payload)]; + }, +}; + +const triggers = new Map< + string, + ((context: string | Record) => Promise<(() => void) | undefined>)[] +>(); + +async function main() { + logger.info("[Init] Connected to MQTT broker"); + + let i = 1; + for (const line of definitions.trim().split("\n")) { + const definition = line.split("->").map(x => x.trim()) as [string, string, string]; + + if (definition.length < 3) { + continue; + } + + if (definition[0].startsWith("#")) { + continue; + } + + const [inputExpression, expression, replyExpression] = definition; + const [topic, timeExpression] = inputExpression.split("|").map(x => x.trim()); + + const fn = async (context: string | Record) => { + const passTimeExpression = timeExpression ? Utils.withinTimeExpression(timeExpression) : true; + if (!passTimeExpression) return; + + const passEvalExpression = await jexl.eval(expression, { + payload: context, + global: { + ts: new Date().getTime(), + }, + }); + if (!passEvalExpression) return; + + logger.info(`[Sub] Incoming ${topic}`); + + return () => { + try { + const [replyTopic, replyPayload] = Utils.parseReplyExpression(replyExpression, context); + client.publish(replyTopic, replyPayload); + logger.info(`[Pub] ${topic} -> ${replyTopic}`); + } catch (e) { + logger.error(e); + } + }; + }; + + if (triggers.has(topic)) { + const actions = triggers.get(topic); + if (actions) { + actions.push(fn); + triggers.set(topic, actions); + } + } else { + triggers.set(topic, [fn]); + client.subscribe(topic); + logger.info(`[Init] Subcribed to ${topic}`); + } + + i++; + } + + client.on("message", async (topic, message) => { + const actions = triggers.get(topic); + if (!actions) return; + + for (const action of actions) { + if (!action) continue; + + let payload = message; + try { + payload = JSON.parse(payload.toString()); + } catch (e) { + logger.error(e); + } + + const callback = await action(payload); + if (callback) { + await callback(); + } + } + }); +} + +client.on("connect", main);