added receiver plugin
This commit is contained in:
@@ -6,11 +6,17 @@ A small Home Assistant add-on repository.
|
||||
|
||||
- **Meshtastic Sensor Broadcaster** — periodically broadcasts selected Home
|
||||
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
|
||||
|
||||
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
|
||||
`/addons` share and use **Check for updates** in the Add-on Store.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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" ]
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
meshtastic
|
||||
paho-mqtt
|
||||
cryptography
|
||||
requests
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
bashio::log.info "Starting Meshtastic Sensor Receiver..."
|
||||
exec python3 /receiver.py
|
||||
Reference in New Issue
Block a user