From c00d5ba317ffecb9c5f21ed0c2231b2debe87dff Mon Sep 17 00:00:00 2001 From: c1328 <112856305+c1328@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:45:24 +0100 Subject: [PATCH] Refactor Meshtastic ioBroker integration for clarity --- meshcli_iobroker.js | 724 +++++++++++++++++++++++--------------------- 1 file changed, 377 insertions(+), 347 deletions(-) diff --git a/meshcli_iobroker.js b/meshcli_iobroker.js index 75575c4..ebcf738 100644 --- a/meshcli_iobroker.js +++ b/meshcli_iobroker.js @@ -1,355 +1,84 @@ // Meshtastic ioBroker Integration Kit -// Idea based on https://forum.iobroker.net/topic/73326/meshtastic/2 // ====================================================== // CONFIG // ====================================================== +const deviceIp = "192.168.1.xxx"; +const mqttPath = "mqtt.3.msh.*.json.*"; -// IP of your Meshtastic node -const deviceIp = "192.168.1.x"; +const MESHTASTIC_BIN = "/home/iobroker/.local/bin/meshtastic"; -// MQTT path variable (note the 3 because I use the 3rd instance of mqtt at IoBroker) -const mqttPath = /^mqtt\.3\.msh\..*\.json\..*$/; +const BASE = "0_userdata.0.Meshtastic"; +const NODES = BASE + ".Nodes"; +const CHATS = BASE + ".Chats"; + +const POLL_INTERVAL = 300000; +const HISTORY_MAX = 10; -// configure the channels of your node const chats = [ { name: "Default", id: 0 }, { name: "", id: 1 }, { name: "", id: 2 } ]; -// Base paths (usualy no adjustment needed) -const BASE = "0_userdata.0.Meshtastic"; -const NODES = BASE + ".Nodes"; -const CHATS = BASE + ".Chats"; +// Node.js execFile verfügbar +const { execFile } = require("child_process"); // ====================================================== // HELPERS // ====================================================== function safeCreateObject(id, obj) { - if (!existsObject(id)) setObject(id, obj); + if (!existsObject(id)) { + setObject(id, obj); + log("Created object: " + id, "info"); + } } function safeCreateState(id, common) { if (!existsObject(id)) { - setObject(id, { type: "state", common: common, native: {} }); - } -} - -function safeSetState(id, val) { - if (existsObject(id)) setState(id, val, true); -} - -function shellSafe(msg) { - if (!msg) return ""; - return msg.replace(/'/g, "").replace(/"/g, '\\"'); -} - -function parseNum(val) { - if (!val || val === "N/A" || val === "Powered") return 0; - return parseFloat(String(val).replace(/[^\d.-]/g, "")) || 0; -} - -// ====================================================== -// 1. MQTT TRIGGER (Realtime Chat + Name Resolution) -// ====================================================== -on({ id: mqttPath, change: "any" }, function (obj) { - try { - if (!obj.state.val) return; - const msg = JSON.parse(obj.state.val); - - const channelIdx = parseInt(msg.channel) || 0; - - // sender hex - let senderHex = parseInt(msg.from) - .toString(16) - .toLowerCase() - .replace("!", "") - .padStart(8, "0"); - - const nodeBasePath = `${NODES}.${senderHex}`; - - // Name resolution - let displayName = senderHex; - - if (existsState(nodeBasePath + ".info.alias")) { - let aliasVal = getState(nodeBasePath + ".info.alias").val; - if (aliasVal && aliasVal !== "N/A") displayName = aliasVal; - } else if (existsState(nodeBasePath + ".info.user")) { - let userVal = getState(nodeBasePath + ".info.user").val; - if (userVal && userVal !== "N/A") displayName = userVal; - } - - // --- TEXT MESSAGE --- - if (msg.type === "text" && msg.payload?.text) { - const text = msg.payload.text; - - // Node lastMessage - safeCreateState(nodeBasePath + ".info.lastMessage", { - name: "Letzte Nachricht", - type: "string", - role: "text", - read: true, - write: false - }); - safeSetState(nodeBasePath + ".info.lastMessage", text); - - // Chat lastMessage + history - const chatPath = `${CHATS}.${channelIdx}`; - if (existsObject(chatPath)) { - safeSetState(chatPath + ".lastMessage", `${displayName}: ${text}`); - addToHistory(channelIdx, displayName, text); - } - - log(`Meshtastic Text: [${channelIdx}] ${displayName}: ${text}`); - - } - - // --- POSITION --- - else if (msg.type === "position" && msg.payload) { - const p = msg.payload; - - if (p.latitude_i && p.longitude_i) { - const lat = p.latitude_i / 10000000; - const lon = p.longitude_i / 10000000; - const alt = p.altitude || 0; - - safeSetState(nodeBasePath + ".info.latitude", lat); - safeSetState(nodeBasePath + ".info.longitude", lon); - safeSetState(nodeBasePath + ".info.altitude", alt); - safeSetState(nodeBasePath + ".info.location", `${lat},${lon}`); - - log(`Meshtastic Position: ${displayName} @ ${lat},${lon}`); - } - } - - } catch (e) { - log("Fehler im MQTT Trigger: " + e, "error"); - } -}); - -// ====================================================== -// 2. SEND MESSAGE TO CHAT CHANNELS -// ====================================================== -on({ id: new RegExp("^" + CHATS.replace(/\./g, "\\.") + "\\.\\d+\\.sendMessage$"), change: "any" }, -function (obj) { - - if (obj.state.ack) return; - const msg = obj.state.val; - if (!msg) return; - - const parts = obj.id.split("."); - const channelId = parseInt(parts[parts.length - 2]); - - const safeMsg = shellSafe(msg); - - log(`Meshtastic: Send Chat ${channelId}: ${safeMsg}`); - - exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --ch-index ${channelId} --sendtext '${safeMsg}'`, - function (error, stdout, stderr) { - if (error) log("Send error: " + (stderr || error), "error"); - else setTimeout(() => setState(obj.id, "", true), 300); - } - ); -}); - -// ====================================================== -// 3. SEND DIRECT MESSAGE TO NODE -// ====================================================== -on({ id: new RegExp("^" + NODES.replace(/\./g, "\\.") + "\\..*\\.command\\.sendMessage$"), change: "any", ack: false }, -function (obj) { - - const msg = obj.state.val; - if (!msg) return; - - const nodeId = obj.id.split(".")[4]; - const safeMsg = shellSafe(msg); - - log(`Meshtastic: Direct to !${nodeId}: ${safeMsg}`); - - exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --dest "!${nodeId}" --sendtext "${safeMsg}"`, - function (error) { - if (!error) setTimeout(() => setState(obj.id, "", true), 300); - } - ); -}); - -// ====================================================== -// 4. NODE COMMAND BUTTONS (Ping/Traceroute/Telemetry/Location) -// ====================================================== -function cliAction(target, cmd) { - exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} ${cmd} --dest "!${target}"`); -} - -// Ping -on({ id: new RegExp("^" + NODES.replace(/\./g, "\\.") + "\\..*\\.command\\.sendPing$"), change: "any" }, -obj => { - if (obj.state.ack || !obj.state.val) return; - const nodeId = obj.id.split(".")[4]; - cliAction(nodeId, "--sendping"); - setState(obj.id, false, true); -}); - -// Traceroute -on({ id: new RegExp("^" + NODES.replace(/\./g, "\\.") + "\\..*\\.command\\.sendTraceRoute$"), change: "any" }, -obj => { - if (obj.state.ack || !obj.state.val) return; - const nodeId = obj.id.split(".")[4]; - cliAction(nodeId, "--traceroute"); - setState(obj.id, false, true); -}); - -// Telemetry -on({ id: new RegExp("^" + NODES.replace(/\./g, "\\.") + "\\..*\\.command\\.getTelemetry$"), change: "any" }, -obj => { - if (obj.state.ack || !obj.state.val) return; - const nodeId = obj.id.split(".")[4]; - cliAction(nodeId, "--request-telemetry"); - setState(obj.id, false, true); -}); - -// Location request -on({ id: new RegExp("^" + NODES.replace(/\./g, "\\.") + "\\..*\\.command\\.getLocation$"), change: "any" }, -obj => { - if (obj.state.ack || !obj.state.val) return; - const nodeId = obj.id.split(".")[4]; - cliAction(nodeId, "--request-position"); - setState(obj.id, false, true); -}); - -// ====================================================== -// 5. NODE POLLING (CLI --nodes) -// ====================================================== -function updateNodes() { - exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --nodes`, - function (error, result) { - if (!result || !result.includes("Connected")) return; - const nodes = parseData(result); - handleNodes(nodes); - } - ); -} - -function handleNodes(nodes) { - nodes.forEach(node => { - node.ID = node.ID.replace("!", ""); - if (!nodeIsKnown(node.ID)) { - createNode(node); - setTimeout(() => updateNode(node), 2000); - } else { - updateNode(node); - } - }); -} - -function nodeIsKnown(id) { - return !!getObject(`${NODES}.${id}`); -} - -function parseData(data) { - const lines = data.trim().split("\n"); - const headerIndex = lines.findIndex(l => l.includes("│") && l.includes("ID")); - if (headerIndex === -1) return []; - - const keys = lines[headerIndex] - .split("│") - .map(k => k.trim()) - .filter((k, i, arr) => i > 0 && i < arr.length - 1); - - return lines - .filter(l => l.includes("│") && !l.includes("═") && !l.includes("─") && !l.includes(" User ")) - .map(line => { - let values = line.split("│").map(v => v.trim()).slice(1, -1); - if (values.length < keys.length) return null; - let obj = {}; - keys.forEach((key, i) => obj[key] = values[i] || "N/A"); - return obj; - }) - .filter(x => x); -} - -// ====================================================== -// 6. NODE CREATION + STATES -// ====================================================== -function createNode(data) { - safeCreateObject(`${NODES}.${data.ID}`, { - type: "channel", - common: { name: data.User }, - native: {} - }); - - createNodeStates(data.ID); -} - -function createNodeStates(id) { - - safeCreateObject(`${NODES}.${id}.info`, { - type: "channel", - common: { name: "Info" }, - native: {} - }); - - safeCreateObject(`${NODES}.${id}.command`, { - type: "channel", - common: { name: "Command" }, - native: {} - }); - - // Info states - [ - ["user","User","string"], - ["alias","Alias","string"], - ["location","Location","string"], - ["latitude","Latitude","number"], - ["longitude","Longitude","number"], - ["altitude","Altitude","number"], - ["battery","Battery","number"], - ["lastMessage","Letzte Nachricht","string"] - ].forEach(s => { - safeCreateState(`${NODES}.${id}.info.${s[0]}`, { - name: s[1], type: s[2], read: true, write: false + safeCreateObject(id, { + type: "state", + common, + native: {} }); - }); - - // Command states - [ - ["sendMessage","Direktnachricht senden","string","text"], - ["sendPing","Ping senden","boolean","button"], - ["sendTraceRoute","Traceroute starten","boolean","button"], - ["getLocation","Standort anfordern","boolean","button"], - ["getTelemetry","Telemetrie anfordern","boolean","button"] - ].forEach(s => { - safeCreateState(`${NODES}.${id}.command.${s[0]}`, { - name: s[1], type: s[2], role: s[3], read: true, write: true - }); - }); + } } -// ====================================================== -// 7. UPDATE NODE VALUES -// ====================================================== -function updateNode(data) { +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} - const path = `${NODES}.${data.ID}.info.`; +// Robust senderHex conversion +function toSenderHex(fromField) { + let raw = String(fromField || "").replace(/^!/, ""); - safeSetState(path + "user", data.User || "N/A"); - safeSetState(path + "alias", data.AKA || "N/A"); - - let lat = parseNum(data.Latitude); - let lon = parseNum(data.Longitude); - - if (lat !== 0 && lon !== 0) { - safeSetState(path + "latitude", lat); - safeSetState(path + "longitude", lon); - safeSetState(path + "location", lat + "," + lon); + let hex; + if (/^\d+$/.test(raw)) { + hex = Number(raw).toString(16); + } else { + hex = raw.replace(/^0x/, ""); } - let battVal = data.Battery === "Powered" ? 100 : parseNum(data.Battery); - safeSetState(path + "battery", battVal); + return hex.toLowerCase().padStart(8, "0"); +} + +// Wrapper: meshtastic CLI call +function runMeshtastic(args, cb) { + execFile(MESHTASTIC_BIN, args, (err, stdout, stderr) => { + if (err) { + log("Meshtastic CLI error: " + (stderr || err), "error"); + } + if (cb) cb(err, stdout, stderr); + }); } // ====================================================== -// 8. CHAT STRUCTURE + HISTORY +// INIT STRUCTURE // ====================================================== function createChannels() { safeCreateObject(BASE, { type: "channel", common: { name: "Meshtastic" }, native: {} }); @@ -359,13 +88,15 @@ function createChannels() { function createChats() { chats.forEach(c => { - safeCreateObject(`${CHATS}.${c.id}`, { + const base = CHATS + "." + c.id; + + safeCreateObject(base, { type: "channel", common: { name: c.name }, native: {} }); - safeCreateState(`${CHATS}.${c.id}.lastMessage`, { + safeCreateState(base + ".lastMessage", { name: "Letzte Nachricht", type: "string", role: "text", @@ -373,7 +104,7 @@ function createChats() { write: false }); - safeCreateState(`${CHATS}.${c.id}.sendMessage`, { + safeCreateState(base + ".sendMessage", { name: "Nachricht senden", type: "string", role: "text", @@ -381,7 +112,7 @@ function createChats() { write: true }); - safeCreateState(`${CHATS}.${c.id}.history`, { + safeCreateState(base + ".history", { name: "Chat Historie JSON", type: "string", role: "json", @@ -389,7 +120,7 @@ function createChats() { write: false }); - safeCreateState(`${CHATS}.${c.id}.history_html`, { + safeCreateState(base + ".history_html", { name: "Chat Historie HTML", type: "string", role: "html", @@ -399,16 +130,21 @@ function createChats() { }); } +// ====================================================== +// HISTORY +// ====================================================== function addToHistory(channelIdx, senderName, messageText) { - const basePath = `${CHATS}.${channelIdx}`; - const historyPath = basePath + ".history"; - const htmlPath = basePath + ".history_html"; - const maxEntries = 10; + const base = CHATS + "." + channelIdx; + const historyPath = base + ".history"; + const htmlPath = base + ".history_html"; let history = []; if (existsState(historyPath)) { - try { history = JSON.parse(getState(historyPath).val) || []; } - catch { history = []; } + try { + history = JSON.parse(getState(historyPath).val) || []; + } catch { + history = []; + } } history.unshift({ @@ -418,34 +154,328 @@ function addToHistory(channelIdx, senderName, messageText) { text: messageText }); - if (history.length > maxEntries) history = history.slice(0, maxEntries); + if (history.length > HISTORY_MAX) { + history = history.slice(0, HISTORY_MAX); + } - safeSetState(historyPath, JSON.stringify(history)); + setState(historyPath, JSON.stringify(history), true); + + // HTML output (escaped) + let html = `
`; - let html = `
`; history.forEach(m => { - html += `
- ${m.from} • ${m.time}
${m.text}
`; + html += ` +
+
+ ${escapeHtml(m.from)} • ${escapeHtml(m.time)} +
+
+ ${escapeHtml(m.text)} +
+
`; }); - html += `
`; - safeSetState(htmlPath, html); + html += `
`; + setState(htmlPath, html, true); } // ====================================================== -// INIT +// MQTT TRIGGER (Realtime) +// ====================================================== +on({ id: new RegExp("^" + mqttPath.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"), change: "any" }, obj => { + try { + if (!obj.state.val) return; + + const msg = JSON.parse(obj.state.val); + + const channelIdx = parseInt(msg.channel) || 0; + const senderHex = toSenderHex(msg.from); + + const nodeBase = NODES + "." + senderHex; + const infoBase = nodeBase + ".info."; + + // Name resolution + let displayName = senderHex; + + if (existsState(infoBase + "alias")) { + const v = getState(infoBase + "alias").val; + if (v && v !== "N/A") displayName = v; + } else if (existsState(infoBase + "user")) { + const v = getState(infoBase + "user").val; + if (v && v !== "N/A") displayName = v; + } + + // TEXT + if (msg.type === "text" && msg.payload?.text) { + const text = msg.payload.text; + + safeCreateState(infoBase + "lastMessage", { + name: "Letzte Nachricht", + type: "string", + role: "text", + read: true, + write: false + }); + + setState(infoBase + "lastMessage", text, true); + + const chatBase = CHATS + "." + channelIdx; + if (existsObject(chatBase)) { + setState(chatBase + ".lastMessage", `${displayName}: ${text}`, true); + addToHistory(channelIdx, displayName, text); + } + + log(`Meshtastic Text [${channelIdx}] ${displayName}: ${text}`, "info"); + } + + // POSITION + if (msg.type === "position" && msg.payload) { + const p = msg.payload; + if (p.latitude_i && p.longitude_i) { + const lat = p.latitude_i / 10000000; + const lon = p.longitude_i / 10000000; + const alt = p.altitude || 0; + + setState(infoBase + "latitude", lat, true); + setState(infoBase + "longitude", lon, true); + setState(infoBase + "altitude", alt, true); + setState(infoBase + "location", `${lat},${lon}`, true); + + log(`Meshtastic Position ${displayName}: ${lat},${lon}`, "info"); + } + } + + } catch (e) { + log("MQTT Trigger Fehler: " + e, "error"); + } +}); + +// ====================================================== +// SEND MESSAGE TRIGGERS +// ====================================================== + +// Chat send +on({ id: /^0_userdata\.0\.Meshtastic\.Chats\.\d+\.sendMessage$/, change: "any" }, obj => { + if (obj.state.ack) return; + + const msg = obj.state.val; + if (!msg) return; + + const parts = obj.id.split("."); + const channelId = Number(parts[parts.length - 2]); + + runMeshtastic( + ["--host", deviceIp, "--ch-index", String(channelId), "--sendtext", msg], + err => { + if (!err) setTimeout(() => setState(obj.id, "", true), 300); + } + ); +}); + +// Direct send +on({ id: /^0_userdata\.0\.Meshtastic\.Nodes\..*\.command\.sendMessage$/, change: "any", ack: false }, obj => { + const msg = obj.state.val; + if (!msg) return; + + const parts = obj.id.split("."); + const nodeId = parts[parts.length - 3]; + + runMeshtastic( + ["--host", deviceIp, "--dest", "!" + nodeId, "--sendtext", msg], + err => { + if (!err) setTimeout(() => setState(obj.id, "", true), 300); + } + ); +}); + +// ====================================================== +// CLI NODE POLLING +// ====================================================== +function parseData(data) { + const lines = data.trim().split("\n"); + const headerIndex = lines.findIndex(l => l.includes("│") && l.includes("ID")); + if (headerIndex === -1) return []; + + const keys = lines[headerIndex] + .split("│") + .map(k => k.trim()) + .filter((_, i, arr) => i > 0 && i < arr.length - 1); + + return lines + .filter(l => l.includes("│") && !l.includes("═") && !l.includes("─")) + .map(line => { + const values = line.split("│").map(v => v.trim()).slice(1, -1); + if (values.length < keys.length) return null; + let obj = {}; + keys.forEach((k, i) => (obj[k] = values[i] || "N/A")); + return obj; + }) + .filter(Boolean); +} + +function nodeIsKnown(id) { + return existsObject(NODES + "." + id); +} + +function createNode(data) { + safeCreateObject(NODES + "." + data.ID, { + type: "channel", + common: { name: data.User }, + native: {} + }); + + createNodeStates(data.ID); +} + +function createNodeStates(id) { + const base = NODES + "." + id; + + safeCreateObject(base + ".info", { + type: "channel", + common: { name: "Info" }, + native: {} + }); + + safeCreateObject(base + ".command", { + type: "channel", + common: { name: "Command" }, + native: {} + }); + + // Info states + [ + ["alias", "Alias", "string", "text"], + ["user", "User", "string", "text"], + ["latitude", "Latitude", "number", "value.gps.latitude"], + ["longitude", "Longitude", "number", "value.gps.longitude"], + ["location", "Location", "string", "value.gps"], + ["altitude", "Altitude", "number", "value"], + ["battery", "Battery", "number", "value.battery"], + ["lastMessage", "Letzte Nachricht", "string", "text"] + ].forEach(s => { + safeCreateState(base + ".info." + s[0], { + name: s[1], + type: s[2], + role: s[3], + read: true, + write: false + }); + }); + + // Command states + [ + ["sendMessage", "Direktnachricht senden", "string", "text"], + ["sendPing", "Ping senden", "boolean", "button"], + ["sendTraceRoute", "Traceroute starten", "boolean", "button"], + ["getLocation", "Standort anfordern", "boolean", "button"], + ["getTelemetry", "Telemetrie anfordern", "boolean", "button"] + ].forEach(s => { + safeCreateState(base + ".command." + s[0], { + name: s[1], + type: s[2], + role: s[3], + read: true, + write: true + }); + }); +} + +function updateNode(data) { + function parseNum(val) { + if (!val || val === "N/A" || val === "Powered") return 0; + return parseFloat(String(val).replace(/[^\d.-]/g, "")) || 0; + } + + const base = NODES + "." + data.ID + ".info."; + + setState(base + "user", data.User || "N/A", true); + setState(base + "alias", data.AKA || "N/A", true); + + const lat = parseNum(data.Latitude); + const lon = parseNum(data.Longitude); + + if (lat !== 0 && lon !== 0) { + setState(base + "latitude", lat, true); + setState(base + "longitude", lon, true); + setState(base + "location", `${lat},${lon}`, true); + } + + let battVal = data.Battery === "Powered" ? 100 : parseNum(data.Battery); + setState(base + "battery", battVal, true); +} + +function updateNodes() { + runMeshtastic(["--host", deviceIp, "--nodes"], (err, stdout) => { + if (err || !stdout) return; + if (!stdout.includes("Connected")) return; + + const nodes = parseData(stdout); + + nodes.forEach(n => { + n.ID = String(n.ID).replace(/^!/, ""); + if (!nodeIsKnown(n.ID)) createNode(n); + updateNode(n); + }); + }); +} + +// ====================================================== +// CLI ACTIONS (ALL INCLUDED) +// ====================================================== +function requestTelemetry(target) { + runMeshtastic(["--host", deviceIp, "--request-telemetry", "--dest", "!" + target]); +} + +function startTraceroute(target) { + runMeshtastic(["--host", deviceIp, "--traceroute", "--dest", "!" + target]); +} + +function sendPing(target) { + runMeshtastic(["--host", deviceIp, "--sendping", "--dest", "!" + target]); +} + +function sendDirectMessage(target, message) { + runMeshtastic(["--host", deviceIp, "--dest", "!" + target, "--sendtext", message]); +} + +function sendChatMessage(chatId, message) { + runMeshtastic(["--host", deviceIp, "--ch-index", String(chatId), "--sendtext", message]); +} + +// ====================================================== +// COMMAND BUTTON LISTENERS +// ====================================================== +on({ id: /^0_userdata\.0\.Meshtastic\.Nodes\..*\.command\.(sendPing|sendTraceRoute|getLocation|getTelemetry)$/, change: "any" }, obj => { + if (obj.state.ack) return; + if (obj.state.val !== true) return; + + const parts = obj.id.split("."); + const nodeId = parts[4]; + const cmd = parts[6]; + + if (cmd === "sendPing") sendPing(nodeId); + if (cmd === "sendTraceRoute") startTraceroute(nodeId); + if (cmd === "getLocation" || cmd === "getTelemetry") requestTelemetry(nodeId); + + setTimeout(() => setState(obj.id, false, true), 300); +}); + +// ====================================================== +// STARTUP // ====================================================== createChannels(); createChats(); -setTimeout(() => { - log("Meshtastic: Initial Node Poll..."); - updateNodes(); -}, 2000); +log("Meshtastic: Initialisierung abgeschlossen.", "info"); -setInterval(() => { - log("Meshtastic: Scheduled Node Poll..."); - updateNodes(); -}, 300000); +setTimeout(() => updateNodes(), 2000); -log("Meshtastic: Script fully loaded (MQTT + CLI + Commands)."); +const pollInterval = setInterval(() => { + log("Meshtastic: Node Polling...", "info"); + updateNodes(); +}, POLL_INTERVAL); + +onStop(() => { + clearInterval(pollInterval); + log("Meshtastic: Script stopped, interval cleared.", "info"); +}, 1000);