28 Commits
r1.0 ... 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
c1328
a1ef88cb45 Update channel names in README.md 2026-01-29 22:54:14 +01:00
c1328
7720ae1d58 Refactor Meshtastic ioBroker integration script 2026-01-29 22:53:42 +01:00
c1328
c9d6b742bb Update example configuration in README.md
update due to refactoring of JS
2026-01-29 22:51:56 +01:00
c1328
f38dd41f1b Add limitations section for direct messages in README
Added a section about limitations regarding direct messages due to end-to-end encryption and recommended using a separate private channel for private communication.
2026-01-29 22:25:54 +01:00
c1328
c96ddec88d Update README.md 2026-01-29 20:39:23 +01:00
c1328
7f7134101d Revise README with updated MQTT Parser info
Updated links and descriptions for MQTT Parser and Python script.
2026-01-29 20:30:16 +01:00
c1328
7b9ce76c9f Update README.md 2026-01-29 20:26:01 +01:00
c1328
21d81e3ca4 Add MQTT JSON parser for Meshtastic messages 2026-01-29 20:23:25 +01:00
c1328
f11faa8fc8 Refactor message handling and state updates
added history function für chats etc.
2026-01-29 20:10:06 +01:00
c1328
282d0038b0 Add Meshtastic MQTT Parser installation and script
Added installation instructions for Meshtastic MQTT Parser and provided a Python script for message decryption and handling.
2026-01-29 20:06:37 +01:00
c1328
3dc572ec84 Update meshcli_iobroker.js
bug fixes
2026-01-29 00:21:14 +01:00
c1328
00c118ab4a Update meshcli_iobroker.js
bugfixes
2026-01-29 00:08:46 +01:00
c1328
aa562b8ab6 Update meshcli_iobroker.js
history html object added
2026-01-28 23:48:16 +01:00
c1328
df58a950bd Update meshcli_iobroker.js
add history function
2026-01-28 23:34:45 +01:00
c1328
0b3c39b964 Update README.md 2026-01-28 20:51:33 +01:00
4 changed files with 867 additions and 279 deletions

119
README.md
View File

