From 296bb08c562342ddb84e7be38a25127327b5e04d Mon Sep 17 00:00:00 2001 From: Cobra Date: Fri, 10 Apr 2026 06:44:13 -0400 Subject: [PATCH] Fix ble_alerter: MAC name filter, Matrix 429 backoff, active scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject dash/colon MAC-format device names (e.g. 45-48-39-7F-73-C7) from open mode — were slipping past is_generic_name() - Add exponential backoff (1s, 2s) on Matrix 429/5xx, max 3 attempts — startup burst was rate-limiting and dropping alerts - Commit active scanner flag (scanning_mode=active) that was in working tree but not in prior commit 8686eb5 despite the commit message - Add 3 tests: MAC format filter, 429 retry success, 429 give-up Fixes DevTrack #518 --- net_alerter/ble_alerter.py | 72 ++++++++++++++++++++------------- net_alerter/test_ble_alerter.py | 48 ++++++++++++++++++++-- 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/net_alerter/ble_alerter.py b/net_alerter/ble_alerter.py index 9e0612c..0e52a56 100755 --- a/net_alerter/ble_alerter.py +++ b/net_alerter/ble_alerter.py @@ -11,6 +11,7 @@ import asyncio import json import logging import os +import re import signal import sys import time @@ -115,6 +116,9 @@ def load_env(): logging.error(f"Failed to load .env: {e}") +_MAC_RE = re.compile(r'^[0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}$') + + def is_generic_name(name): """ Filter out unhelpful device names (generic manufacturer defaults, hex-only, too short). @@ -122,17 +126,21 @@ def is_generic_name(name): """ if not name or not isinstance(name, str): return True - + name = name.strip() - + # Too short if len(name) < 3: return True - + # All hex characters (looks like MAC address fragment) if all(c in "0123456789ABCDEFabcdef" for c in name): return True - + + # MAC address format with colons or dashes (e.g. 45-48-39-7F-73-C7 or 45:48:39:7F:73:C7) + if _MAC_RE.match(name): + return True + # Common generic patterns generic_patterns = [ "LE-", "BLE-", "BT-", "BT ", "Bluetooth", "Unknown", @@ -141,7 +149,7 @@ def is_generic_name(name): for pattern in generic_patterns: if pattern.lower() in name.lower(): return True - + return False @@ -160,34 +168,44 @@ def fmt_duration(secs): def send_alert(msg): - """Send alert to Matrix room.""" + """Send alert to Matrix room. Retries up to 3 times with exponential backoff on 429/5xx.""" if not MATRIX_ACCESS_TOKEN: logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert") return - + url = ( f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/" f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message" ) payload = json.dumps({"msgtype": "m.text", "body": msg}).encode() - req = urllib.request.Request( - url, - data=payload, - headers={ - "Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}", - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", - }, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=10) as r: - pass - logging.debug(f"Alert sent to Matrix") - except urllib.error.HTTPError as e: - logging.error(f"Matrix send failed with HTTP {e.code}") - except Exception as e: - logging.error(f"Matrix send exception: {e}") + + for attempt in range(3): + req = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}", + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + pass + logging.debug("Alert sent to Matrix") + return + except urllib.error.HTTPError as e: + if e.code in (429, 500, 502, 503, 504) and attempt < 2: + wait = 2 ** attempt # 1s, 2s + logging.warning(f"Matrix HTTP {e.code}, retrying in {wait}s") + time.sleep(wait) + else: + logging.error(f"Matrix send failed with HTTP {e.code}") + return + except Exception as e: + logging.error(f"Matrix send exception: {e}") + return async def on_arrival(name, mac, rssi): @@ -256,8 +274,8 @@ async def scan_loop(): await on_arrival(name, mac, rssi) - # Active scanner — sends SCAN_REQ to get full advertisement data including device names - async with BleakScanner(detection_callback=detection_callback) as scanner: + # Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive + async with BleakScanner(detection_callback=detection_callback, scanning_mode="active") as scanner: try: while not shutdown_event.is_set(): await asyncio.sleep(1) diff --git a/net_alerter/test_ble_alerter.py b/net_alerter/test_ble_alerter.py index 54e755e..758ea19 100755 --- a/net_alerter/test_ble_alerter.py +++ b/net_alerter/test_ble_alerter.py @@ -55,6 +55,14 @@ class TestIsGenericName(unittest.TestCase): self.assertTrue(ble_alerter.is_generic_name("Unknown Device")) self.assertFalse(ble_alerter.is_generic_name("John's iPhone")) + def test_mac_format_names(self): + """MAC-format names (dash or colon separated) are generic.""" + self.assertTrue(ble_alerter.is_generic_name("45-48-39-7F-73-C7")) + self.assertTrue(ble_alerter.is_generic_name("45:48:39:7F:73:C7")) + self.assertTrue(ble_alerter.is_generic_name("aa:bb:cc:dd:ee:ff")) + # Partial MAC (not 6 groups) is NOT caught by regex — falls through to other checks + self.assertTrue(ble_alerter.is_generic_name("AABBCC")) # caught by all-hex check + def test_real_device_names(self): """Real device names are not generic.""" self.assertFalse(ble_alerter.is_generic_name("John's iPhone")) @@ -104,16 +112,50 @@ class TestSendAlert(unittest.TestCase): """Alert is sent to Matrix API.""" ble_alerter.MATRIX_ACCESS_TOKEN = "syt_test" ble_alerter.MATRIX_ROOM_ID = "!abc:m.test.com" - + mock_urlopen.return_value.__enter__ = MagicMock(return_value=MagicMock()) mock_urlopen.return_value.__exit__ = MagicMock(return_value=False) - + ble_alerter.send_alert("[BLE] ARRIVED: test") - + mock_urlopen.assert_called_once() args, kwargs = mock_urlopen.call_args self.assertIn("_matrix/client/v3/rooms", args[0].full_url) + @patch('ble_alerter.time.sleep') + @patch('ble_alerter.urllib.request.urlopen') + def test_send_alert_retries_on_429(self, mock_urlopen, mock_sleep): + """429 response triggers retry with backoff, succeeds on second attempt.""" + import urllib.error + ble_alerter.MATRIX_ACCESS_TOKEN = "syt_test" + ble_alerter.MATRIX_ROOM_ID = "!abc:m.test.com" + + # First call raises 429, second succeeds + mock_urlopen.side_effect = [ + urllib.error.HTTPError(None, 429, "Too Many Requests", {}, None), + MagicMock(__enter__=MagicMock(return_value=MagicMock()), __exit__=MagicMock(return_value=False)), + ] + + ble_alerter.send_alert("[BLE] test") + + self.assertEqual(mock_urlopen.call_count, 2) + mock_sleep.assert_called_once_with(1) # 2^0 = 1s backoff + + @patch('ble_alerter.time.sleep') + @patch('ble_alerter.urllib.request.urlopen') + def test_send_alert_gives_up_after_3_attempts(self, mock_urlopen, mock_sleep): + """Persistent 429 gives up after 3 attempts.""" + import urllib.error + ble_alerter.MATRIX_ACCESS_TOKEN = "syt_test" + ble_alerter.MATRIX_ROOM_ID = "!abc:m.test.com" + + mock_urlopen.side_effect = urllib.error.HTTPError(None, 429, "Too Many Requests", {}, None) + + ble_alerter.send_alert("[BLE] test") + + self.assertEqual(mock_urlopen.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) # sleeps after attempt 0 and 1 + class TestArrivalDeparture(unittest.IsolatedAsyncioTestCase): """Test arrival/departure detection."""