bt monitor
This commit is contained in:
95
roles/bluetooth-monitor/other/analysis.py
Normal file
95
roles/bluetooth-monitor/other/analysis.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from copy import copy
|
||||
from sklearn.model_selection import cross_val_score
|
||||
from sklearn import svm
|
||||
from sklearn.neural_network import MLPClassifier
|
||||
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
|
||||
import matplotlib.pyplot as plt
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
def load_measurements(csv_file: Path):
|
||||
def cleanup_column_name(col_name: str):
|
||||
clean_name = col_name.replace('#', '').strip()
|
||||
if clean_name == 'room':
|
||||
return 'tracker'
|
||||
return clean_name
|
||||
|
||||
df = pd.read_csv(str(csv_file))
|
||||
|
||||
# String cleanup in column names and room names
|
||||
df = df.rename(columns=cleanup_column_name)
|
||||
df.applymap(lambda x: x.strip() if isinstance(x, str) else x)
|
||||
|
||||
df['tracker'] = df['tracker'].astype("category")
|
||||
df['real_room'] = df['real_room'].astype("category")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
FAR_AWAY_FEATURE_VALUE = 1
|
||||
def get_feature_value(rssi, tx_power):
|
||||
MIN_RSSI = -90
|
||||
MAX_TRANSFORMED_RSSI = 40
|
||||
v = tx_power - rssi - MAX_TRANSFORMED_RSSI
|
||||
if v < 0:
|
||||
v = 0
|
||||
return v / (-MIN_RSSI)
|
||||
|
||||
|
||||
def make_training_data(df: pd.DataFrame, device_to_map):
|
||||
idx_to_tracker = dict(enumerate(df['tracker'].cat.categories ))
|
||||
tracker_to_idx = {v: k for k, v in idx_to_tracker.items()}
|
||||
idx_to_room = dict(enumerate(df['real_room'].cat.categories ))
|
||||
room_to_idx = {v: k for k, v in idx_to_room.items()}
|
||||
|
||||
last_real_room = None
|
||||
start_time = None
|
||||
current_feature = [FAR_AWAY_FEATURE_VALUE] * len(idx_to_tracker)
|
||||
|
||||
features = []
|
||||
labels = []
|
||||
|
||||
# Feature vectors - rssi column for each room
|
||||
for i, row in df.iterrows():
|
||||
time, device, tracker, rssi, tx_power, real_room = row
|
||||
if device != device_to_map:
|
||||
continue
|
||||
if last_real_room != real_room:
|
||||
start_time = time
|
||||
last_real_room = real_room
|
||||
|
||||
tracker_idx = tracker_to_idx[tracker]
|
||||
current_feature[tracker_idx] = get_feature_value(rssi, tx_power)
|
||||
if time - start_time > 20:
|
||||
features.append(copy(current_feature))
|
||||
labels.append(room_to_idx[real_room])
|
||||
|
||||
return np.array(features), np.array(labels)
|
||||
|
||||
def train(features, labels, classes):
|
||||
clf = svm.SVC(kernel='rbf')
|
||||
print("Training")
|
||||
scores = cross_val_score(clf, features, labels, cv=5)
|
||||
print(scores)
|
||||
print("%0.2f accuracy with a standard deviation of %0.2f" % (scores.mean(), scores.std()))
|
||||
|
||||
X_train, X_test, y_train, y_test = train_test_split(features, labels, random_state=0)
|
||||
clf.fit(X_train, y_train)
|
||||
cm = confusion_matrix(clf.predict(X_test), y_test)
|
||||
print(cm)
|
||||
print(classes)
|
||||
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=classes)
|
||||
disp.plot()
|
||||
plt.show()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
csv_path = Path("/home/martin/code/ansible/roles/bluetooth-monitor/other/collected.csv")
|
||||
df = load_measurements(csv_path)
|
||||
features, labels = make_training_data(df, "martins_apple_watch")
|
||||
print(np.unique(labels))
|
||||
print(features.shape, labels.shape)
|
||||
train(features, labels, list(df['real_room'].dtype.categories))
|
||||
331
roles/bluetooth-monitor/other/bt_monitor_analyze.ipynb
Normal file
331
roles/bluetooth-monitor/other/bt_monitor_analyze.ipynb
Normal file
File diff suppressed because one or more lines are too long
27855
roles/bluetooth-monitor/other/collected.csv
Normal file
27855
roles/bluetooth-monitor/other/collected.csv
Normal file
File diff suppressed because it is too large
Load Diff
27855
roles/bluetooth-monitor/other/collected_backup.csv
Normal file
27855
roles/bluetooth-monitor/other/collected_backup.csv
Normal file
File diff suppressed because it is too large
Load Diff
121
roles/bluetooth-monitor/other/data_collector.py
Normal file
121
roles/bluetooth-monitor/other/data_collector.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import aiomqtt
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from time import time
|
||||
from pathlib import Path
|
||||
from collections import namedtuple
|
||||
from typing import Dict
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
BtleMeasurement = namedtuple("BtleMeasurement", ["time", "tracker", "address", "rssi", "tx_power"])
|
||||
BtleDeviceMeasurement = namedtuple("BtleDeviceMeasurement", ["time", "device", "tracker", "rssi", "tx_power"])
|
||||
|
||||
|
||||
class KnownRoomCsvLogger:
|
||||
"""Logs known room measurements to be used later as training data for classifier"""
|
||||
|
||||
def __init__(self, csv_file: Path):
|
||||
self.known_room = None
|
||||
|
||||
if csv_file.exists():
|
||||
self.csv_file_handle = open(csv_file, "a")
|
||||
else:
|
||||
self.csv_file_handle = open(csv_file, "w")
|
||||
print(f"#time,device,tracker,rssi,tx_power,real_room", file=csv_file)
|
||||
|
||||
def update_known_room(self, known_room: str):
|
||||
self.known_room = known_room
|
||||
|
||||
def report_measure(self, m: BtleDeviceMeasurement):
|
||||
ignore_rooms = ("keins", "?", "none", "unknown")
|
||||
if self.known_room is None or self.known_room in ignore_rooms:
|
||||
return
|
||||
print(
|
||||
f"{m.time},{m.device},{m.tracker},{m.rssi},{m.tx_power},{self.known_room}",
|
||||
file=self.csv_file_handle,
|
||||
)
|
||||
|
||||
|
||||
class DeviceDecoder:
|
||||
|
||||
def __init__(self, irk_to_devicename: Dict[str, str], address_to_name: Dict[str, str]):
|
||||
"irk_to_devicename is dict with irk as a hex string, mapping to device name"
|
||||
self.irk_to_devicename = {bytes.fromhex(k): v for k, v in irk_to_devicename.items()}
|
||||
self.address_to_name = address_to_name
|
||||
|
||||
def _resolve_rpa(rpa: bytes, irk: bytes) -> bool:
|
||||
"""Compares the random address rpa to an irk (secret key) and return True if it matches"""
|
||||
assert len(rpa) == 6
|
||||
assert len(irk) == 16
|
||||
|
||||
key = irk
|
||||
plain_text = b"\x00" * 16
|
||||
plain_text = bytearray(plain_text)
|
||||
plain_text[15] = rpa[3]
|
||||
plain_text[14] = rpa[4]
|
||||
plain_text[13] = rpa[5]
|
||||
plain_text = bytes(plain_text)
|
||||
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
cipher_text = cipher.encrypt(plain_text)
|
||||
return cipher_text[15] == rpa[0] and cipher_text[14] == rpa[1] and cipher_text[13] == rpa[2]
|
||||
|
||||
def _addr_to_bytes(addr: str) -> bytes:
|
||||
"""Converts a bluetooth mac address string with semicolons to bytes"""
|
||||
str_without_colons = addr.replace(":", "")
|
||||
bytearr = bytearray.fromhex(str_without_colons)
|
||||
bytearr.reverse()
|
||||
return bytes(bytearr)
|
||||
|
||||
def addr_to_bytes(addr: str) -> bytes:
|
||||
"""Converts a bluetooth mac address string with semicolons to bytes"""
|
||||
str_without_colons = addr.replace(":", "")
|
||||
bytearr = bytearray.fromhex(str_without_colons)
|
||||
bytearr.reverse()
|
||||
return bytes(bytearr)
|
||||
|
||||
def decode(self, addr: str):
|
||||
"""addr is a bluetooth address as a string e.g. 4d:24:12:12:34:10"""
|
||||
for irk, name in self.irk_to_devicename.items():
|
||||
if self.resolve_rpa(self.addr_to_bytes(addr), irk):
|
||||
return name
|
||||
return self.addr_to_name.get(addr, None)
|
||||
|
||||
|
||||
server = "homeassistant.fritz.box"
|
||||
username = "my_btmonitor"
|
||||
password = "8aBIAC14jaKKbla"
|
||||
|
||||
|
||||
async def collect_data_from_mqtt_into_csv():
|
||||
now = datetime.now()
|
||||
with open(f"logfile_{now}.csv", "w") as csv_file:
|
||||
print(f"# time,device,room,rssi,tx_power,real_room", file=csv_file)
|
||||
async with aiomqtt.Client(hostname=server, username=username, password=password) as client:
|
||||
real_room = "?"
|
||||
await client.subscribe("my_btmonitor/#")
|
||||
async for message in client.messages:
|
||||
current_time = time()
|
||||
topic = message.topic
|
||||
if topic.value == "my_btmonitor/real_room":
|
||||
print(f"Changing real room from {real_room} to {message.payload}")
|
||||
real_room = message.payload.decode()
|
||||
else:
|
||||
splitted_topic = message.topic.value.split("/")
|
||||
if splitted_topic[0] == "my_btmonitor" and splitted_topic[1] == "devices":
|
||||
device = splitted_topic[2]
|
||||
room = splitted_topic[3]
|
||||
msg_json = json.loads(message.payload)
|
||||
rssi = msg_json.get("rssi", -1)
|
||||
tx_power = msg_json.get("tx_power", -1)
|
||||
if real_room is not None and real_room != "keins":
|
||||
print(
|
||||
f"{current_time},{device},{room},{rssi},{tx_power},{real_room}",
|
||||
file=csv_file,
|
||||
)
|
||||
print(f"{current_time},{device},{room},{rssi},{tx_power},{real_room}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(collect_data_from_mqtt_into_csv())
|
||||
@@ -117,7 +117,11 @@ async def on_device_found_callback(irks, mqtt_client, room, device, advertising_
|
||||
"distance": filtered_distance,
|
||||
"unfiltered_distance": distance,
|
||||
}
|
||||
await mqtt_client.publish(topic, json.dumps(data).encode())
|
||||
try:
|
||||
await mqtt_client.publish(topic, json.dumps(data).encode())
|
||||
except Exception:
|
||||
print("Probably mqtt isn't running - exit whole script and let systemd restart it")
|
||||
exit(1)
|
||||
#print(data)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user