From 30948ebb0a28e8ceb9d8236f4571a56bc71edb51 Mon Sep 17 00:00:00 2001 From: Oliver Walter Date: Wed, 1 Jul 2026 01:27:24 +0200 Subject: [PATCH] added more diagnostics --- meshtastic_receiver/CHANGELOG.md | 1 + meshtastic_receiver/DOCS.md | 14 +++++ meshtastic_receiver/receiver.py | 88 +++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/meshtastic_receiver/CHANGELOG.md b/meshtastic_receiver/CHANGELOG.md index 7f751b0..946da26 100644 --- a/meshtastic_receiver/CHANGELOG.md +++ b/meshtastic_receiver/CHANGELOG.md @@ -7,5 +7,6 @@ - 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. +- Link diagnostics per node: Last Seen, RSSI, SNR, and Hops Away. - 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 index aa7acfc..833d760 100644 --- a/meshtastic_receiver/DOCS.md +++ b/meshtastic_receiver/DOCS.md @@ -7,6 +7,8 @@ the results in Home Assistant via MQTT Discovery: `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. +- **Link diagnostics** — `Last Seen`, `RSSI`, `SNR`, and `Hops Away`, derived + from each packet's metadata (shown under the device's Diagnostic section). This is the receiving counterpart to the **Meshtastic Sensor Broadcaster** add-on. @@ -44,11 +46,23 @@ ignored). For example: 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`. +## Link diagnostics + +These are created automatically (no config) from each received packet: + +| Sensor | Source | Notes | +|--------|--------|-------| +| `Last Seen` | packet `rx_time` | Updates on every packet on the channel. | +| `RSSI` | packet `rx_rssi` (dBm) | Signal at the gateway. Omitted when the gateway originated the packet (value 0). | +| `SNR` | packet `rx_snr` (dB) | Published alongside RSSI. | +| `Hops Away` | `hop_start - hop_limit` | Number of hops the packet traveled. Requires firmware that reports `hop_start`. | + ## Topics used - Discovery: `{discovery_prefix}/sensor/{device}/{label}/config` - State: `meshtastic_receiver/{device}/{label}` - Last message: `meshtastic_receiver/{device}/last_message` (+ `/attributes`) +- Diagnostics: `meshtastic_receiver/{device}/{last_seen,rssi,snr,hops_away}` - Availability: `meshtastic_receiver/{device}/status` ## Privacy note diff --git a/meshtastic_receiver/receiver.py b/meshtastic_receiver/receiver.py index 68e7047..c4e3ed7 100644 --- a/meshtastic_receiver/receiver.py +++ b/meshtastic_receiver/receiver.py @@ -14,6 +14,7 @@ import os import re import sys import time +from datetime import datetime, timezone import paho.mqtt.client as mqtt import requests @@ -62,6 +63,36 @@ 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+)?)") +# Built-in per-node diagnostic sensors derived from packet metadata. +DIAGNOSTICS = [ + { + "key": "last_seen", + "name": "Last Seen", + "device_class": "timestamp", + "icon": "mdi:clock-outline", + }, + { + "key": "rssi", + "name": "RSSI", + "device_class": "signal_strength", + "unit": "dBm", + "state_class": "measurement", + }, + { + "key": "snr", + "name": "SNR", + "unit": "dB", + "state_class": "measurement", + "icon": "mdi:signal", + }, + { + "key": "hops_away", + "name": "Hops Away", + "state_class": "measurement", + "icon": "mdi:transit-connection-variant", + }, +] + # Set once the HA broker client is connected. ha_client = None @@ -158,10 +189,62 @@ def publish_discovery(): json.dumps(cfg), retain=True, ) - log(f"[ha] published discovery for last_message + {len(SENSORS)} sensors") + + for d in DIAGNOSTICS: + key = d["key"] + cfg = { + "name": d["name"], + "unique_id": f"{DEVICE_UID}_{key}", + "state_topic": f"{BASE_TOPIC}/{key}", + "availability_topic": AVAIL_TOPIC, + "entity_category": "diagnostic", + "device": device_block(), + } + if d.get("unit"): + cfg["unit_of_measurement"] = d["unit"] + if d.get("device_class"): + cfg["device_class"] = d["device_class"] + if d.get("state_class"): + cfg["state_class"] = d["state_class"] + if d.get("icon"): + cfg["icon"] = d["icon"] + ha_client.publish( + f"{DISCOVERY_PREFIX}/sensor/{DEVICE_UID}/{key}/config", + json.dumps(cfg), + retain=True, + ) + + log( + f"[ha] published discovery for last_message + {len(SENSORS)} sensors " + f"+ {len(DIAGNOSTICS)} diagnostics" + ) # --- Message handling -------------------------------------------------------- +def publish_metadata(mp): + """Publish link-quality diagnostics from a received MeshPacket.""" + # Last seen: prefer the gateway's rx_time, fall back to now. + ts = ( + datetime.fromtimestamp(mp.rx_time, tz=timezone.utc) + if mp.rx_time + else datetime.now(timezone.utc) + ) + ha_client.publish(f"{BASE_TOPIC}/last_seen", ts.isoformat(), retain=True) + + # RSSI/SNR are 0 for packets the gateway originated itself — only publish + # genuine over-the-air measurements. + if mp.rx_rssi != 0: + ha_client.publish(f"{BASE_TOPIC}/rssi", mp.rx_rssi, retain=True) + ha_client.publish(f"{BASE_TOPIC}/snr", round(mp.rx_snr, 2), retain=True) + + # hop_start is the original TTL; hops taken = hop_start - hop_limit. + # hop_start == 0 means older firmware that didn't report it. + if mp.hop_start: + ha_client.publish( + f"{BASE_TOPIC}/hops_away", mp.hop_start - mp.hop_limit, retain=True + ) + + 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) @@ -212,6 +295,9 @@ def on_src_message(client, userdata, msg): mp = env.packet from_id = getattr(mp, "from") + # Link diagnostics + last-seen update for every packet on the channel. + publish_metadata(mp) + if mp.HasField("decoded"): data = mp.decoded elif mp.HasField("encrypted"):