added receiver plugin

This commit is contained in:
Oliver Walter
2026-07-01 01:22:45 +02:00
parent a5eec74524
commit ddbbd27dbe
9 changed files with 475 additions and 1 deletions
+7 -1
View File
@@ -6,11 +6,17 @@ A small Home Assistant add-on repository.
- **Meshtastic Sensor Broadcaster** — periodically broadcasts selected Home - **Meshtastic Sensor Broadcaster** — periodically broadcasts selected Home
Assistant sensor values over a Meshtastic mesh (TCP or USB-connected node). Assistant sensor values over a Meshtastic mesh (TCP or USB-connected node).
- **Meshtastic Sensor Receiver** — subscribes to a Meshtastic channel over MQTT,
decrypts it, and exposes the latest message plus parsed temperature/humidity
values as Home Assistant sensors (via MQTT Discovery).
Together they form a round trip: the broadcaster sends `🏠 T1:.. H1:..` messages
from one site, and the receiver turns them back into sensors at another.
## Install ## Install
Add-on Store -> ⋮ (top right) -> **Repositories** -> paste this repo's URL. Add-on Store -> ⋮ (top right) -> **Repositories** -> paste this repo's URL.
Then install **Meshtastic Sensor Broadcaster** and configure it. Then install the add-on you want and configure it.
Or, for local development, copy the `meshtastic_broadcaster/` folder into the Or, for local development, copy the `meshtastic_broadcaster/` folder into the
`/addons` share and use **Check for updates** in the Add-on Store. `/addons` share and use **Check for updates** in the Add-on Store.
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 1.0.0
- Initial release.
- Subscribes to a Meshtastic channel over MQTT (public server or local broker).
- Decrypts channel packets (AES-CTR) using the channel PSK.
- Exposes the latest text message as a sensor (with from/to/time attributes).
- Parses `LABEL:VALUE` messages into individual temperature/humidity sensors.
- Auto-creates entities via MQTT Discovery; resolves the HA broker through the
Supervisor (no manual credentials required).
+58
View File
@@ -0,0 +1,58 @@
# Meshtastic Sensor Receiver
Subscribes to a Meshtastic channel over MQTT, decrypts the packets, and exposes
the results in Home Assistant via MQTT Discovery:
- **Last Message** — the most recent text message on the channel (with `from`,
`to`, and timestamp as attributes).
- **Parsed sensors** — values pulled out of structured messages such as
`🏠 T1:20.8°C T2:26.7°C H1:83.7%` become individual numeric sensors.
This is the receiving counterpart to the **Meshtastic Sensor Broadcaster** add-on.
## Requirements
An MQTT broker configured in Home Assistant (e.g. the **Mosquitto broker**
add-on). The add-on discovers it automatically via the Supervisor — no manual
credentials needed for the HA side.
## Options
| Option | Description |
|--------|-------------|
| `source_broker` | MQTT broker the mesh packets arrive on. `mqtt.meshtastic.org` for the public server, or your local broker. |
| `source_port` | Broker port. `1883` plain, `8883` TLS. |
| `source_username` / `source_password` | Source broker credentials. Public server uses `meshdev` / `large4cats`. |
| `source_tls` | `true` when using port 8883. |
| `region` | Region code in the topic, e.g. `EU_868`, `US`. Use `+` to match any region. |
| `channel_name` | Exact (case-sensitive) channel name, e.g. `DET.Baulog`. |
| `channel_psk` | Base64 channel key from the Meshtastic app. `AQ==` is the default public key. |
| `match_prefix` | Only messages containing this string are parsed into sensors. Leave empty to parse every message. The Last Message sensor always updates. |
| `device_name` | Name of the HA device that groups the created entities. |
| `discovery_prefix` | MQTT Discovery prefix (default `homeassistant`). |
| `sensors` | List of `{label, name, device_class, unit}`. Each `label` (e.g. `T1`) is matched as `label:value` in the message. |
## How parsing works
The add-on scans each message for `LABEL:VALUE` pairs (units after the value are
ignored). For example:
```
🏠 T1:20.8°C T2:26.7°C T3:26.6°C H1:83.7% H2:54.4% H3:55.5%
```
produces `T1=20.8`, `T2=26.7`, `T3=26.6`, `H1=83.7`, `H2=54.4`, `H3=55.5`,
each published to the sensor configured with that `label`.
## Topics used
- Discovery: `{discovery_prefix}/sensor/{device}/{label}/config`
- State: `meshtastic_receiver/{device}/{label}`
- Last message: `meshtastic_receiver/{device}/last_message` (+ `/attributes`)
- Availability: `meshtastic_receiver/{device}/status`
## Privacy note
On the public server your packets stay AES-encrypted in transit; only holders of
the channel PSK can read them. For full privacy and no traffic restrictions, run
a local MQTT broker and point both your node and `source_broker` at it.
+21
View File
@@ -0,0 +1,21 @@
ARG BUILD_FROM
FROM ${BUILD_FROM}
ENV LANG=C.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Isolated venv (Debian bookworm marks the system env externally-managed)
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt /
RUN pip install --no-cache-dir -r /requirements.txt
COPY run.sh /
COPY receiver.py /
RUN chmod a+x /run.sh
CMD [ "/run.sh" ]
+8
View File
@@ -0,0 +1,8 @@
# Debian base so the meshtastic deps (cryptography, protobuf) install
# from prebuilt wheels instead of compiling on Alpine.
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-debian:bookworm
amd64: ghcr.io/home-assistant/amd64-base-debian:bookworm
armv7: ghcr.io/home-assistant/armv7-base-debian:bookworm
armhf: ghcr.io/home-assistant/armhf-base-debian:bookworm
i386: ghcr.io/home-assistant/i386-base-debian:bookworm
+74
View File
@@ -0,0 +1,74 @@
name: Meshtastic Sensor Receiver
version: "1.0.0"
slug: meshtastic_receiver
description: Read Meshtastic mesh text messages over MQTT and expose the latest message plus parsed temperature/humidity values as Home Assistant sensors
url: https://git.revwal.de/oliver/ha-meshtastic-addon
arch:
- aarch64
- amd64
- armv7
- armhf
- i386
init: false
# Asks the Supervisor for the configured MQTT broker (e.g. the Mosquitto add-on)
# so sensors can be published via MQTT Discovery without manual credentials.
services:
- mqtt:want
options:
# --- Source: where the mesh packets are read from -------------------------
source_broker: mqtt.meshtastic.org
source_port: 1883
source_username: meshdev
source_password: large4cats
source_tls: false
region: EU_868
channel_name: DET.Baulog
channel_psk: "AQ=="
# --- Parsing / presentation ----------------------------------------------
# Only messages containing this string are parsed into sensors. Leave empty
# to parse every text message. The "last message" sensor always updates.
match_prefix: "🏠"
device_name: Meshtastic Mesh
discovery_prefix: homeassistant
sensors:
- label: T1
name: Temperature 1
device_class: temperature
unit: "°C"
- label: T2
name: Temperature 2
device_class: temperature
unit: "°C"
- label: T3
name: Temperature 3
device_class: temperature
unit: "°C"
- label: H1
name: Humidity 1
device_class: humidity
unit: "%"
- label: H2
name: Humidity 2
device_class: humidity
unit: "%"
- label: H3
name: Humidity 3
device_class: humidity
unit: "%"
schema:
source_broker: str
source_port: int(1,65535)
source_username: str?
source_password: str?
source_tls: bool
region: str
channel_name: str
channel_psk: str
match_prefix: str?
device_name: str
discovery_prefix: str
sensors:
- label: str
name: str
device_class: str?
unit: str?
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Receive Meshtastic mesh text messages over MQTT and expose them in Home Assistant.
Flow:
1. Subscribe to a Meshtastic gateway's MQTT topic (public server or local broker).
2. Decrypt channel packets (AES-CTR) using the channel PSK.
3. Publish the latest text message and parsed Temperature/Humidity values to
Home Assistant via MQTT Discovery (so entities appear automatically).
"""
import base64
import json
import os
import re
import sys
import time
import paho.mqtt.client as mqtt
import requests
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from meshtastic.protobuf import mesh_pb2, mqtt_pb2, portnums_pb2
def log(msg):
print(msg, file=sys.stdout, flush=True)
# --- Add-on options (written by the Supervisor) ------------------------------
with open("/data/options.json", encoding="utf-8") as fh:
OPTS = json.load(fh)
SRC_BROKER = OPTS["source_broker"]
SRC_PORT = int(OPTS.get("source_port", 1883))
SRC_USER = OPTS.get("source_username", "") or ""
SRC_PASS = OPTS.get("source_password", "") or ""
SRC_TLS = bool(OPTS.get("source_tls", False))
REGION = OPTS.get("region", "+") or "+"
CHANNEL_NAME = OPTS["channel_name"]
CHANNEL_PSK = OPTS.get("channel_psk", "AQ==")
MATCH_PREFIX = OPTS.get("match_prefix", "") or ""
DEVICE_NAME = OPTS.get("device_name", "Meshtastic Mesh")
DISCOVERY_PREFIX = OPTS.get("discovery_prefix", "homeassistant")
SENSORS = OPTS.get("sensors", [])
DEVICE_UID = re.sub(r"[^a-z0-9_]", "_", DEVICE_NAME.lower()) or "meshtastic_mesh"
BASE_TOPIC = f"meshtastic_receiver/{DEVICE_UID}"
AVAIL_TOPIC = f"{BASE_TOPIC}/status"
# --- PSK handling ------------------------------------------------------------
# "AQ==" (byte 0x01) is Meshtastic's shorthand for this well-known default key.
_DEFAULT_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
def expand_psk(psk_b64):
raw = base64.b64decode(psk_b64)
return _DEFAULT_KEY if raw == b"\x01" else raw
CHANNEL_KEY = expand_psk(CHANNEL_PSK)
# Matches "T1:20.8", "H2:54.4", "P:1013" etc. (unit suffix is ignored here).
PAIR_RE = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*:\s*(-?\d+(?:\.\d+)?)")
# Set once the HA broker client is connected.
ha_client = None
# --- Decryption --------------------------------------------------------------
def decrypt_packet(encrypted, key, from_id, packet_id):
"""AES-CTR decrypt. Nonce = packetId(uint64 LE) | fromNode(uint32 LE) | 0*4."""
try:
nonce = (
packet_id.to_bytes(4, "little")
+ b"\x00" * 4
+ from_id.to_bytes(4, "little")
+ b"\x00" * 4
)
cipher = Cipher(
algorithms.AES(key), modes.CTR(nonce), backend=default_backend()
)
d = cipher.decryptor()
return d.update(encrypted) + d.finalize()
except Exception as exc: # noqa: BLE001
log(f"[warn] decryption error: {exc}")
return None
# --- Home Assistant MQTT broker resolution (via Supervisor service) ----------
def resolve_ha_mqtt():
token = os.environ.get("SUPERVISOR_TOKEN")
if not token:
return None
try:
r = requests.get(
"http://supervisor/services/mqtt",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if r.status_code == 200:
d = r.json()["data"]
return {
"host": d["host"],
"port": int(d["port"]),
"username": d.get("username") or None,
"password": d.get("password") or None,
"ssl": bool(d.get("ssl", False)),
}
log(f"[warn] supervisor mqtt service returned HTTP {r.status_code}")
except Exception as exc: # noqa: BLE001
log(f"[warn] could not resolve HA MQTT via supervisor: {exc}")
return None
# --- MQTT Discovery ----------------------------------------------------------
def device_block():
return {
"identifiers": [DEVICE_UID],
"name": DEVICE_NAME,
"manufacturer": "Meshtastic",
"model": "MQTT Mesh Receiver",
}
def publish_discovery():
# Latest text message (with from/to/time as attributes).
cfg = {
"name": "Last Message",
"unique_id": f"{DEVICE_UID}_last_message",
"state_topic": f"{BASE_TOPIC}/last_message",
"json_attributes_topic": f"{BASE_TOPIC}/last_message/attributes",
"icon": "mdi:message-text",
"availability_topic": AVAIL_TOPIC,
"device": device_block(),
}
ha_client.publish(
f"{DISCOVERY_PREFIX}/sensor/{DEVICE_UID}/last_message/config",
json.dumps(cfg),
retain=True,
)
for s in SENSORS:
label = s["label"]
cfg = {
"name": s.get("name", label),
"unique_id": f"{DEVICE_UID}_{label}",
"state_topic": f"{BASE_TOPIC}/{label}",
"availability_topic": AVAIL_TOPIC,
"device": device_block(),
}
if s.get("unit"):
cfg["unit_of_measurement"] = s["unit"]
if s.get("device_class"):
cfg["device_class"] = s["device_class"]
cfg["state_class"] = "measurement"
ha_client.publish(
f"{DISCOVERY_PREFIX}/sensor/{DEVICE_UID}/{label}/config",
json.dumps(cfg),
retain=True,
)
log(f"[ha] published discovery for last_message + {len(SENSORS)} sensors")
# --- Message handling --------------------------------------------------------
def handle_text(text, from_id, to_id):
# HA sensor states are limited to 255 chars.
ha_client.publish(f"{BASE_TOPIC}/last_message", text[:255], retain=True)
ha_client.publish(
f"{BASE_TOPIC}/last_message/attributes",
json.dumps(
{
"from": f"!{from_id:08x}",
"to": f"!{to_id:08x}",
"received": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"full_text": text,
}
),
retain=True,
)
if MATCH_PREFIX and MATCH_PREFIX not in text:
return
known = {s["label"] for s in SENSORS}
published = []
for label, value in PAIR_RE.findall(text):
if label in known:
ha_client.publish(f"{BASE_TOPIC}/{label}", value, retain=True)
published.append(f"{label}={value}")
if published:
log(f"[parse] {' '.join(published)}")
# --- Source MQTT callbacks ---------------------------------------------------
def on_src_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0:
topic = f"msh/{REGION}/2/e/{CHANNEL_NAME}/#"
client.subscribe(topic)
log(f"[source] connected; subscribed to {topic}")
else:
log(f"[source] connection refused: reason_code={reason_code}")
def on_src_message(client, userdata, msg):
try:
env = mqtt_pb2.ServiceEnvelope()
env.ParseFromString(msg.payload)
except Exception as exc: # noqa: BLE001
log(f"[warn] could not parse ServiceEnvelope: {exc}")
return
mp = env.packet
from_id = getattr(mp, "from")
if mp.HasField("decoded"):
data = mp.decoded
elif mp.HasField("encrypted"):
plain = decrypt_packet(mp.encrypted, CHANNEL_KEY, from_id, mp.id)
if plain is None:
return
data = mesh_pb2.Data()
try:
data.ParseFromString(plain)
except Exception: # noqa: BLE001
return # wrong PSK or not for this channel
else:
return
if data.portnum == portnums_pb2.PortNum.TEXT_MESSAGE_APP:
text = data.payload.decode("utf-8", errors="replace")
log(f"[rx] !{from_id:08x}: {text}")
handle_text(text, from_id, mp.to)
# --- Entry point -------------------------------------------------------------
def main():
global ha_client
ha = resolve_ha_mqtt()
if ha is None:
log(
"[fatal] No MQTT broker available. Install and start the Mosquitto "
"broker add-on, then restart this add-on."
)
sys.exit(1)
log(f"[ha] using MQTT broker {ha['host']}:{ha['port']}")
ha_client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2, client_id="meshtastic_receiver"
)
if ha["username"]:
ha_client.username_pw_set(ha["username"], ha["password"])
if ha["ssl"]:
ha_client.tls_set()
ha_client.will_set(AVAIL_TOPIC, "offline", retain=True)
def on_ha_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0:
log("[ha] connected")
client.publish(AVAIL_TOPIC, "online", retain=True)
publish_discovery()
else:
log(f"[ha] connection refused: reason_code={reason_code}")
ha_client.on_connect = on_ha_connect
ha_client.connect(ha["host"], ha["port"], keepalive=60)
ha_client.loop_start()
src = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
if SRC_USER:
src.username_pw_set(SRC_USER, SRC_PASS)
if SRC_TLS:
src.tls_set()
src.on_connect = on_src_connect
src.on_message = on_src_message
log(f"[source] connecting to {SRC_BROKER}:{SRC_PORT}")
src.connect(SRC_BROKER, SRC_PORT, keepalive=60)
try:
src.loop_forever()
except KeyboardInterrupt:
pass
finally:
ha_client.publish(AVAIL_TOPIC, "offline", retain=True)
ha_client.loop_stop()
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
meshtastic
paho-mqtt
cryptography
requests
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/with-contenv bashio
bashio::log.info "Starting Meshtastic Sensor Receiver..."
exec python3 /receiver.py