diff --git a/README.md b/README.md index 2848c62..41aadc5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/meshtastic_receiver/CHANGELOG.md b/meshtastic_receiver/CHANGELOG.md new file mode 100644 index 0000000..7f751b0 --- /dev/null +++ b/meshtastic_receiver/CHANGELOG.md @@ -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). diff --git a/meshtastic_receiver/DOCS.md b/meshtastic_receiver/DOCS.md new file mode 100644 index 0000000..aa7acfc --- /dev/null +++ b/meshtastic_receiver/DOCS.md @@ -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. diff --git a/meshtastic_receiver/Dockerfile b/meshtastic_receiver/Dockerfile new file mode 100644 index 0000000..3d61411 --- /dev/null +++ b/meshtastic_receiver/Dockerfile @@ -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" ] diff --git a/meshtastic_receiver/build.yaml b/meshtastic_receiver/build.yaml new file mode 100644 index 0000000..b0eafdb --- /dev/null +++ b/meshtastic_receiver/build.yaml @@ -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 diff --git a/meshtastic_receiver/config.yaml b/meshtastic_receiver/config.yaml new file mode 100644 index 0000000..0fe50aa --- /dev/null +++ b/meshtastic_receiver/config.yaml @@ -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? diff --git a/meshtastic_receiver/receiver.py b/meshtastic_receiver/receiver.py new file mode 100644 index 0000000..68e7047 --- /dev/null +++ b/meshtastic_receiver/receiver.py @@ -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() diff --git a/meshtastic_receiver/requirements.txt b/meshtastic_receiver/requirements.txt new file mode 100644 index 0000000..eb1a728 --- /dev/null +++ b/meshtastic_receiver/requirements.txt @@ -0,0 +1,4 @@ +meshtastic +paho-mqtt +cryptography +requests diff --git a/meshtastic_receiver/run.sh b/meshtastic_receiver/run.sh new file mode 100644 index 0000000..9515115 --- /dev/null +++ b/meshtastic_receiver/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/with-contenv bashio +bashio::log.info "Starting Meshtastic Sensor Receiver..." +exec python3 /receiver.py