13 Commits
r1.3 ... main

Author SHA1 Message Date
c1328
5cb66fe2f6 Update README with systemd service setup details
Added instructions for service configuration and monitoring.
2026-02-05 22:53:27 +01:00
c1328
34020a03e2 Document systemd service for Meshtastic Decryptor
Added systemd service setup instructions for Meshtastic Decryptor.
2026-02-05 22:50:59 +01:00
c1328
6062c2aff7 Implement channel database and argument parsing
Added channel database and command line argument handling for channel selection.
2026-02-05 22:44:52 +01:00
c1328
204be1227d Fix formatting and improve clarity in README.md 2026-02-05 22:22:58 +01:00
c1328
e0ad455bef Enhance MQTT JSON parser with logging and config updates
Refactor configuration and add logging for MQTT events.
2026-02-05 22:20:44 +01:00
c1328
2d9c055219 Document script for relaying encrypted DMs via Lora
Added a script for relaying encrypted direct messages over Lora and requested feedback on its reliability.
2026-02-05 22:10:13 +01:00
c1328
94b5fadbc1 Add MQTT bridge for Meshtastic downlink for DM 2026-02-05 22:04:04 +01:00
c1328
a778180bb1 Remove Python script example from README
Removed Python script example and related configuration details from README.
2026-02-05 17:30:55 +01:00
c1328
9861cb3f7e Refactor Meshtastic CLI integration and remove retries
Removed retry handling for CLI calls and updated node management logic.
2026-01-31 19:30:34 +01:00
c1328
d140dec06e Enhance Meshtastic ioBroker integration with retries
Updated comments and improved retry handling for CLI calls.
2026-01-31 19:06:32 +01:00
c1328
4a9af44647 Refactor parseData function for improved clarity 2026-01-30 23:08:43 +01:00
c1328
c2f857802e Update configuration variables in README.md 2026-01-30 22:46:24 +01:00
c1328
c00d5ba317 Refactor Meshtastic ioBroker integration for clarity 2026-01-30 22:45:24 +01:00
5 changed files with 803 additions and 525 deletions

150
README.md
View File

