From 4c64fc7117d80caec10c177594fd48cc8a629c40 Mon Sep 17 00:00:00 2001 From: prabathbr <60564552+prabathbr@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:17:07 +1100 Subject: [PATCH 1/3] serial_rss_bot example serial_rss_bot example which broadcasts emergency bushfire warnings in VIC, AU --- examples/serial_rss_bot.py | 251 +++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 examples/serial_rss_bot.py diff --git a/examples/serial_rss_bot.py b/examples/serial_rss_bot.py new file mode 100644 index 0000000..5a36fea --- /dev/null +++ b/examples/serial_rss_bot.py @@ -0,0 +1,251 @@ +import asyncio +import logging +import re +import urllib.request +import xml.etree.ElementTree as ET +from html import unescape +from html.parser import HTMLParser +import hashlib + + +from meshcore import MeshCore, EventType + +# ===================== +# Config +# ===================== +SERIAL_PORT = "COM4" # change this to your serial port +CHANNEL_IDXS = [6]#[5, 6] # change this to the index of your target channel + +RSS_URL = "https://data.emergency.vic.gov.au/Show?pageId=getIncidentRSS" +POLL_INTERVAL_SEC = 300 # poll interval (seconds) + +# Only send items whose description (plain text) contains this keyword (case-insensitive) +KEYWORDS_REQUIRED = ["BUSHFIRE"] + +# Only send these fields from the RSS description +FIELDS_TO_SEND = [ + "Type", + "Fire District", + "Location", + "Latitude", + "Longitude", + "Status", + "Size", +] + +logging.basicConfig(level=logging.INFO) +_LOGGER = logging.getLogger("serial_rssbot") + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self): + super().__init__() + self._chunks: list[str] = [] + + def handle_data(self, data: str) -> None: + if data: + self._chunks.append(data) + + def get_text(self) -> str: + return "".join(self._chunks) + + +def _strip_html(html: str) -> str: + """Convert HTML-ish fragments into plain text.""" + if not html: + return "" + + # Normalize
into newlines so field parsing is reliable. + html = re.sub(r"<\s*br\s*/?\s*>", "\n", html, flags=re.IGNORECASE) + + parser = _HTMLTextExtractor() + try: + parser.feed(html) + parser.close() + text = parser.get_text() + except Exception: + text = re.sub(r"<[^>]+>", "\n", html) + + text = unescape(text) + # Normalize whitespace but keep newlines as separators. + text = text.replace("\r", "\n") + text = re.sub(r"[ \t\f\v]+", " ", text) + text = re.sub(r"\n+", "\n", text) + return text.strip() + + +def _fetch_rss_bytes(url: str) -> bytes: + req = urllib.request.Request( + url, + headers={ + "User-Agent": "meshcore-rss-bot/1.0", + "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + method="GET", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + +def extract_fields(raw_html: str) -> dict[str, str]: + """ + Extract desired fields from the HTML description. + We strip HTML to text with newlines, then parse lines like "Field: value". + """ + text = _strip_html(raw_html) + lines = [ln.strip() for ln in text.split("\n") if ln.strip()] + + result: dict[str, str] = {} + for ln in lines: + m = re.match(r"^([^:]+):\s*(.*)$", ln) + if not m: + continue + key = m.group(1).strip() + val = m.group(2).strip() + + # Match desired fields case-insensitively + for wanted in FIELDS_TO_SEND: + if key.lower() == wanted.lower(): + result[wanted] = val + break + + return result + +def _parse_rss_items(xml_bytes: bytes) -> list[dict[str, str]]: + """Return list of items with keys: id, description, raw_description.""" + items: list[dict[str, str]] = [] + + root = ET.fromstring(xml_bytes) + + # RSS 2.0: ... + channel = root.find("channel") if root.tag.lower().endswith("rss") else root + if channel is None: + return items + + for it in channel.findall("item"): + title = (it.findtext("title") or "").strip() + link = (it.findtext("link") or "").strip() + guid = (it.findtext("guid") or "").strip() + pub_date = (it.findtext("pubDate") or "").strip() + + desc_html = it.findtext("description") or "" + desc_text = _strip_html(desc_html) + + # stable_id = (guid or link or f"{title}|{pub_date}" or desc_text).strip() + + base_id = (guid or link or f"{title}|{pub_date}").strip() + + # fingerprint only the fields you output + fields = extract_fields(desc_html) + parts = [] + for k in FIELDS_TO_SEND: + v = fields.get(k, "") + parts.append(f"{k}={v}") + fingerprint = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()[:12] + + stable_id = f"{base_id}|{fingerprint}" + + + items.append( + { + "id": stable_id, + "description": desc_text, # plain text for keyword filtering + "raw_description": desc_html, # original HTML-ish description + } + ) + + return items + + + + + +def build_filtered_message(item: dict[str, str]) -> str | None: + # Keyword filter (case-insensitive) on plain text description + desc_text_lower = (item.get("description") or "").lower() + if not any(k.lower() in desc_text_lower for k in KEYWORDS_REQUIRED): + return None + + fields = extract_fields(item.get("raw_description") or "") + if not fields: + return None + + # Keep order exactly as FIELDS_TO_SEND, skip missing + parts = [] + for k in FIELDS_TO_SEND: + v = fields.get(k) + if v: + parts.append(f"{v}") + + if not parts: + return None + + return " | ".join(parts) + + +async def _fetch_items_async() -> list[dict[str, str]]: + xml_bytes = await asyncio.to_thread(_fetch_rss_bytes, RSS_URL) + return _parse_rss_items(xml_bytes) + + +async def main() -> None: + meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True) + print(f"Connected on {SERIAL_PORT}") + + # await meshcore.start_auto_message_fetching() + + seen_ids: set[str] = set() + + # Prime seen set so we do not spam old items on startup + try: + initial_items = await _fetch_items_async() + for it in initial_items: + if it.get("id"): + seen_ids.add(it["id"]) + _LOGGER.info("Primed %d existing RSS items as seen.", len(seen_ids)) + except Exception as ex: + _LOGGER.warning("Failed to prime RSS items: %s", ex) + + try: + while True: + try: + items = await _fetch_items_async() + + # Feeds are usually newest-first. Send unseen in chronological order. + new_items = [it for it in items if it.get("id") and it["id"] not in seen_ids] + for it in reversed(new_items): + msg_text = build_filtered_message(it) + + # Mark as seen even if it does not match filter, so we do not re-check forever + seen_ids.add(it["id"]) + + if not msg_text: + continue + + # _LOGGER.info("Sending filtered RSS item to channel %s", CHANNEL_IDX) + # result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, msg_text) + # if result.type == EventType.ERROR: + # _LOGGER.error("Error sending RSS message: %s", result.payload) + for ch in CHANNEL_IDXS: + _LOGGER.info("Sending filtered RSS item to channel %s", ch) + result = await meshcore.commands.send_chan_msg(ch, msg_text) + if result.type == EventType.ERROR: + _LOGGER.error("Error sending RSS message to channel %s: %s", ch, result.payload) + await asyncio.sleep(5) # seconds + await asyncio.sleep(5) + + + except Exception as ex: + _LOGGER.warning("RSS poll failed: %s", ex) + + await asyncio.sleep(POLL_INTERVAL_SEC) + + except KeyboardInterrupt: + print("Stopping...") + finally: + await meshcore.stop_auto_message_fetching() + await meshcore.disconnect() + print("Disconnected") + + +if __name__ == "__main__": + asyncio.run(main()) From f6fea7fd49f46e3988b95d971dbd88050604898f Mon Sep 17 00:00:00 2001 From: prabathbr <60564552+prabathbr@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:19:17 +1100 Subject: [PATCH 2/3] Update README.md - `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 926a5c3..25b2c5d 100644 --- a/README.md +++ b/README.md @@ -627,6 +627,7 @@ Check the `examples/` directory for more: - `serial_infos.py`: Quick device info retrieval - `serial_msg.py`: Message sending and receiving - `serial_pingbot.py`: Ping bot which can be run on a channel +- `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU - `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box - `ble_pin_pairing_example.py`: BLE connection with PIN pairing - `ble_private_key_export.py`: BLE private key export with PIN authentication From 1e2171bcc142fb48c14df6358b3bd8feade00f5c Mon Sep 17 00:00:00 2001 From: prabathbr <60564552+prabathbr@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:54:49 +1100 Subject: [PATCH 3/3] added STATUS filtering and show on map pattern lat,long Added STATUS filtering and show on map pattern lat, long --- examples/serial_rss_bot.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/examples/serial_rss_bot.py b/examples/serial_rss_bot.py index 5a36fea..5106380 100644 --- a/examples/serial_rss_bot.py +++ b/examples/serial_rss_bot.py @@ -33,6 +33,11 @@ FIELDS_TO_SEND = [ "Size", ] +BLOCKED_STATUSES = [ + "Under Control", + "Safe", +] + logging.basicConfig(level=logging.INFO) _LOGGER = logging.getLogger("serial_rssbot") @@ -168,13 +173,41 @@ def build_filtered_message(item: dict[str, str]) -> str | None: fields = extract_fields(item.get("raw_description") or "") if not fields: return None + + status_norm = fields.get("Status", "").strip().lower() + blocked_norm = [b.lower() for b in BLOCKED_STATUSES] + + if status_norm in blocked_norm: + return None + + + # if any(b.lower() in status for b in BLOCKED_STATUSES): + # return None # Keep order exactly as FIELDS_TO_SEND, skip missing + # parts = [] + # for k in FIELDS_TO_SEND: + # v = fields.get(k) + # if v: + # parts.append(f"{v}") + parts = [] + + lat = fields.get("Latitude") + lon = fields.get("Longitude") + for k in FIELDS_TO_SEND: + if k in ("Latitude", "Longitude"): + continue + v = fields.get(k) if v: - parts.append(f"{v}") + parts.append(v) + + # append formatted lat,lon at the end + if lat and lon: + parts.append(f"{lat},{lon}") + if not parts: return None