Fix ble_alerter: MAC name filter, Matrix 429 backoff, active scanner
- 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 c0b91e9 despite the commit message
- Add 3 tests: MAC format filter, 429 retry success, 429 give-up
Fixes DevTrack #518
This commit is contained in:
+39
-21
@@ -11,6 +11,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -115,6 +116,9 @@ def load_env():
|
|||||||
logging.error(f"Failed to load .env: {e}")
|
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):
|
def is_generic_name(name):
|
||||||
"""
|
"""
|
||||||
Filter out unhelpful device names (generic manufacturer defaults, hex-only, too short).
|
Filter out unhelpful device names (generic manufacturer defaults, hex-only, too short).
|
||||||
@@ -133,6 +137,10 @@ def is_generic_name(name):
|
|||||||
if all(c in "0123456789ABCDEFabcdef" for c in name):
|
if all(c in "0123456789ABCDEFabcdef" for c in name):
|
||||||
return True
|
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
|
# Common generic patterns
|
||||||
generic_patterns = [
|
generic_patterns = [
|
||||||
"LE-", "BLE-", "BT-", "BT ", "Bluetooth", "Unknown",
|
"LE-", "BLE-", "BT-", "BT ", "Bluetooth", "Unknown",
|
||||||
@@ -160,7 +168,7 @@ def fmt_duration(secs):
|
|||||||
|
|
||||||
|
|
||||||
def send_alert(msg):
|
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:
|
if not MATRIX_ACCESS_TOKEN:
|
||||||
logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert")
|
logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert")
|
||||||
return
|
return
|
||||||
@@ -170,24 +178,34 @@ def send_alert(msg):
|
|||||||
f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message"
|
f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message"
|
||||||
)
|
)
|
||||||
payload = json.dumps({"msgtype": "m.text", "body": msg}).encode()
|
payload = json.dumps({"msgtype": "m.text", "body": msg}).encode()
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
for attempt in range(3):
|
||||||
data=payload,
|
req = urllib.request.Request(
|
||||||
headers={
|
url,
|
||||||
"Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}",
|
data=payload,
|
||||||
"Content-Type": "application/json",
|
headers={
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
"Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}",
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
method="POST",
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||||
)
|
},
|
||||||
try:
|
method="POST",
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
)
|
||||||
pass
|
try:
|
||||||
logging.debug(f"Alert sent to Matrix")
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
except urllib.error.HTTPError as e:
|
pass
|
||||||
logging.error(f"Matrix send failed with HTTP {e.code}")
|
logging.debug("Alert sent to Matrix")
|
||||||
except Exception as e:
|
return
|
||||||
logging.error(f"Matrix send exception: {e}")
|
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):
|
async def on_arrival(name, mac, rssi):
|
||||||
@@ -256,8 +274,8 @@ async def scan_loop():
|
|||||||
|
|
||||||
await on_arrival(name, mac, rssi)
|
await on_arrival(name, mac, rssi)
|
||||||
|
|
||||||
# Active scanner — sends SCAN_REQ to get full advertisement data including device names
|
# Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive
|
||||||
async with BleakScanner(detection_callback=detection_callback) as scanner:
|
async with BleakScanner(detection_callback=detection_callback, scanning_mode="active") as scanner:
|
||||||
try:
|
try:
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ class TestIsGenericName(unittest.TestCase):
|
|||||||
self.assertTrue(ble_alerter.is_generic_name("Unknown Device"))
|
self.assertTrue(ble_alerter.is_generic_name("Unknown Device"))
|
||||||
self.assertFalse(ble_alerter.is_generic_name("John's iPhone"))
|
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):
|
def test_real_device_names(self):
|
||||||
"""Real device names are not generic."""
|
"""Real device names are not generic."""
|
||||||
self.assertFalse(ble_alerter.is_generic_name("John's iPhone"))
|
self.assertFalse(ble_alerter.is_generic_name("John's iPhone"))
|
||||||
@@ -114,6 +122,40 @@ class TestSendAlert(unittest.TestCase):
|
|||||||
args, kwargs = mock_urlopen.call_args
|
args, kwargs = mock_urlopen.call_args
|
||||||
self.assertIn("_matrix/client/v3/rooms", args[0].full_url)
|
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):
|
class TestArrivalDeparture(unittest.IsolatedAsyncioTestCase):
|
||||||
"""Test arrival/departure detection."""
|
"""Test arrival/departure detection."""
|
||||||
|
|||||||
Reference in New Issue
Block a user