mirror of
https://github.com/c1328/meshtastic-cli-iobroker-mqtt.git
synced 2026-03-28 17:42:42 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb66fe2f6 | ||
|
|
34020a03e2 | ||
|
|
6062c2aff7 | ||
|
|
204be1227d | ||
|
|
e0ad455bef | ||
|
|
2d9c055219 | ||
|
|
94b5fadbc1 | ||
|
|
a778180bb1 | ||
|
|
9861cb3f7e | ||
|
|
d140dec06e | ||
|
|
4a9af44647 | ||
|
|
c2f857802e | ||
|
|
c00d5ba317 |
150
README.md
150
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const path = `${NODES}.${data.ID}.info.`;
|
||||
// Robust senderHex conversion
|
||||
function toSenderHex(fromField) {
|
||||
let raw = String(fromField || "").replace(/^!/, "");
|
||||
|
||||
safeSetState(path + "user", data.User || "N/A");
|
||||
safeSetState(path + "alias", data.AKA || "N/A");
|
||||
|
||||
let lat = parseNum(data.Latitude);
|
||||
let lon = parseNum(data.Longitude);
|
||||
|
||||
if (lat !== 0 && lon !== 0) {
|
||||
safeSetState(path + "latitude", lat);
|
||||
safeSetState(path + "longitude", lon);
|
||||
safeSetState(path + "location", lat + "," + lon);
|
||||
let hex;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
hex = Number(raw).toString(16);
|
||||
} else {
|
||||
hex = raw.replace(/^0x/, "");
|
||||
}
|
||||
|
||||
let battVal = data.Battery === "Powered" ? 100 : parseNum(data.Battery);
|
||||
safeSetState(path + "battery", battVal);
|
||||
return hex.toLowerCase().padStart(8, "0");
|
||||
}
|
||||
|
||||
// Wrapper: meshtastic CLI call
|
||||
function runMeshtastic(args, cb) {
|
||||
execFile(MESHTASTIC_BIN, args, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
log("Meshtastic CLI error: " + (stderr || err), "error");
|
||||
}
|
||||
if (cb) cb(err, stdout, stderr);
|
||||
});
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 8. CHAT STRUCTURE + HISTORY
|
||||
// INIT STRUCTURE
|
||||
// ======================================================
|
||||
function createChannels() {
|
||||
safeCreateObject(BASE, { type: "channel", common: { name: "Meshtastic" }, native: {} });
|
||||
@@ -359,13 +88,15 @@ function createChannels() {
|
||||
|
||||
function createChats() {
|
||||
chats.forEach(c => {
|
||||
safeCreateObject(`${CHATS}.${c.id}`, {
|
||||
const base = CHATS + "." + c.id;
|
||||
|
||||
safeCreateObject(base, {
|
||||
type: "channel",
|
||||
common: { name: c.name },
|
||||
native: {}
|
||||
});
|
||||
|
||||
safeCreateState(`${CHATS}.${c.id}.lastMessage`, {
|
||||
safeCreateState(base + ".lastMessage", {
|
||||
name: "Letzte Nachricht",
|
||||
type: "string",
|
||||
role: "text",
|
||||
@@ -373,7 +104,7 @@ function createChats() {
|
||||
write: false
|
||||
});
|
||||
|
||||
safeCreateState(`${CHATS}.${c.id}.sendMessage`, {
|
||||
safeCreateState(base + ".sendMessage", {
|
||||
name: "Nachricht senden",
|
||||
type: "string",
|
||||
role: "text",
|
||||
@@ -381,7 +112,7 @@ function createChats() {
|
||||
write: true
|
||||
});
|
||||
|
||||
safeCreateState(`${CHATS}.${c.id}.history`, {
|
||||
safeCreateState(base + ".history", {
|
||||
name: "Chat Historie JSON",
|
||||
type: "string",
|
||||
role: "json",
|
||||
@@ -389,7 +120,7 @@ function createChats() {
|
||||
write: false
|
||||
});
|
||||
|
||||
safeCreateState(`${CHATS}.${c.id}.history_html`, {
|
||||
safeCreateState(base + ".history_html", {
|
||||
name: "Chat Historie HTML",
|
||||
type: "string",
|
||||
role: "html",
|
||||
@@ -399,16 +130,21 @@ function createChats() {
|
||||
});
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// HISTORY
|
||||
// ======================================================
|
||||
function addToHistory(channelIdx, senderName, messageText) {
|
||||
const basePath = `${CHATS}.${channelIdx}`;
|
||||
const historyPath = basePath + ".history";
|
||||
const htmlPath = basePath + ".history_html";
|
||||
const maxEntries = 10;
|
||||
const base = CHATS + "." + channelIdx;
|
||||
const historyPath = base + ".history";
|
||||
const htmlPath = base + ".history_html";
|
||||
|
||||
let history = [];
|
||||
if (existsState(historyPath)) {
|
||||
try { history = JSON.parse(getState(historyPath).val) || []; }
|
||||
catch { history = []; }
|
||||
try {
|
||||
history = JSON.parse(getState(historyPath).val) || [];
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
|
||||
history.unshift({
|
||||
@@ -418,34 +154,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
144
mqtt-json-decryptor.py
Normal 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()
|
||||
@@ -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
175
mqtt-pki-downlink.py
Normal 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)
|
||||
Reference in New Issue
Block a user