@@ -156,93 +156,6 @@ pip install meshtastic-mqtt-json
- Die restlichen restlichen Telemetriedaten sind nicht so zeitkritisch und können zyklisch über den meshtastic-cli geholt werden.
- Je Kanal der gelesen werden soll wird eine entsprechend konfigurierte Datei gebraucht. Beispiel: [mqtt-json-parse.py](https://github.com/c1328/meshtastic-cli-iobroker-mqtt/blob/main/mqtt-json-parse.py)
```python
import json
import time
import paho.mqtt.client as mqtt
from meshtastic_mqtt_json import MeshtasticMQTT
# --- KONFIGURATION ---
LOCAL_BROKER = "<ip-of-your-mosquitto-server"
LOCAL_PORT = 1883
LOCAL_USER = "<username>"
LOCAL_PASS = "<password>"
# Kanal-Details
CHANNEL_NAME = "<channel-name>"
CHANNEL_INDEX = <channel-id/number>
CHANNEL_KEY = "<channel-key>"
REGION = "EU_868"
ENCRYPTED_ROOT = f"msh/{REGION}/2/e/"
# --- SETUP LOKALER PUBLISHER ---
publisher = mqtt.Client()
publisher.username_pw_set(LOCAL_USER, LOCAL_PASS)
publisher.connect(LOCAL_BROKER, LOCAL_PORT)
publisher.loop_start()
def send_to_iobroker(sender_id_dec, msg_type, payload_data):
"""Baut das Meshtastic-JSON-Format nach und sendet es lokal."""
sender_id_hex = hex(sender_id_dec)[2:].lower().zfill(8)
# Ziel-Topic für den ioBroker JavaScript-Trigger
topic = f"msh/{REGION}/2/json/{CHANNEL_NAME}/!{sender_id_hex}"
full_payload = {
"from": sender_id_dec,
"channel": CHANNEL_INDEX,
"type": msg_type,
"payload": payload_data,
"timestamp": int(time.time())
}
publisher.publish(topic, json.dumps(full_payload), qos=1)
# --- CALLBACKS ---
def on_text_message(json_data):
"""Verarbeitet reine Textnachrichten."""
# json_data["decoded"]["payload"] enthält bei Textnachrichten den String
send_to_iobroker(json_data["from"], "text", {"text": json_data["decoded"]["payload"]})
print(f'Relayed Text: {json_data["decoded"]["payload"]}')
def on_position(json_data):
"""Verarbeitet GPS-Positionen."""
# json_data["decoded"]["payload"] enthält hier das Positions-Objekt
p = json_data["decoded"]["payload"]
# Payload-Struktur für dein ioBroker-Skript aufbauen
# Meshtastic nutzt oft latitude_i (Integer) statt Float für Präzision
pos_payload = {
"latitude_i": p.get("latitude_i"),
"longitude_i": p.get("longitude_i"),
"altitude": p.get("altitude")
}
send_to_iobroker(json_data["from"], "position", pos_payload)
print(f'Relayed Position Update from {json_data["from"]}')
# --- SETUP DECRYPTOR ---
decryptor = MeshtasticMQTT()
# Registrierung der gewünschten Callbacks
decryptor.register_callback('TEXT_MESSAGE_APP', on_text_message)
decryptor.register_callback('POSITION_APP', on_position)
# Authentifizierung am internen Client setzen
if hasattr(decryptor, '_client'):
decryptor._client.username_pw_set(LOCAL_USER, LOCAL_PASS)
# Verbindung zum lokalen Broker herstellen
decryptor.connect(
LOCAL_BROKER,
LOCAL_PORT,
ENCRYPTED_ROOT,
CHANNEL_NAME,
LOCAL_USER,
LOCAL_PASS,
CHANNEL_KEY
)
```
Das Script kann für den Test mit Screen im Hintergrund laufen lassen:
```bash
@@ -270,13 +183,21 @@ CTRL-A-D
Beispiel:
```text
// IP of your Meshtastic node
const deviceIp = "192.168.1.xxx";
// ======================================================
// CONFIG
// ======================================================
const deviceIp = "192.168.1.xxx";
const mqttPath = "mqtt.3.msh.*.json.*";
// MQTT path variable (note the 3 because I use the 3rd instance of mqtt at IoBroker)
const mqttPath = /^mqtt\.3\.msh\..*\.json\..*$/;
const MESHTASTIC_BIN = "/home/iobroker/.local/bin/meshtastic";
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 },
@@ -345,6 +266,51 @@ Das Skript arbeitet hybrid:
Dieses Setup optimiert die Erfassung von Gruppen-Kanälen und Positionsdaten. Direktnachrichten (DMs) werden aufgrund der Ende-zu-Ende-Verschlüsselung (PKC) bewusst nicht unterstützt. Für private Kommunikation im ioBroker empfiehlt sich die Nutzung eines separaten, privaten Kanals.
Prototypisch gibt es folgendes Script, welches verschlüsselte Direktnachrichten, die an Nodes aus der eigenen DB gerichtet sind und verschlüsselt über MQTT daherkommen, über Lora weiterleitet: https://github.com/c1328/meshtastic-cli-iobroker-mqtt/blob/main/mqtt-pki-downlink.py
Das Script sollte als systemd eingerichtet werden, dann schreibt es seinen Status in das MQTT topic ```service/PKIdownlink/#```
```bash
# Service Konfiguration anlegen
vi /etc/systemd/system/meshtastic-decryptor@.service
# mit folgendem Inhalt:
[Unit]
Description=Meshtastic Decryptor Service for Channel %I
After=network.target
[Service]
# %I (the part after @) is passed as an argument to the script
ExecStart=/usr/bin/python3 /home/meshtastic/mqtt-json-decryptor.py %I
WorkingDirectory=/home/meshtastic
User=meshtastic
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
```bash
# Systemd neu laden
systemctl daemon-reload
# Kanäle starten
systemctl start meshtastic-decryptor@Kanal0
systemctl start meshtastic-decryptor@Kanal1
systemctl start meshtastic-decryptor@Kanal2
# Autostart aktivieren
sudo systemctl enable meshtastic-decryptor@Kanal0
sudo systemctl enable meshtastic-decryptor@Kanal1
sudo systemctl enable meshtastic-decryptor@Kanal2
# Schauen ob alles läuft
journalctl -u "meshtastic-decryptor@*" -f
```
Hier ist gerne Rückmeldung erwünscht, ob das zuverlässig funktioniert.
---
## 📝 Lizenz

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 }
{ name: "Privat", id: 1 },
{ name: "Public", 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,375 @@ 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
// ======================================================
/**
* Parst die CLI-Ausgabe von meshtastic --nodes in ein Array von Node-Objekten
* @param {string} data - CLI-Ausgabe
* @returns {Array<Object>} nodes
*/
function parseData(data) {
const lines = data.trim().split('\n');
// Header-Zeile finden (ID | User | …)
const headerIndex = lines.findIndex(l => l.includes('│') && l.includes('ID'));
if (headerIndex === -1) return [];
// Header-Spalten extrahieren
const keys = lines[headerIndex]
.split('│')
.map(k => k.trim())
.filter((k, i) => i > 0 && i < lines[headerIndex].split('│').length - 1);
// Zeilen filtern und Nodes erzeugen
return lines
.filter(l =>
l.includes('│') &&
!l.includes('═') && // Tabellenrahmen ausschließen
!l.includes('─') &&
!l.includes(' ID ') && // Header ausschließen
!l.includes(' User ') // optional: User-Header ausschließen
)
.map(line => {
const values = line.split('│').map(v => v.trim()).slice(1, -1);
if (values.length < keys.length) return null;
let obj = {};
keys.forEach((key, index) => {
obj[key] = values[index] || "N/A";
});
// Node-ID normalisieren
if (obj.ID) obj.ID = normalizeNodeId(obj.ID);
// Nur gültige Node-IDs weitergeben
if (!isValidNodeId(obj.ID)) return null;
return obj;
})
.filter(obj => obj !== null);
}
/**
* Normalisiert eine Node-ID (CLI oder MQTT)
* @param {string} rawId
* @returns {string} normalizedId (8-stellig hex, lowercase)
*/
function normalizeNodeId(rawId) {
let id = String(rawId || "").replace(/^!/, "");
if (/^\d+$/.test(id)) id = Number(id).toString(16);
else id = id.replace(/^0x/, "");
return id.toLowerCase().padStart(8, "0");
}
/**
* Prüft, ob eine Node-ID gültig ist (8-stellige Hex)
* @param {string} id
* @returns {boolean}
*/
function isValidNodeId(id) {
return /^[0-9a-f]{8}$/.test(id);
}
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);

144
mqtt-json-decryptor.py Normal file
View File

@@ -0,0 +1,144 @@
import sys
import json
import time
import logging
import paho.mqtt.client as mqtt
from meshtastic_mqtt_json import MeshtasticMQTT
# --- CHANNEL DATABASE ---
# Add all your channels here
CHANNELS = {
"Puig": {"index": 0, "key": "BASE64_KEY_1"},
"Default": {"index": 1, "key": "BASE64_KEY_2"},
"Privat": {"index": 2, "key": "BASE64_KEY_3"}
}
# Get channel name from command line argument
if len(sys.argv) < 2 or sys.argv[1] not in CHANNELS:
print(f"Usage: python3 decryptor.py <channel_name>")
print(f"Available channels: {', '.join(CHANNELS.keys())}")
sys.exit(1)
CHANNEL_NAME = sys.argv[1]
CHANNEL_INDEX = CHANNELS[CHANNEL_NAME]["index"]
CHANNEL_KEY = CHANNELS[CHANNEL_NAME]["key"]
# --- REST OF CONFIGURATION ---
LOCAL_BROKER = "mqtt"
LOCAL_PORT = 1883
LOCAL_USER = "user"
LOCAL_PASS = "pass"
REGION = "EU_868"
ENCRYPTED_ROOT = f"msh/{REGION}/2/e/"
SERVICE_BASE_PATH = f"service/Decryptor/{CHANNEL_NAME}"
# --- LOGGING SETUP ---
# Format includes the channel name for better clarity in journalctl
logging.basicConfig(
level=logging.INFO,
format=f'%(asctime)s - [{CHANNEL_NAME}] - %(levelname)s - %(message)s'
)
# --- MQTT LOGGING HANDLER ---
# Forwards local log events to an MQTT topic for remote monitoring
class MQTTHandler(logging.Handler):
def emit(self, record):
try:
# Only attempt publish if the publisher client is connected
if 'publisher' in globals() and publisher.is_connected():
log_entry = self.format(record)
publisher.publish(f"{SERVICE_BASE_PATH}/log", log_entry)
except Exception:
pass
# --- LOCAL PUBLISHER SETUP ---
# Used for sending decrypted JSON data and service logs to ioBroker
publisher = mqtt.Client()
publisher.username_pw_set(LOCAL_USER, LOCAL_PASS)
# Attach the MQTT logger after the client object is created
logging.getLogger().addHandler(MQTTHandler())
def send_to_iobroker(sender_id_dec, msg_type, payload_data):
"""
Constructs a Meshtastic-compatible JSON structure and publishes it locally.
Target: msh/REGION/2/json/CHANNEL/!senderhex
"""
sender_id_hex = hex(sender_id_dec)[2:].lower().zfill(8)
topic = f"msh/{REGION}/2/json/{CHANNEL_NAME}/!{sender_id_hex}"
full_payload = {
"from": sender_id_dec,
"channel": CHANNEL_INDEX,
"type": msg_type,
"payload": payload_data,
"timestamp": int(time.time())
}
publisher.publish(topic, json.dumps(full_payload), qos=1)
# --- DECRYPTION CALLBACKS ---
def on_text_message(json_data):
"""Callback for incoming decrypted text messages."""
msg_text = json_data["decoded"]["payload"]
send_to_iobroker(json_data["from"], "text", {"text": msg_text})
logging.info(f"Relayed Text from !{hex(json_data['from'])[2:]}: {msg_text}")
def on_position(json_data):
"""Callback for incoming decrypted position updates."""
p = json_data["decoded"]["payload"]
pos_payload = {
"latitude_i": p.get("latitude_i"),
"longitude_i": p.get("longitude_i"),
"altitude": p.get("altitude")
}
send_to_iobroker(json_data["from"], "position", pos_payload)
logging.info(f"Relayed Position from !{hex(json_data['from'])[2:]}")
# --- DECRYPTOR INITIALIZATION ---
# Using MeshtasticMQTT to handle encrypted MQTT streams
decryptor = MeshtasticMQTT()
decryptor.register_callback('TEXT_MESSAGE_APP', on_text_message)
decryptor.register_callback('POSITION_APP', on_position)
# Configure authentication for the internal decryptor client
if hasattr(decryptor, '_client'):
decryptor._client.username_pw_set(LOCAL_USER, LOCAL_PASS)
# --- START SERVICE ---
try:
logging.info(f"Connecting to local broker at {LOCAL_BROKER}...")
publisher.connect(LOCAL_BROKER, LOCAL_PORT)
publisher.loop_start()
logging.info(f"Starting Decryptor service for channel '{CHANNEL_NAME}'...")
decryptor.connect(
LOCAL_BROKER, LOCAL_PORT, ENCRYPTED_ROOT,
CHANNEL_NAME, LOCAL_USER, LOCAL_PASS, CHANNEL_KEY
)
start_time = time.time()
last_heartbeat = 0
# --- MAIN MAINTENANCE LOOP ---
while True:
time.sleep(1)
# Publish health status every 60 seconds
if time.time() - last_heartbeat > 60:
status = {
"status": "online",
"channel": CHANNEL_NAME,
"uptime_seconds": int(time.time() - start_time),
"timestamp": time.strftime('%Y-%m-%d %H:%M:%S')
}
publisher.publish(f"{SERVICE_BASE_PATH}/status", json.dumps(status), retain=True)
last_heartbeat = time.time()
except KeyboardInterrupt:
logging.info("Service stopped by user.")
except Exception as e:
logging.error(f"Critical error in main loop: {e}")
finally:
# Clean shutdown of MQTT connections
publisher.loop_stop()
publisher.disconnect()

View File

@@ -1,84 +0,0 @@
import json
import time
import paho.mqtt.client as mqtt
from meshtastic_mqtt_json import MeshtasticMQTT
# --- KONFIGURATION ---
LOCAL_BROKER = "<ip-of-your-mosquitto-server"
LOCAL_PORT = 1883
LOCAL_USER = "<username>"
LOCAL_PASS = "<password>"
# Kanal-Details
CHANNEL_NAME = "<channel-name>"
CHANNEL_INDEX = <channel-id/numer>
CHANNEL_KEY = "<channel-key>"
REGION = "EU_868"
ENCRYPTED_ROOT = f"msh/{REGION}/2/e/"
# --- SETUP LOKALER PUBLISHER ---
publisher = mqtt.Client()
publisher.username_pw_set(LOCAL_USER, LOCAL_PASS)
publisher.connect(LOCAL_BROKER, LOCAL_PORT)
publisher.loop_start()
def send_to_iobroker(sender_id_dec, msg_type, payload_data):
"""Baut das Meshtastic-JSON-Format nach und sendet es lokal."""
sender_id_hex = hex(sender_id_dec)[2:].lower().zfill(8)
# Ziel-Topic für den ioBroker JavaScript-Trigger
topic = f"msh/{REGION}/2/json/{CHANNEL_NAME}/!{sender_id_hex}"
full_payload = {
"from": sender_id_dec,
"channel": CHANNEL_INDEX,
"type": msg_type,
"payload": payload_data,
"timestamp": int(time.time())
}
publisher.publish(topic, json.dumps(full_payload), qos=1)
# --- CALLBACKS ---
def on_text_message(json_data):
"""Verarbeitet reine Textnachrichten."""
# json_data["decoded"]["payload"] enthält bei Textnachrichten den String
send_to_iobroker(json_data["from"], "text", {"text": json_data["decoded"]["payload"]})
print(f'Relayed Text: {json_data["decoded"]["payload"]}')
def on_position(json_data):
"""Verarbeitet GPS-Positionen."""
# json_data["decoded"]["payload"] enthält hier das Positions-Objekt
p = json_data["decoded"]["payload"]
# Payload-Struktur für dein ioBroker-Skript aufbauen
# Meshtastic nutzt oft latitude_i (Integer) statt Float für Präzision
pos_payload = {
"latitude_i": p.get("latitude_i"),
"longitude_i": p.get("longitude_i"),
"altitude": p.get("altitude")
}
send_to_iobroker(json_data["from"], "position", pos_payload)
print(f'Relayed Position Update from {json_data["from"]}')
# --- SETUP DECRYPTOR ---
decryptor = MeshtasticMQTT()
# Registrierung der gewünschten Callbacks
decryptor.register_callback('TEXT_MESSAGE_APP', on_text_message)
decryptor.register_callback('POSITION_APP', on_position)
# Authentifizierung am internen Client setzen
if hasattr(decryptor, '_client'):
decryptor._client.username_pw_set(LOCAL_USER, LOCAL_PASS)
# Verbindung zum lokalen Broker herstellen
decryptor.connect(
LOCAL_BROKER,
LOCAL_PORT,
ENCRYPTED_ROOT,
CHANNEL_NAME,
LOCAL_USER,
LOCAL_PASS,
CHANNEL_KEY
)

175
mqtt-pki-downlink.py Normal file
View File

@@ -0,0 +1,175 @@
import paho.mqtt.client as mqtt
import meshtastic.tcp_interface
from meshtastic import portnums_pb2
import logging
import time
import sys
import threading
import os
import json
# --- 0. RECONNECT HOOK ---
# This hook ensures the script exits completely if a background thread crashes
# (e.g., BrokenPipeError). systemd will then trigger a clean restart.
def or_die(args):
logging.error(f"CRITICAL THREAD CRASH: {args.exc_value}")
os._exit(1)
threading.excepthook = or_die
# --- CONFIGURATION ---
MQTT_BROKER = "<IP or hostname>"
MQTT_USER = "<user>"
MQTT_PW = "<password>"
MQTT_TOPIC = "msh/EU_868/2/e/PKI/#"
NODE_IP = "<IP of your node>"
MAX_AGE_HOURS = 12 # Only serve nodes active within the last X hours
# --- MQTT LOGGING HANDLER ---
# Forwards Python logging records to an MQTT topic for remote monitoring in ioBroker.
class MQTTHandler(logging.Handler):
def emit(self, record):
try:
if 'client' in globals() and client.is_connected():
log_entry = self.format(record)
client.publish("service/PKIdownlink/log", log_entry)
except Exception:
pass # Avoid infinite loops if MQTT publish fails
# Logging Setup (Console)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# --- 1. MESHTASTIC INTERFACE INITIALIZATION ---
logging.info(f"Connecting to Meshtastic node at {NODE_IP}...")
try:
interface = meshtastic.tcp_interface.TCPInterface(hostname=NODE_IP)
time.sleep(5) # Wait for NodeDB synchronization
if interface.myInfo:
my_id = interface.myInfo.my_node_num
logging.info(f"TCP Connection established. My ID: !{my_id:08x}")
else:
logging.warning("Connected, but myInfo not loaded. Restarting...")
sys.exit(1)
except Exception as e:
logging.error(f"Failed to connect to Meshtastic node: {e}")
sys.exit(1)
# --- 2. INITIAL WIFI STATUS CHECK ---
if interface.nodes:
my_node = interface.nodes.get(interface.myInfo.my_node_num)
if my_node:
metrics = my_node.get("deviceMetrics", {})
rssi = metrics.get("rssi") or my_node.get("stats", {}).get("signalStrength")
if rssi:
logging.info(f"Gateway WiFi Signal (RSSI): {rssi} dBm")
if rssi < -80:
logging.warning("Weak WiFi signal! This may cause BrokenPipeErrors.")
else:
logging.info("WiFi RSSI not yet available in NodeDB.")
# --- 3. MQTT CALLBACKS ---
def on_connect(client, userdata, flags, rc):
if rc == 0:
logging.info("Connected successfully to MQTT broker.")
client.subscribe(MQTT_TOPIC)
else:
logging.error(f"MQTT login failed with code {rc}")
def on_message(client, userdata, msg):
global interface
try:
if not interface or interface.myInfo is None:
return
# Extract Destination ID from topic (e.g., .../PKI/!abcdefgh)
dest_id_hex = msg.topic.split('/')[-1].lower()
dest_id_int = int(dest_id_hex.replace('!', '0x'), 16)
nodes = interface.nodes or {}
num_known = len(nodes)
# Safety check for asynchronous NodeDB updates (if only local node is known)
if num_known <= 1:
time.sleep(0.5)
nodes = interface.nodes or {}
num_known = len(nodes)
# Ignore messages for ourself
if dest_id_int == interface.myInfo.my_node_num:
return
# Check if target node is in local NodeDB
if dest_id_int in nodes:
node_info = nodes[dest_id_int]
long_name = node_info.get("user", {}).get("longName", "Unknown")
last_heard = node_info.get("lastHeard", 0)
# Apply time filter (MAX_AGE_HOURS)
if (time.time() - last_heard) > (MAX_AGE_HOURS * 3600):
logging.info(f"RESULT: DISCARDED | Target: {dest_id_hex} ({long_name}) | Reason: Inactive | DB: {num_known}")
return
logging.info(f"RESULT: MATCH | Target: {dest_id_hex} ({long_name}) | DB: {num_known} | ACTION: LoRa TX")
# Inject message into LoRa Mesh
interface.sendData(
data=msg.payload,
destinationId=dest_id_int,
portNum=portnums_pb2.TEXT_MESSAGE_APP,
wantAck=False,
wantResponse=False
)
else:
logging.info(f"RESULT: DISCARDED | Target: {dest_id_hex} | Reason: Not in DB | DB: {num_known}")
except Exception as e:
logging.error(f"Error processing MQTT message: {e}")
# --- 4. MQTT CLIENT SETUP ---
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
client.username_pw_set(MQTT_USER, MQTT_PW)
client.on_connect = on_connect
client.on_message = on_message
# Attach MQTT Logger after client creation
logging.getLogger().addHandler(MQTTHandler())
# --- 5. MAIN LOOP ---
last_heartbeat = 0
start_time = time.time()
try:
logging.info("Starting MQTT Bridge Main Loop...")
client.connect(MQTT_BROKER, 1883, 60)
while True:
# Process MQTT events
client.loop(timeout=1.0)
# Publish Health Status every 60 seconds
if time.time() - last_heartbeat > 60:
status = {
"status": "online",
"db_size": len(interface.nodes or {}),
"uptime_script": int(time.time() - start_time)
}
client.publish("service/PKIdownlink/status", json.dumps(status), retain=True)
last_heartbeat = time.time()
# Monitor TCP Connection to Heltec Hardware
if not interface or interface.failure or not interface.isConnected:
logging.error("Critical Failure: Connection to Heltec lost!")
sys.exit(1) # Triggers systemd restart
except KeyboardInterrupt:
logging.info("Terminated by user.")
if interface:
interface.close()
sys.exit(0)
except Exception as e:
logging.error(f"Unexpected Error: {e}")
sys.exit(1)