@@ -18,7 +18,7 @@ Ziel ist es, Meshtastic-Netzwerke in ioBroker sichtbar und steuerbar zu machen:
send TraceRoute, Ping, Message, u.v.m.
- **Echtzeit-Chat** via MQTT
Nachrichtenempfang (LoRa & MQTT) direkt in ioBroker-Datenpunkten
Nachrichtenempfang und Sendung (LoRa & MQTT) direkt in ioBroker-Datenpunkten
- **Multi-Kanal Support**
Primary/Secondary Channels werden unterstützt
@@ -34,6 +34,7 @@ Ziel ist es, Meshtastic-Netzwerke in ioBroker sichtbar und steuerbar zu machen:
- ioBroker mit JavaScript- und MQTT-Adapter -> Mein Szenario: ioBroker v7.7.22 mit JS adapter 9.0.11
- Python 3 + Meshtastic CLI -> Mein Szenario: Debian Bookworm mit Python 3.11 und meshtastic cli 2.7.7
- Mosquitto Broker als lokales MQTT-Gateway/Bridge -> Mein Szenario: Mosquitto 2.0.11 aus dem Standard Debian Repo
- Meshtastic MQTT Parser für Nachrichten die über einen öffentlichen MQTT kommen, denn die werden von der Node ja nicht nochmal als entschlüsseltes JSON zurückgegeben -> Mein Szenarion: https://github.com/acidvegas/meshtastic-mqtt-json R2.0.0 auf dem Server hinterlegt, auf dem auch die Mosquitto Bridge läuft
---
@@ -77,14 +78,19 @@ Mosquitto muss nach der Konfiguration natürlich neu gestartet werden.
systemctl restart mosquitto.service
```
MQTT + JSON aktivieren an der Node:
Auf der Node nun MQTT aktivieren + JSON deaktivieren:
```bash
meshtastic --host <IP-of-your-meshtastic-node> --set mqtt.enabled true
meshtastic --host <IP-of-your-meshtastic-node> --set mqtt.json_enabled true
```
Zusätzlich muss natürlich die soeben konfigurierte Mosquitto Bridge in der Node konfiguriert werden mit Adresse, Port, Username und Password.
Da der Meshtastic-MQTT-Parser uns perfekt formatierte Nachrichten und Positionsdaten liefert können wir JSON auf der Node deaktivieren - das spart auf der Node auch etwas Ressourcen
```bash
meshtastic --host <IP-of-your-meshtastic-node> --set mqtt.json_enabled false
```
Zusätzlich muss natürlich die soeben konfigurierte Mosquitto Bridge auf der Node konfiguriert werden mit Adresse, Port und ggf. Username und Password.
---
@@ -138,7 +144,29 @@ systemctl restart mosquitto.service
---
### 4. Konfigurieren der MQTT Instanz in ioBroker
### 4. Meshtastic MQTT Parser installieren
- Der Meshtastic MQTT Parser wird auf dem gleichen Server installiert, wo auch der zuvor installierte Mosquitto Server läuft. Hier das GitHub Repo: https://github.com/acidvegas/meshtastic-mqtt-json
```bash
pip install meshtastic-mqtt-json
```
- Wir brauchen ein kleines Python Script, welches uns die Nachrichten und Positionsdaten, die verschlüsselt über einen öffentlichen MQTT Server unter msh/EU_868/2/e/<channel>/ kommen, entschlüsselt und in der gleichen Form unter msh/EU_868/2/json/<channel> ablegt wie es eine Meshtastic Node tun würde.
- 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)
Das Script kann für den Test mit Screen im Hintergrund laufen lassen:
```bash
screen -S <channel-name>
python <scriptname.py>
CTRL-A-D
```
---
### 5. Konfigurieren der MQTT Instanz in ioBroker
- Eine MQTT Instanz im ioBroker muss auf unsere Mosquitto Bridge konfiguriert werden
- IP, Port, Username und Password müssen auf die Bridge zeigen
@@ -147,7 +175,7 @@ systemctl restart mosquitto.service
---
### 5. JS script in ioBroker anlegen und aktivieren
### 6. JS script in ioBroker anlegen und aktivieren
- Das Script meshcli_iobroker.js als JS in ioBroker anlegen und die Konfigurationen im oberen Abschnitt des Scripts an die eigenen Bedürfnisse anpassen
- Damit das Script Datenpunkte anlegen kann, muss "Enable command "setObject"" in der JS Instanz erlaubt werden
@@ -155,15 +183,25 @@ systemctl restart mosquitto.service
Beispiel:
```text
// Configuration of IP adress of meshtastic node that is connected via TCP
var deviceIp = '<IP-of-your-meshtastic-node>';
// Configuration of MQTT instance that is used (in my case 3rd instance)
var mqttPath = 'mqtt.3.msh.*.json.*';
// Configuration of channel names
var chats = [
{ name: 'Default', id: 0 },
{ name: '<private channel>', id: 1 },
{ name: '<public channel>', id: 2 }
// ======================================================
// CONFIG
// ======================================================
const deviceIp = "192.168.1.xxx";
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;
const chats = [
{ name: "Default", id: 0 },
{ name: "<private Channel>", id: 1 },
{ name: "<public Channel>", id: 2 }
];
```
@@ -220,7 +258,58 @@ Das Skript arbeitet hybrid:
** Integration: Senden und Empfangen beliebiger Nachrichten bzw. Steuermöglichkeit des ioBroker durch Nachrichten
** Visualisierung in VIS/Jarvis: Sowohl die Positionen als auch der Chatverlauf kann über die History bzw. History-HTML Datenpunkte leicht visualiert werden
---
## ❌ Einschränkung
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,125 +1,372 @@
// this script/idea is based on https://forum.iobroker.net/topic/73326/meshtastic/2
// Meshtastic ioBroker Integration Kit
// Meshtastic ioBroker Integration Kit (Full Script)
// ======================================================
// CONFIG
// ======================================================
const deviceIp = "192.168.1.xxx";
const mqttPath = "mqtt.3.msh.*.json.*";
// Configuration of IP adress of meshtastic node that is connected via TCP
var deviceIp = '<IP-of-your-meshtastic-node>';
// Configuration of MQTT instance that is used (in my case 3rd instance)
var mqttPath = 'mqtt.3.msh.*.json.*';
var chats = [
{ name: 'Default', id: 0 },
{ name: 'Puig', id: 1 },
{ name: 'Baleares', id: 2 }
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;
const chats = [
{ name: "Default", id: 0 },
{ name: "Privat", id: 1 },
{ name: "Public", id: 2 }
];
// Node.js execFile verfügbar
const { execFile } = require("child_process");
// ======================================================
// 1. MQTT TRIGGER (Realtime Chat + Name Resolution)
// HELPERS
// ======================================================
on({id: /^mqtt\.3\.msh\..*\.json\..*$/, change: "any"}, function (obj) {
function safeCreateObject(id, obj) {
if (!existsObject(id)) {
setObject(id, obj);
log("Created object: " + id, "info");
}
}
function safeCreateState(id, common) {
if (!existsObject(id)) {
safeCreateObject(id, {
type: "state",
common,
native: {}
});
}
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Robust senderHex conversion
function toSenderHex(fromField) {
let raw = String(fromField || "").replace(/^!/, "");
let hex;
if (/^\d+$/.test(raw)) {
hex = Number(raw).toString(16);
} else {
hex = raw.replace(/^0x/, "");
}
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);
});
}
// ======================================================
// INIT STRUCTURE
// ======================================================
function createChannels() {
safeCreateObject(BASE, { type: "channel", common: { name: "Meshtastic" }, native: {} });
safeCreateObject(NODES, { type: "channel", common: { name: "Nodes" }, native: {} });
safeCreateObject(CHATS, { type: "channel", common: { name: "Chats" }, native: {} });
}
function createChats() {
chats.forEach(c => {
const base = CHATS + "." + c.id;
safeCreateObject(base, {
type: "channel",
common: { name: c.name },
native: {}
});
safeCreateState(base + ".lastMessage", {
name: "Letzte Nachricht",
type: "string",
role: "text",
read: true,
write: false
});
safeCreateState(base + ".sendMessage", {
name: "Nachricht senden",
type: "string",
role: "text",
read: true,
write: true
});
safeCreateState(base + ".history", {
name: "Chat Historie JSON",
type: "string",
role: "json",
read: true,
write: false
});
safeCreateState(base + ".history_html", {
name: "Chat Historie HTML",
type: "string",
role: "html",
read: true,
write: false
});
});
}
// ======================================================
// HISTORY
// ======================================================
function addToHistory(channelIdx, senderName, messageText) {
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 = [];
}
}
history.unshift({
ts: Date.now(),
time: formatDate(new Date(), "hh:mm"),
from: senderName,
text: messageText
});
if (history.length > HISTORY_MAX) {
history = history.slice(0, HISTORY_MAX);
}
setState(historyPath, JSON.stringify(history), true);
// HTML output (escaped)
let html = `<div style="display:flex;flex-direction:column;gap:10px;font-family:sans-serif;">`;
history.forEach(m => {
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>`;
setState(htmlPath, html, true);
}
// ======================================================
// 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);
if (msg.payload && msg.payload.text) {
const text = msg.payload.text;
const channelIdx = msg.channel || 0;
const channelIdx = parseInt(msg.channel) || 0;
const senderHex = toSenderHex(msg.from);
let senderHex = parseInt(msg.from).toString(16).toLowerCase();
const nodeBasePath = '0_userdata.0.Meshtastic.Nodes.' + senderHex;
const nodeMsgPath = nodeBasePath + '.info.lastMessage';
const nodeBase = NODES + "." + senderHex;
const infoBase = nodeBase + ".info.";
// --- Name Resolution ---
let displayName = senderHex;
let aliasPath = nodeBasePath + '.info.alias';
let userPath = nodeBasePath + '.info.user';
// Name resolution
let displayName = senderHex;
if (existsState(aliasPath)) {
let aliasVal = getState(aliasPath).val;
if (aliasVal && aliasVal !== 'N/A') {
displayName = aliasVal;
} else if (existsState(userPath)) {
displayName = getState(userPath).val;
}
}
// Save in Node Info
if (getObject(nodeBasePath)) {
if (!existsState(nodeMsgPath)) {
setObject(nodeMsgPath, {
type: 'state',
common: { name: 'Letzte Nachricht', type: 'string', role: 'text', read: true, write: false },
native: {}
});
}
setState(nodeMsgPath, text, true);
}
// Save in Chat Channel
const chatMsgPath = '0_userdata.0.Meshtastic.Chats.' + channelIdx + '.lastMessage';
if (getObject('0_userdata.0.Meshtastic.Chats.' + channelIdx)) {
setState(chatMsgPath, `${displayName}: ${text}`, true);
}
log(`Meshtastic Chat [${channelIdx}]: ${displayName} sagt "${text}"`);
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("Fehler im MQTT Trigger: " + e, "error");
log("MQTT Trigger Fehler: " + e, "error");
}
});
// ======================================================
// 2. CLI Node Polling + Parsing
// SEND MESSAGE TRIGGERS
// ======================================================
function updateNodes() {
exec('/home/iobroker/.local/bin/meshtastic --host ' + deviceIp + ' --nodes', function (error, result, stderr) {
if (result && result.includes('Connected to radio')) {
var nodes = parseData(result);
handleNodes(nodes);
// 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);
}
function handleNodes(nodes) {
nodes.forEach(node => {
node.ID = node.ID.replace("!", "");
if (nodeIsKnown(node.ID)) {
updateNode(node);
} else {
createNode(node);
setTimeout(() => updateNode(node), 4000);
}
});
/**
* 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 !!getObject('0_userdata.0.Meshtastic.Nodes.' + id);
return existsObject(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, index) => { obj[key] = values[index] || "N/A"; });
return obj;
}).filter(obj => obj !== null);
}
// ======================================================
// 3. Node Creation + Info States + Command States
// ======================================================
function createNode(data) {
log("creating new node " + data.User, "info");
setObject('0_userdata.0.Meshtastic.Nodes.' + data.ID, {
type: 'channel',
safeCreateObject(NODES + "." + data.ID, {
type: "channel",
common: { name: data.User },
native: {}
});
@@ -128,221 +375,154 @@ function createNode(data) {
}
function createNodeStates(id) {
const base = NODES + "." + id;
// Info Channel
setObject('0_userdata.0.Meshtastic.Nodes.' + id + '.info', {
type: 'channel',
common: { name: 'Info' },
safeCreateObject(base + ".info", {
type: "channel",
common: { name: "Info" },
native: {}
});
// Info States
var nodeInfoStates = [
{id: 'id', name: 'NodeID', type: 'string'},
{id: 'user', name: 'User', type: 'string'},
{id: 'alias', name: 'Alias', type: 'string'},
{id: 'location', name: 'Location', type: 'string', role: 'value.gps'}, // for JARVIS map
{id: 'latitude', name: 'Latitude', type: 'number', role: 'value.gps.latitude'},
{id: 'longitude', name: 'Longitude', type: 'number', role: 'value.gps.longitude'},
{id: 'altitude', name: 'Altitude', type: 'number', unit: 'm'},
{id: 'chanUtil', name: 'Channel util.', type: 'number', unit: '%'},
{id: 'txAir', name: 'Tx air util.', type: 'number', unit: '%'},
{id: 'snr', name: 'SNR', type: 'number', unit: 'dB'},
{id: 'channel', name: 'Channel', type: 'string'},
{id: 'lastHeard', name: 'Last heard', type: 'string'},
{id: 'battery', name: 'Battery', type: 'number', unit: '%'},
{id: 'lastMessage', name: 'Letzte Nachricht', type: 'string'}
];
safeCreateObject(base + ".command", {
type: "channel",
common: { name: "Command" },
native: {}
});
nodeInfoStates.forEach(state => {
setObject('0_userdata.0.Meshtastic.Nodes.' + id + '.info.' + state.id, {
type: 'state',
common: {
name: state.name,
type: state.type === 'string' ? 'string' :
state.type === 'number' ? 'number' :
state.type === 'boolean' ? 'boolean' : 'string', // cast TS
role: state.role || 'value',
unit: state.unit || null,
read: true,
write: false,
def: state.type === 'number' ? 0 : ''
},
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 Channel
setObject('0_userdata.0.Meshtastic.Nodes.' + id + '.command', {
type: 'channel',
common: { name: 'Command' },
native: {}
});
// Command States
var nodeCommandStates = [
{id: 'sendMessage', name: 'Direktnachricht senden', type: 'string', role: 'value'},
{id: 'sendPing', name: 'Ping senden', type: 'boolean', role: 'button'},
{id: 'sendTraceRoute', name: 'Traceroute starten', type: 'boolean', role: 'button'},
{id: 'getLocation', name: 'Standort anfordern', type: 'boolean', role: 'button'},
{id: 'getTelemetry', name: 'Telemetrie anfordern', type: 'boolean', role: 'button'}
];
nodeCommandStates.forEach(state => {
setObject('0_userdata.0.Meshtastic.Nodes.' + id + '.command.' + state.id, {
type: 'state',
common: {
name: state.name,
type: state.type === 'string' ? 'string' :
state.type === 'number' ? 'number' :
state.type === 'boolean' ? 'boolean' : 'string',
role: state.role,
read: true,
write: true,
def: state.type === 'number' ? 0 : ''
},
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(base + ".command." + s[0], {
name: s[1],
type: s[2],
role: s[3],
read: true,
write: true
});
});
}
// ======================================================
// 4. Update Node Values (Location + Lat/Lon separated)
// ======================================================
function updateNode(data) {
function parseNum(val) {
if (!val || val === "N/A") return 0;
return parseFloat(val.replace(/[^\d.-]/g, "")) || 0;
if (!val || val === "N/A" || val === "Powered") return 0;
return parseFloat(String(val).replace(/[^\d.-]/g, "")) || 0;
}
const path = '0_userdata.0.Meshtastic.Nodes.' + data.ID + '.info.';
const base = NODES + "." + data.ID + ".info.";
setState(path + "id", data.N, true);
setState(path + "user", data.User, true);
setState(path + "alias", data.AKA, true);
setState(base + "user", data.User || "N/A", true);
setState(base + "alias", data.AKA || "N/A", true);
let lat = parseNum(data.Latitude);
let lon = parseNum(data.Longitude);
const lat = parseNum(data.Latitude);
const lon = parseNum(data.Longitude);
setState(path + "latitude", lat, true);
setState(path + "longitude", lon, true);
setState(path + "location", lat + ", " + lon, true);
if (lat !== 0 && lon !== 0) {
setState(base + "latitude", lat, true);
setState(base + "longitude", lon, true);
setState(base + "location", `${lat},${lon}`, true);
}
setState(path + "altitude", parseNum(data.Altitude), true);
setState(path + "chanUtil", parseNum(data["Channel util."]), true);
setState(path + "txAir", parseNum(data["Tx air util."]), true);
setState(path + "snr", parseNum(data.SNR), true);
setState(path + "channel", data.Channel, true);
setState(path + "lastHeard", data.LastHeard, true);
setState(path + "battery", parseNum(data.Battery), true);
let battVal = data.Battery === "Powered" ? 100 : parseNum(data.Battery);
setState(base + "battery", battVal, true);
}
// ======================================================
// 5. Chats + SendMessage Endpoint
// ======================================================
function createChannels() {
setObject('0_userdata.0.Meshtastic', { type: 'channel', common: { name: 'Meshtastic Server' }, native: {} });
setObject('0_userdata.0.Meshtastic.Nodes', { type: 'channel', common: { name: 'Nodes' }, native: {} });
setObject('0_userdata.0.Meshtastic.Chats', { type: 'channel', common: { name: 'Chats' }, native: {} });
}
function updateNodes() {
runMeshtastic(["--host", deviceIp, "--nodes"], (err, stdout) => {
if (err || !stdout) return;
if (!stdout.includes("Connected")) return;
function createChats() {
chats.forEach(chatObj => {
// create channels
setObject('0_userdata.0.Meshtastic.Chats.' + chatObj.id, {
type: 'channel',
common: {
name: chatObj.name
},
native: {}
});
const nodes = parseData(stdout);
// last message (read-only)
setObject('0_userdata.0.Meshtastic.Chats.' + chatObj.id + '.lastMessage', {
type: 'state',
common: {
name: 'Letzte Nachricht',
type: 'string',
role: 'text',
read: true,
write: false
},
native: {}
});
// send message (allow writing)
setObject('0_userdata.0.Meshtastic.Chats.' + chatObj.id + '.sendMessage', {
type: 'state',
common: {
name: 'Nachricht senden',
type: 'string',
role: 'text',
read: true,
write: true
},
native: {}
nodes.forEach(n => {
n.ID = String(n.ID).replace(/^!/, "");
if (!nodeIsKnown(n.ID)) createNode(n);
updateNode(n);
});
});
}
// ======================================================
// 6. Command Listener + CLI Actions
// CLI ACTIONS (ALL INCLUDED)
// ======================================================
function registerEndpointListeners() {
$('state[id=0_userdata.0.Meshtastic.Nodes.*.command.*]').each(function (id) {
on({id: id, change: "any"}, function (obj) {
let parts = obj.id.split(".");
let nodeId = parts[4];
let cmd = parts[6];
if (cmd === "getTelemetry" || cmd === "getLocation") requestTelemetry(nodeId);
if (cmd === "sendPing") sendPing(nodeId);
if (cmd === "sendTraceRoute") startTraceroute(nodeId);
if (cmd === "sendMessage") {
let msg = getState(obj.id).val;
sendDirectMessage(nodeId, msg);
}
});
});
$('state[id=0_userdata.0.Meshtastic.Chats.*.sendMessage]').each(function (id) {
on({id: id, change: "any"}, function (obj) {
let parts = obj.id.split(".");
let chatId = parts[4];
let msg = getState(obj.id).val;
sendChatMessage(chatId, msg);
});
});
}
// --- CLI Actions ---
function requestTelemetry(target) {
exec('/home/iobroker/.local/bin/meshtastic --host '+deviceIp+' --request-telemetry --dest "!'+target+'"');
runMeshtastic(["--host", deviceIp, "--request-telemetry", "--dest", "!" + target]);
}
function startTraceroute(target) {
exec('/home/iobroker/.local/bin/meshtastic --host '+deviceIp+' --traceroute --dest "!'+target+'"');
runMeshtastic(["--host", deviceIp, "--traceroute", "--dest", "!" + target]);
}
function sendPing(target) {
exec('/home/iobroker/.local/bin/meshtastic --host '+deviceIp+' --sendping --dest "!'+target+'"');
runMeshtastic(["--host", deviceIp, "--sendping", "--dest", "!" + target]);
}
function sendDirectMessage(target, message) {
exec('/home/iobroker/.local/bin/meshtastic --host '+deviceIp+' --dest "!'+target+'" --sendtext "'+message+'"');
runMeshtastic(["--host", deviceIp, "--dest", "!" + target, "--sendtext", message]);
}
function sendChatMessage(chatId, message) {
exec('/home/iobroker/.local/bin/meshtastic --host '+deviceIp+' --ch-index '+chatId+' --sendtext "'+message+'"');
runMeshtastic(["--host", deviceIp, "--ch-index", String(chatId), "--sendtext", message]);
}
// ======================================================
// INIT
// 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();
registerEndpointListeners();
updateNodes();
setInterval(updateNodes, 300000);
log("Meshtastic: Initialisierung abgeschlossen.", "info");
setTimeout(() => updateNodes(), 2000);
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()

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)