Refactor Meshtastic ioBroker integration for clarity

This commit is contained in:
c1328
2026-01-30 22:45:24 +01:00
committed by GitHub
parent a1ef88cb45
commit c00d5ba317
+377 -347
View File
@@ -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: "<private Channel>", id: 1 },
{ name: "<public Channel>", 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 = `<div style="display:flex;flex-direction:column;gap:10px;font-family:sans-serif;">`;
let html = `<div style="display:flex;flex-direction:column;gap:8px;">`;
history.forEach(m => {
html += `<div style="padding:6px 10px;border-radius:10px;background:rgba(128,128,128,0.1);">
<b>${m.from}</b> • ${m.time}<br>${m.text}</div>`;
html += `
<div style="background:rgba(128,128,128,0.1);padding:8px 12px;border-radius:12px;max-width:95%;">
<div style="font-size:0.75em;opacity:0.6;margin-bottom:4px;">
<b>${escapeHtml(m.from)}</b> • ${escapeHtml(m.time)}
</div>
<div style="font-size:0.95em;line-height:1.3;">
${escapeHtml(m.text)}
</div>
</div>`;
});
html += `</div>`;
safeSetState(htmlPath, html);
html += `</div>`;
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);