3 Commits
r1.1 ... r1.2

Author SHA1 Message Date
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
2 changed files with 243 additions and 64 deletions

116
README.md
View File

@@ -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 aus dem Server hinterlegt, auf dem auch die Mosquitto Bridge läuft
---
@@ -138,7 +139,116 @@ 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.
```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/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
)
```
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 +257,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
@@ -220,6 +330,8 @@ 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
---

View File

@@ -22,30 +22,30 @@ on({id: /^mqtt\.3\.msh\..*\.json\..*$/, change: "any"}, function (obj) {
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;
// Basis-Daten extrahieren
const channelIdx = parseInt(msg.channel) || 0;
// ID generieren: Dezimal zu Hex, Kleinbuchstaben, ohne !, aufgefüllt auf 8 Stellen
let senderHex = parseInt(msg.from).toString(16).toLowerCase().replace('!', '').padStart(8, '0');
const nodeBasePath = '0_userdata.0.Meshtastic.Nodes.' + senderHex;
let senderHex = parseInt(msg.from).toString(16).toLowerCase();
const nodeBasePath = '0_userdata.0.Meshtastic.Nodes.' + senderHex;
// --- Namensauflösung ---
let displayName = senderHex;
if (existsState(nodeBasePath + '.info.alias')) {
let aliasVal = getState(nodeBasePath + '.info.alias').val;
if (aliasVal && aliasVal !== 'N/A' && aliasVal !== '') displayName = aliasVal;
} else if (existsState(nodeBasePath + '.info.user')) {
let userVal = getState(nodeBasePath + '.info.user').val;
if (userVal && userVal !== 'N/A' && userVal !== '') displayName = userVal;
}
// --- FALL 1: TEXTNACHRICHTEN ---
if (msg.type === "text" && msg.payload && msg.payload.text) {
const text = msg.payload.text;
const nodeMsgPath = nodeBasePath + '.info.lastMessage';
// --- Name Resolution ---
let displayName = senderHex;
let aliasPath = nodeBasePath + '.info.alias';
let userPath = nodeBasePath + '.info.user';
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)) {
// In Node Info speichern
if (existsObject(nodeBasePath)) {
if (!existsState(nodeMsgPath)) {
setObject(nodeMsgPath, {
type: 'state',
@@ -56,65 +56,96 @@ on({id: /^mqtt\.3\.msh\..*\.json\..*$/, change: "any"}, function (obj) {
setState(nodeMsgPath, text, true);
}
// Save in Chat Channel
// In Chat & Historie speichern
const chatPath = '0_userdata.0.Meshtastic.Chats.' + channelIdx;
if (getObject(chatPath)) {
if (existsObject(chatPath)) {
setState(chatPath + '.lastMessage', `${displayName}: ${text}`, true);
// --- add to history ---
addToHistory(channelIdx, displayName, text);
} else {
log(`Chat-Kanal ${channelIdx} nicht gefunden unter ${chatPath}`, "warn");
}
log(`Meshtastic Text: [Kanal ${channelIdx}] ${displayName}: ${text}`);
}
log(`Meshtastic Chat [${channelIdx}]: ${displayName} sagt "${text}"`);
// --- FALL 2: POSITIONSDATEN ---
else if (msg.type === "position" && msg.payload) {
const p = msg.payload;
const infoPath = nodeBasePath + '.info.';
if (p.latitude_i && p.longitude_i) {
// Umrechnung von Meshtastic Integer zu Float
const lat = p.latitude_i / 10000000;
const lon = p.longitude_i / 10000000;
const alt = p.altitude || 0;
if (existsObject(nodeBasePath)) {
// Einzelne Datenpunkte setzen
setState(infoPath + 'latitude', lat, true);
setState(infoPath + 'longitude', lon, true);
setState(infoPath + 'altitude', alt, true);
// Kombiniertes Location-Feld für Jarvis/Maps (lat,lon)
setState(infoPath + 'location', `${lat},${lon}`, true);
log(`Meshtastic Position: ${displayName} ist bei ${lat}, ${lon}`);
}
}
}
} catch (e) {
log("Fehler im MQTT Trigger: " + e, "error");
}
});
// Trigger für Nachrichten an Kanäle (Chats)
on({id: /^0_userdata\.0\.Meshtastic\.Chats\.\d+\.sendMessage$/, change: "any", ack: false}, function (obj) {
on({id: /^0_userdata\.0\.Meshtastic\.Chats\.\d+\.sendMessage$/, change: "any"}, function (obj) {
const msg = obj.state.val;
if (!msg || msg === "") return;
if (!msg || msg === "" || obj.state.ack === true) return;
const parts = obj.id.split('.');
// parseInt stellt sicher, dass channelId eine Nummer ist
const channelId = parseInt(parts[parts.length - 2]);
log(`Meshtastic: Sende Nachricht an Kanal ${channelId}: ${msg}`);
// Log-Fix: Falls obj.from undefined ist, nutzen wir einen Fallback
const source = obj.from || "Skript/System";
// Sonderzeichen-Schutz: Wir entfernen einfache Anführungszeichen aus der Nachricht,
// um den Shell-Befehl nicht zu brechen.
const safeMsg = msg.replace(/'/g, "");
// In der CLI-Exec wird channelId wieder zum String für den Befehl,
// aber für addToHistory ist es nun die korrekte Nummer.
exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --ch-index ${channelId} --sendtext "${msg}"`, function (error, result, stderr) {
log(`Meshtastic: Sende an Kanal ${channelId} (von ${source}): ${safeMsg}`);
// Wir umschließen die Nachricht in EINFACHE Anführungszeichen (') für die Shell
const command = `/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --ch-index ${channelId} --sendtext '${safeMsg}'`;
exec(command, function (error, stdout, stderr) {
if (error) {
log(`Fehler beim Senden: ${stderr}`, 'error');
log(`Fehler beim Senden (Kanal ${channelId}): ${stderr || error}`, 'error');
} else {
setState(obj.id, "", true);
// Jetzt passt der Typ für den ersten Parameter (number)
addToHistory(channelId, "ICH (ioBroker)", msg);
// Feld leeren nach Erfolg
setTimeout(() => setState(obj.id, "", true), 500);
}
});
});
// Trigger für Direktnachrichten an einzelne Nodes
// 2. Trigger für Direktnachrichten (Nodes)
on({id: /^0_userdata\.0\.Meshtastic\.Nodes\..*\.command\.sendMessage$/, change: "any", ack: false}, function (obj) {
const msg = obj.state.val;
if (!msg || msg === "") return;
const parts = obj.id.split('.');
const nodeId = parts[parts.length - 3]; // Pfad ist Nodes.ID.command.sendMessage
log(`Meshtastic: Sende Direktnachricht an !${nodeId}: ${msg}`);
const nodeId = parts[parts.length - 3];
const safeMsg = msg.replace(/"/g, '\\"');
exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --dest "!${nodeId}" --sendtext "${msg}"`, function (error, result, stderr) {
if (error) {
log(`Fehler beim Senden an Node: ${stderr}`, 'error');
} else {
setState(obj.id, "", true);
log(`Meshtastic: Sende Direktnachricht an !${nodeId}: ${safeMsg}`);
exec(`/home/iobroker/.local/bin/meshtastic --host ${deviceIp} --dest "!${nodeId}" --sendtext "${safeMsg}"`, function (error) {
if (!error) {
setTimeout(() => setState(obj.id, "", true), 500);
}
});
});
// ======================================================
// 2. CLI Node Polling + Parsing
// ======================================================
@@ -258,30 +289,41 @@ function createNodeStates(id) {
function updateNode(data) {
function parseNum(val) {
if (!val || val === "N/A") return 0;
if (!val || val === "N/A" || val === "Powered") return 0;
// Entfernt Einheiten wie %, m, dB und konvertiert zu Number
return parseFloat(val.replace(/[^\d.-]/g, "")) || 0;
}
const path = '0_userdata.0.Meshtastic.Nodes.' + data.ID + '.info.';
setState(path + "id", data.N, true);
setState(path + "user", data.User, true);
setState(path + "alias", data.AKA, true);
// Basis-Informationen
setState(path + "id", data.N || "N/A", true);
setState(path + "user", data.User || "N/A", true);
setState(path + "alias", data.AKA || "N/A", true);
// Standort-Logik
let lat = parseNum(data.Latitude);
let lon = parseNum(data.Longitude);
setState(path + "latitude", lat, true);
setState(path + "longitude", lon, true);
setState(path + "location", lat + ", " + lon, true);
// Nur aktualisieren, wenn echte Koordinaten geliefert werden (verhindert 0,0 Sprünge)
if (lat !== 0 && lon !== 0) {
setState(path + "latitude", lat, true);
setState(path + "longitude", lon, true);
// Format lat,lon (ohne Leerzeichen) für Jarvis Maps
setState(path + "location", lat + "," + lon, true);
}
// Hardware- & Netzparameter
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);
setState(path + "channel", data.Channel || "0", true);
setState(path + "lastHeard", data.LastHeard || "N/A", true);
// Batterie-Sonderbehandlung für "Powered"
let battVal = data.Battery === 'Powered' ? 100 : parseNum(data.Battery);
setState(path + "battery", battVal, true);
}
// ======================================================
@@ -342,6 +384,19 @@ function createChats() {
},
native: {}
});
// message history as JSON html (read-only)
setObject('0_userdata.0.Meshtastic.Chats.' + chatObj.id + '.history_html', {
type: 'state',
common: {
name: 'Chat Historie HTML',
type: 'string',
role: 'html',
read: true,
write: false
},
native: {}
});
});
}
@@ -355,7 +410,7 @@ function addToHistory(channelIdx, senderName, messageText) {
const basePath = '0_userdata.0.Meshtastic.Chats.' + channelIdx;
const historyPath = basePath + '.history';
const htmlPath = basePath + '.history_html';
const maxEntries = 20; // Anzahl der gespeicherten Nachrichten
const maxEntries = 10; // Anzahl der gespeicherten Nachrichten
// --- 1. JSON HISTORY (für Jarvis JsonTable) ---
let history = [];
@@ -369,7 +424,7 @@ function addToHistory(channelIdx, senderName, messageText) {
// Neues Objekt erstellen
const newEntry = {
ts: Date.now(),
time: formatDate(new Date(), "HH:mm"),
time: formatDate(new Date(), "hh:mm"),
from: senderName,
text: messageText
};
@@ -463,11 +518,23 @@ function sendChatMessage(chatId, message) {
}
// ======================================================
// INIT
// 6. INITIALISIERUNG & START
// ======================================================
// Erstellt die Grundstruktur (nur beim ersten Start relevant)
createChannels();
createChats();
registerEndpointListeners();
updateNodes();
setInterval(updateNodes, 300000);
// Erster Abruf der Nodes beim Skriptstart
setTimeout(function() {
log("Meshtastic: Initialer Node-Abruf gestartet...");
updateNodes();
}, 2000);
// Regelmäßiges Polling der Node-Liste (alle 5 Minuten)
setInterval(function() {
log("Meshtastic: Geplanter Node-Abruf läuft...");
updateNodes();
}, 300000);
log("Meshtastic: Skript erfolgreich initialisiert. MQTT-Trigger aktiv.");