Compare commits

..

5 Commits

Author SHA1 Message Date
7c70221723 IR stuff update 2025-01-06 18:01:18 +01:00
Martin Bauer
9092f08481 Bt monitor 2024-07-28 08:45:01 +02:00
Martin Bauer
e9ec94a5f8 Bluetooth Monitor WIP 2024-03-29 09:32:59 +01:00
Martin Bauer
fe744b2285 bt monitor 2024-03-08 13:02:55 +01:00
Martin Bauer
ffeee72652 music mouse setup 2024-03-08 13:02:43 +01:00
27 changed files with 57134 additions and 146 deletions

2
.gitignore vendored
View File

@@ -5,4 +5,4 @@ ve_*
/music /music
__pycache__ __pycache__
/roles/pi-squeezeserver/backup /roles/pi-squeezeserver/backup
venv venv*

View File

@@ -1,5 +1,8 @@
{ {
"python.linting.pylintEnabled": false, "python.linting.pylintEnabled": false,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.linting.flake8Enabled": true "python.linting.flake8Enabled": true,
"files.associations": {
"*.yaml": "home-assistant"
}
} }

View File

@@ -41,13 +41,21 @@
- pi-disable-onboard-bluetooth - pi-disable-onboard-bluetooth
- bluetooth-monitor - bluetooth-monitor
- hosts: musicmouse
roles:
- pi-standard-setup
- pi-hifiberry-amp
- pi-musicmouse
- pi-squeezelite-custom
- pi-shairport
- pi-lirc
- bluetooth-monitor
#- hosts: octopi #- hosts: octopi
# roles: # roles:
# - pi-dhtsensor # - pi-dhtsensor
#- hosts: newrpi #- hosts: newrpi
# roles: # roles:
# - pi-standard-setup # - pi-standard-setup
# - pi-lirc # - pi-lirc

View File

@@ -12,12 +12,16 @@ all:
sensor_room_name_ascii: prusaprinter sensor_room_name_ascii: prusaprinter
sensor_room_name: prusaprinter sensor_room_name: prusaprinter
dht_pin: 26 dht_pin: 26
main_user: root
bedroompi: bedroompi:
squeezelite_name: BedroomPi squeezelite_name: BedroomPi
shairport_name: BedroomPi shairport_name: BedroomPi
alsa_card_name: Codec alsa_card_name: Codec
sensor_room_name_ascii: schlafzimmer sensor_room_name_ascii: schlafzimmer
sensor_room_name: Schlafzimmer sensor_room_name: Schlafzimmer
my_bt_monitor_watchdog_seconds: 600
my_btmonitor_restart_ble_interface: hci0
main_user: root
kitchenpi: kitchenpi:
squeezelite_name: KitchenPi squeezelite_name: KitchenPi
shairport_name: KitchenPi shairport_name: KitchenPi
@@ -25,6 +29,7 @@ all:
sensor_room_name_ascii: kueche sensor_room_name_ascii: kueche
sensor_room_name: Küche sensor_room_name: Küche
hifiberry_overlay: hifiberry-amp hifiberry_overlay: hifiberry-amp
main_user: root
esszimmerradio: # oben, eltern esszimmerradio: # oben, eltern
squeezelite_name: Esszimmer squeezelite_name: Esszimmer
shairport_name: _Oben_Esszimmer shairport_name: _Oben_Esszimmer
@@ -32,6 +37,7 @@ all:
squeezeserver: 192.168.178.100 squeezeserver: 192.168.178.100
configure_wifi: true configure_wifi: true
alsa_card_name: 1 alsa_card_name: 1
main_user: root
musikserverwohnzimmeroben: # oben, eltern musikserverwohnzimmeroben: # oben, eltern
squeezelite_name: Wohnzimmer squeezelite_name: Wohnzimmer
shairport_name: _Oben_Wohnzimmer shairport_name: _Oben_Wohnzimmer
@@ -40,12 +46,15 @@ all:
sensor_room_name_ascii: wohnzimmeroben sensor_room_name_ascii: wohnzimmeroben
sensor_room_name: WohnzimmerOben sensor_room_name: WohnzimmerOben
hifiberry_overlay: hifiberry-dacplus hifiberry_overlay: hifiberry-dacplus
my_btmonitor_pl0: 68 # default is 73 - increase to make more sensitve (i.e. lower distances) main_user: root
musicmouse: musicmouse:
squeezelite_name: MusicMouse squeezelite_name: MusicMouse
shairport_name: MusicMouse shairport_name: MusicMouse
alsa_card_name: 0 alsa_card_name: 1
hifiberry_overlay: hifiberry-dacplus hifiberry_overlay: hifiberry-dacplus
sensor_room_name: Kinderzimmer
sensor_room_name_ascii: kinderzimmer
main_user: root
newrpi: newrpi:
squeezelite_name: MyTestRaspberry squeezelite_name: MyTestRaspberry
shairport_name: MyTestRaspberry shairport_name: MyTestRaspberry
@@ -56,7 +65,12 @@ all:
server: server:
sensor_room_name: Arbeitszimmer sensor_room_name: Arbeitszimmer
sensor_room_name_ascii: arbeitszimmer sensor_room_name_ascii: arbeitszimmer
my_btmonitor_pl0: 78 my_bt_monitor_watchdog_seconds: 600
my_btmonitor_restart_ble_interface: hci0
main_user: core
homeassistant:
sensor_room_name: Anschlussraum
sensor_room_name_ascii: anschlussraum
vars: vars:
ansible_user: root ansible_user: root
ansible_python_interpreter: /usr/bin/python3 ansible_python_interpreter: /usr/bin/python3

View File

@@ -0,0 +1,3 @@
function dot -w git -d "Manages dotfiles"
git --git-dir=$HOME/.dot --work-tree=$HOME $argv
end

View File

@@ -0,0 +1,47 @@
---
#
- name: Install packages
apt:
name:
- bat
- broot
- duf
- fd-find
- fish
- fzf
- git
- glances
- lsd
- neovim
- ripgrep
- tmux
- zoxide
# TODO: Dust, btm
block:
become: true
become_user: {{main_user}}
- name: Create bin folder
ansible.builtin.file:
path: ~/bin
state: directory
mode: '0755'
# Oh-my-fish
- name: get oh-my-fish repo
git:
repo: 'https://github.com/oh-my-fish/oh-my-fish.git'
dest: ~/.local/share/git/oh-my-fish
- name: install oh-my-fish
shell:
cmd: "bin/install --offline --noninteractive"
executable: /usr/bin/fish
chdir: ~/.local/share/git/oh-my-fish
creates:
- ~/.local/share/omf
- ~/.config/omf
- copy:
content: "bobthefish"
dst: "~/.config/omf/theme"

View File

@@ -0,0 +1,11 @@
FROM python:3
WORKDIR /usr/src/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY bt_monitor_server.py .
COPY training_data.csv .
CMD [ "python", "./bt_monitor_server.py" ]

View 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))

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
import os
import aiomqtt
import json
import asyncio
from time import time
from pathlib import Path
from collections import namedtuple, defaultdict, deque
from typing import Dict, Optional, List
from Crypto.Cipher import AES
import pandas as pd
import numpy as np
import logging
from sklearn import svm
from sklearn.model_selection import cross_val_score
logging.basicConfig(level=logging.INFO)
BtleMeasurement = namedtuple("BtleMeasurement", ["time", "tracker", "address", "rssi", "tx_power"])
BtleDeviceMeasurement = namedtuple("BtleDeviceMeasurement", ["time", "device", "tracker", "rssi", "tx_power"])
MqttInfo = namedtuple("MqttInfo", ["server", "username", "password"])
# ------------------------------------------------------- DECODING -------------------------------------------------------------------------
class DeviceDecoder:
"""Decode bluetooth addresses - either simple ones (just address to name) or random changing ones like Apple devices using irk keys"""
def __init__(self, irk_to_devicename: Dict[str, str], address_to_name: Dict[str, str]):
"""
address_to_name: dictionary from bt address as string separated by ":" to a device name
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 decode(self, addr: str) -> Optional[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 DeviceDecoder._resolve_rpa(DeviceDecoder._addr_to_bytes(addr), irk):
return name
return self.address_to_name.get(addr, None)
def __call__(self, m: BtleMeasurement) -> Optional[BtleDeviceMeasurement]:
decoded_device_name = self.decode(m.address)
if not decoded_device_name:
return None
return BtleDeviceMeasurement(m.time, decoded_device_name, m.tracker, m.rssi, m.tx_power)
# ------------------------------------------------------- MACHINE LEARNING ----------------------------------------------------------------
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,known_room", file=csv_file)
def update_known_room(self, known_room: str):
if known_room != self.known_room:
logging.info(f"Updating known_room {self.known_room} -> {known_room}")
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
logging.info(f"Appending to training set: {m}")
print(
f"{m.time},{m.device},{m.tracker},{m.rssi},{m.tx_power},{self.known_room}",
file=self.csv_file_handle,)
class RunningFeatureVector:
FAR_AWAY_FEATURE_VALUE = 1
MIN_TIME_UNTIL_PREDICTION = 40 # wait until every reachable tracker detected the device
TIME_TO_DELETE_IF_NOT_SEEN = 30 # if device wasn't spotted for this time period, the measure is set to inf
def __init__(self, trackers: List[str]):
self.trackers = trackers
self.feature_vecs_per_device = defaultdict(lambda: [self.FAR_AWAY_FEATURE_VALUE] * len(trackers))
self.last_measurements = deque()
self.tracker_name_to_idx = {name: i for i, name in enumerate(trackers)}
self.start_time = None
@staticmethod
def _get_feature_value(rssi, tx_power):
"""Transforms rssi and tx power into a value between 0 and 1, where 0 is close and 1 is far away"""
MIN_RSSI = -90
MAX_TRANSFORMED_RSSI = 40
v = tx_power - rssi - MAX_TRANSFORMED_RSSI
if v < 0:
v = 0
return v / (-MIN_RSSI)
def add_measurement(self, new_measurement: BtleDeviceMeasurement):
if self.start_time is None:
self.start_time = new_measurement.time
self.last_measurements.append(new_measurement)
while len(self.last_measurements) > 0 and new_measurement.time - self.last_measurements[0].time > self.TIME_TO_DELETE_IF_NOT_SEEN:
self.last_measurements.popleft()
feature_vec = [self.FAR_AWAY_FEATURE_VALUE] * len(self.trackers)
for m in self.last_measurements:
if m.device == new_measurement.device:
tracker_idx = self.tracker_name_to_idx[m.tracker]
feature_vec[tracker_idx] = self._get_feature_value(m.rssi, m.tx_power)
return feature_vec if new_measurement.time - self.start_time > self.MIN_TIME_UNTIL_PREDICTION else None
def training_data_from_df(df: pd.DataFrame, device_to_train: str):
"""Returns a feature matrix (num_measurement, num_trackers) and a label vector (both numeric) to be used in scikit learn"""
trackers = list(df["tracker"].cat.categories)
idx_to_room = dict(enumerate(df["known_room"].cat.categories))
room_to_idx = {v: k for k, v in idx_to_room.items()}
last_known_room = None
features = []
labels = []
feature_accumulator = RunningFeatureVector(trackers)
# Feature vectors - rssi column for each room
for i, row in df.iterrows():
time, device, tracker, rssi, tx_power, known_room = row
m = BtleDeviceMeasurement(time, device, tracker, rssi, tx_power)
if device != device_to_train:
continue
if last_known_room != known_room:
feature_accumulator = RunningFeatureVector(trackers) # reset for new room
last_known_room = known_room
feature_vec = feature_accumulator.add_measurement(m)
if feature_vec is not None:
features.append(feature_vec)
labels.append(room_to_idx[known_room])
return np.array(features), np.array(labels)
def load_measurements_from_csv(csv_file: Path) -> pd.DataFrame:
"""Load csv with training data into dataframe"""
def cleanup_column_name(col_name: str):
return col_name.replace("#", "").strip()
df = pd.read_csv(str(csv_file))
# String cleanup in column names and room names
df = df.rename(columns=cleanup_column_name)
df.map(lambda x: x.strip() if isinstance(x, str) else x)
df["tracker"] = df["tracker"].astype("category")
df["known_room"] = df["known_room"].astype("category")
df['device'] = df['device'].astype("category")
return df
async def send_discovery_messages(mqtt_client, device_names):
for device_name in device_names:
topic = f"homeassistant/sensor/my_btmonitor/{device_name}/config"
msg = {
"name": device_name,
"state_topic": f"my_btmonitor/ml/{device_name}",
"expire_after": 30,
"unique_id": device_name,
}
await mqtt_client.publish(topic, json.dumps(msg).encode(), retain=True)
async def async_main(
mqtt_info: MqttInfo,
trackers: List[str],
devices: List[str],
classifier,
device_decoder: DeviceDecoder,
training_data_logger: KnownRoomCsvLogger,
):
current_rooms = defaultdict(lambda: "unknown")
feature_accumulator = RunningFeatureVector(trackers)
async with aiomqtt.Client(
hostname=mqtt_info.server, username=mqtt_info.username, password=mqtt_info.password
) as client:
await send_discovery_messages(client, devices)
await client.subscribe("my_btmonitor/#")
async for message in client.messages:
current_time = time()
topic = message.topic
if topic.value == "my_btmonitor/known_room":
training_data_logger.update_known_room(message.payload.decode())
else:
splitted_topic = message.topic.value.split("/")
if splitted_topic[0] == "my_btmonitor" and splitted_topic[1] == "raw_measurements":
msg_json = json.loads(message.payload)
measurement = BtleMeasurement(
time=current_time,
tracker=splitted_topic[2],
address=msg_json["address"],
rssi=msg_json["rssi"],
tx_power=msg_json.get("tx_power", 0),
)
logging.debug(f"Got Measurement {measurement}")
m = device_decoder(measurement)
if m is not None:
logging.info(f"Decoded Measurement {m}")
training_data_logger.report_measure(m)
feature_vec =feature_accumulator.add_measurement(m)
if feature_vec:
feature_str={tracker : value for tracker, value in zip(trackers, feature_vec)}
logging.info(f"Features: {feature_str}")
if feature_vec is not None and classifier is not None:
room = classifier(m.device, feature_vec)
if room != current_rooms[m.device]:
logging.info(f"{m.device} moved room {current_rooms[m.device]} to {room}")
current_rooms[m.device] = room
await client.publish(f"my_btmonitor/ml/{m.device}", room.encode())
async def async_main_with_restart(
mqtt_info: MqttInfo,
trackers: List[str],
devices: List[str],
classifier,
device_decoder: DeviceDecoder,
training_data_logger: KnownRoomCsvLogger,
):
while True:
try:
await async_main(mqtt_info, trackers, devices, classifier, device_decoder, training_data_logger)
except Exception as e:
print(e)
print("restarting...")
def get_classification_func(training_df: pd.DataFrame, log_classifier_scores=True):
devices_to_track = list(training_df["device"].unique())
classifiers = {}
rooms = list(training_df["known_room"].dtype.categories)
for device_to_track in devices_to_track:
features, labels = training_data_from_df(training_df, device_to_track)
clf = svm.SVC(kernel="rbf")
logging.info(f"Computing cross validation score for {device_to_track}")
if log_classifier_scores:
scores = cross_val_score(clf, features, labels, cv=5)
logging.info(" %0.2f accuracy with a standard deviation of %0.2f" % (scores.mean(), scores.std()))
logging.info(f"Training SVM classifier for {device_to_track}")
clf.fit(features, labels)
classifiers[device_to_track] = clf
def classify(device_name, feature_vec):
room_idx = classifiers[device_name].predict([feature_vec])[0]
return rooms[room_idx]
return classify
if __name__ == "__main__":
mqtt_info = MqttInfo(server="homeassistant.fritz.box", username="my_btmonitor", password="8aBIAC14jaKKbla")
# Dict with bt addresses as strings to device name
address_to_name = {}
# Devices with random addresses - need irk key
irk_to_devicename = {
"aa67542b82c0e05d65c27fb7e313aba5": "martins_apple_watch",
"840e3892644c1ebd1594a9069c14ce0d": "martins_iphone",
}
script_path = os.path.dirname(os.path.realpath(__file__))
data_file = Path(script_path) / Path("training_data.csv")
training_df = load_measurements_from_csv(data_file)
classification_func = get_classification_func(training_df)
training_data_logger = KnownRoomCsvLogger(data_file)
device_decoder = DeviceDecoder(irk_to_devicename, address_to_name)
trackers = list(training_df["tracker"].cat.categories)
devices = list(training_df['device'].cat.categories)
asyncio.run(async_main_with_restart(mqtt_info, trackers, devices, classification_func, device_decoder, training_data_logger))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
aiomqtt==2.0.0
numpy==1.26.4
pandas==2.2.1
pycryptodome==3.20.0
scikit-learn==1.4.1.post1
scipy==1.12.0
typing_extensions==4.10.0

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,12 @@ from bleak import BleakScanner
from bleak.assigned_numbers import AdvertisementDataType from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.bluezdbus.advertisement_monitor import OrPattern from bleak.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from Cryptodome.Cipher import AES
from functools import partial from functools import partial
import asyncio_mqtt import asyncio_mqtt
import json import json
from typing import Dict from datetime import datetime
import collections import os
import numpy as np import time
# ------------------- Config ---------------------------------------------------------------- # ------------------- Config ----------------------------------------------------------------
@@ -21,116 +20,51 @@ config = {
"password": "{{my_btmonitor_mqtt_password}}", "password": "{{my_btmonitor_mqtt_password}}",
"room": "{{sensor_room_name_ascii}}" "room": "{{sensor_room_name_ascii}}"
}, },
"irk_to_devicename": { "watchdog_seconds": {{my_bt_monitor_watchdog_seconds | default(None)}},
"aa67542b82c0e05d65c27fb7e313aba5": "martins_apple_watch", "restart_ble_interface": {{my_btmonitor_restart_ble_interface | default(None)}},
"840e3892644c1ebd1594a9069c14ce0d" : "martins_iphone",
}
} }
# -------------------------------------------------------------------------------------------- stop_event = asyncio.Event()
time_last_package_received = datetime.now()
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: async def on_device_found_callback(mqtt_client, room, device, advertising_data):
"""Converts a bluetooth mac address string with semicolons to bytes""" global time_last_package_received
str_without_colons = addr.replace(":", "") time_last_package_received = datetime.now()
bytearr = bytearray.fromhex(str_without_colons)
bytearr.reverse()
return bytes(bytearr)
def decode_address(addr: str, irks: Dict[str, str]):
"""
addr is a bluetooth address as a string e.g. 4d:24:12:12:34:10
irks is dict with irk as a hex string, mapping to device name
"""
for irk, name in irks.items():
if resolve_rpa(addr_to_bytes(addr), bytes.fromhex(irk)):
return name
return None
def estimate_distance(rssi, tx_power, pl0=73):
"""
RSSI in dBm
txPower is a transmitter parameter that calculated according to its physic layer and antenna in dBm
Return value in meter
You should calculate "PL0" in calibration stage:
PL0 = txPower - RSSI; // When distance is distance0 (distance0 = 1m or more)
SO, RSSI will be calculated by below formula:
RSSI = txPower - PL0 - 10 * n * log(distance/distance0) - G(t)
G(t) ~= 0 //This parameter is the main challenge in achiving to more accuracy.
n = 2 (Path Loss Exponent, in the free space is 2)
distance0 = 1 (m)
distance = 10 ^ ((txPower - RSSI - PL0 ) / (10 * n))
Read more details:
https://en.wikipedia.org/wiki/Log-distance_path_loss_model
"""
n = 3.5
return 10**((tx_power - rssi - pl0) / (10 * n))
def smooth(y, box_pts):
box = np.ones(box_pts)/box_pts
y_smooth = np.convolve(y, box, mode='same')
return y_smooth
WINDOW_SIZE = 6
last_values = collections.deque(maxlen=WINDOW_SIZE)
def filter_distance(dist: float):
global last_values
last_values.append(dist)
return smooth(np.array(last_values), 12)[-1]
async def on_device_found_callback(irks, mqtt_client, room, device, advertising_data):
decoded_device_id = decode_address(device.address, irks)
rssi = advertising_data.rssi rssi = advertising_data.rssi
tx_power = advertising_data.tx_power tx_power = advertising_data.tx_power
if decoded_device_id and tx_power is not None and rssi is not None: if tx_power is not None and rssi is not None:
topic = f"my_btmonitor/devices/{decoded_device_id}/{room}" topic = f"my_btmonitor/raw_measurements/{room}"
distance = estimate_distance(rssi, tx_power, {{my_btmonitor_pl0 | default('73')}} ) data = {"address": device.address,
filtered_distance = filter_distance(distance)
data = {"id": decoded_device_id,
"name": decoded_device_id,
"rssi": rssi, "rssi": rssi,
"tx_power": tx_power, "tx_power": tx_power}
"distance": filtered_distance, try:
"unfiltered_distance": distance,
}
await mqtt_client.publish(topic, json.dumps(data).encode()) await mqtt_client.publish(topic, json.dumps(data).encode())
#print(data) except Exception:
print("Probably mqtt isn't running - exit whole script and let systemd restart it")
exit(1)
async def main(): async def watchdog():
stop_event = asyncio.Event() global time_last_package_received
timeout = config["watchdog_seconds"]
if not timeout or timeout <= 0:
return
while True:
restart = (datetime.now() - time_last_package_received).seconds > timeout
if restart:
stop_event.set()
await asyncio.sleep(60)
async def ble_scan():
mqtt_conf = config['mqtt'] mqtt_conf = config['mqtt']
while True: while True:
try: try:
async with asyncio_mqtt.Client(hostname=mqtt_conf["hostname"], async with asyncio_mqtt.Client(hostname=mqtt_conf["hostname"],
username=mqtt_conf["username"], username=mqtt_conf["username"],
password=mqtt_conf['password']) as mqtt_client: password=mqtt_conf['password']) as mqtt_client:
cb = partial(on_device_found_callback, config['irk_to_devicename'], mqtt_client, mqtt_conf['room']) cb = partial(on_device_found_callback, mqtt_client, mqtt_conf['room'])
active_scan = True active_scan = True
if active_scan: if active_scan:
async with BleakScanner(cb) as scanner: async with BleakScanner(cb) as scanner:
@@ -146,4 +80,18 @@ async def main():
print("Error", e) print("Error", e)
print("Starting again") print("Starting again")
asyncio.run(main())
async def main():
await asyncio.gather(ble_scan(), watchdog())
if __name__ == "__main__":
restart_interface = config["restart_ble_interface"]
if restart_interface:
print(f"Restarting {restart_interface}")
os.system(f"hciconfig {restart_interface} down")
time.sleep(3)
os.system(f"hciconfig {restart_interface} up")
time.sleep(3)
print("Done")
asyncio.run(main())

View File

@@ -1,36 +0,0 @@
---
- name: Install packages
apt:
name:
- bat
- fish
- fzf
- fd-find
- ripgrep
- lsd
- zoxide
- name: Check if oh-my-fish is installed
stat:
path: '/etc/omf.installed'
register: omf
- name: Download omf installer
get_url:
url: https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install
- name: Execute omf installer
shell: /usr/bin/fish /tmp/install --noninteractive
- name: Execute omf installer
shell: /usr/bin/fish -c omf install
- name: Remove the omf installer
file:
path: /tmp/install
state: absent
- name: Mark oh-my-fish installed with /etc/omf.installed
file:
path: /etc/omf.installed
state: touch

Binary file not shown.

View File

@@ -7,3 +7,11 @@ make arm_noccfgcc
-> linker errors with libzip -> linker errors with libzip
-> installed libzip-dev and added lzip to linker parameters -> installed libzip-dev and added lzip to linker parameters
mkdir tmp
cd tmp
tar xf ../irserver-src.tar.gz
mkdir arm
# in makefile add: LDFLAGS = -lzip
make irserver_arm_noccf

Binary file not shown.

View File

@@ -1 +1,2 @@
include "hauppauge.conf" include "hauppauge.conf"
include "small_led_remote.conf"

View File

@@ -0,0 +1,350 @@
begin remote
name small_led_remote
flags RAW_CODES|CONST_LENGTH
eps 30
aeps 100
gap 108076
begin raw_codes
name ON
9257 4452 619 534 611 510
637 509 611 535 611 510
663 485 609 535 635 486
635 1637 640 1610 613 1636
641 1637 637 485 663 1611
615 1637 613 1637 666 1612
639 1612 615 537 608 509
661 486 609 535 636 485
637 510 609 535 611 510
662 1628 599 1636 640 1612
642 1637 639 1613 615 1637
642
name OFF
9068 4464 602 539 597 511
625 511 598 537 599 511
625 512 597 537 599 513
624 1643 599 1640 604 1640
630 1641 603 511 626 1642
603 1641 627 1618 630 511
599 1641 632 512 599 538
598 512 626 511 599 539
598 512 625 1641 603 511
626 1642 604 1641 603 1641
655 1617 603 1641 604 1640
629
name RED
9237 4461 602 547 597 519
625 521 597 545 600 518
626 520 598 547 596 521
624 1646 603 1646 602 1645
631 1647 601 524 621 1645
605 1645 604 1647 629 522
595 548 596 1645 604 550
594 525 620 520 597 548
596 523 622 1649 601 1647
602 547 599 1643 606 1644
631 1645 604 1645 604 1645
631
name GREEN
9103 4437 624 513 622 487
648 487 622 512 622 487
648 487 622 513 621 487
652 1616 621 1619 623 1617
651 1617 624 486 649 1618
624 1616 624 1617 651 1617
625 486 649 1618 624 486
649 486 622 512 623 486
648 486 622 512 623 1617
625 511 623 1618 623 1618
651 1617 624 1618 625 1617
651
name BLUE
9024 4501 609 524 613 494
638 498 610 522 611 498
634 501 610 523 613 496
635 1632 563 1680 560 1680
640 1629 609 499 675 1594
646 1596 620 1622 647 488
619 1621 647 1621 620 489
645 490 618 517 613 495
640 494 562 1679 589 547
562 572 564 1677 563 1679
589 1678 610 1632 608 1634
641
name WHITE
9083 4471 590 547 586 523
614 522 587 548 588 520
614 521 590 545 588 520
616 1650 591 1651 590 1652
618 1650 591 521 615 1649
592 1651 592 1649 618 1650
591 1650 591 1651 616 521
588 547 586 523 614 524
584 550 586 520 615 520
587 547 587 1652 589 1655
616 1650 589 1653 591 1655
615
name 1_ORANGE
9076 4445 562 581 600 513
618 515 595 530 554 553
623 514 596 532 600 509
581 1683 604 1637 601 1637
633 1636 604 502 584 1710
577 1638 555 1686 580 563
544 584 598 512 619 1638
556 563 571 563 543 592
588 510 623 1637 603 1636
556 1687 580 559 548 1682
584 1686 555 1684 556 1684
583
name 4_ORANGE
9158 4444 620 517 621 490
648 490 620 517 621 490
647 491 620 517 621 490
647 1624 620 1622 623 1622
651 1622 624 491 647 1622
623 1621 623 1622 650 491
620 518 620 1622 622 1623
648 493 619 519 619 493
621 518 593 1647 629 1644
596 517 621 516 593 1648
624 1646 598 1647 597 1647
625
name 7_YELLOW
9092 4468 593 543 592 518
619 517 592 544 593 515
621 518 592 543 592 518
620 1647 593 1650 596 1648
621 1647 596 517 619 1647
601 1642 595 1648 623 516
593 543 593 517 620 516
592 1649 621 518 591 544
594 516 619 1647 595 1662
582 1646 622 1647 596 517
618 1647 595 1648 595 1649
621
name STAR_YELLOW
9012 4468 589 544 587 518
613 518 588 543 586 519
615 516 587 544 586 522
608 1651 588 1650 588 1650
613 1650 588 520 612 1649
588 1649 588 1650 614 520
587 547 583 1649 588 543
589 1650 588 545 587 517
613 518 587 1649 614 1651
586 518 613 1650 589 517
611 1652 587 1650 588 1650
613
name 2_GREEN
9022 4497 569 566 561 546
587 546 561 573 561 547
587 547 561 571 562 546
589 1679 562 1677 564 1677
591 1678 564 546 588 1679
563 1678 564 1678 639 1631
565 546 588 546 561 1678
638 497 610 525 604 501
637 495 613 521 560 1679
563 1678 590 549 609 1626
589 1678 606 1636 561 1678
590
name 8_LIGHT_BLUE
9062 4473 589 548 587 521
615 520 586 548 588 519
614 521 586 547 587 520
615 1650 588 1653 589 1651
616 1651 588 522 612 1653
585 1655 536 1704 608 1659
537 572 561 572 533 600
546 1691 536 599 534 573
560 573 533 600 531 1706
581 1659 612 1654 586 522
609 1655 587 1653 585 1653
615
name 0_LIGHT_BLUE
9001 4491 564 569 563 543
589 542 563 569 563 543
589 542 563 569 563 542
589 1674 565 1674 565 1674
590 1674 564 543 588 1675
562 1676 535 1704 560 1703
535 572 560 1704 535 572
559 1703 535 574 557 572
533 599 533 580 552 1703
535 570 561 1703 535 572
560 1703 535 1703 535 1703
561
name 3_LIGHT_BLUE
9031 4460 596 536 596 510
622 510 595 537 594 510
622 510 618 514 593 511
621 1643 595 1643 596 1643
621 1643 595 519 612 1644
595 1643 595 1644 622 512
593 1645 620 514 591 1644
622 513 592 539 593 513
619 513 592 1644 621 513
592 1643 622 512 593 1643
622 1642 597 1642 597 1642
623
name 6_PURPLE
9015 4472 589 545 586 519
611 521 589 543 584 522
611 526 577 549 585 519
614 1650 588 1651 590 1649
614 1651 588 519 613 1649
590 1648 586 1654 613 521
584 1653 613 1652 586 1654
586 547 586 520 613 520
584 547 585 1652 588 545
585 521 616 516 586 1652
610 1655 586 1651 588 1651
614
name HASH_PINK
9048 4457 600 538 594 508
625 508 598 534 598 509
625 508 598 535 598 508
624 1638 600 1639 600 1640
627 1638 600 508 624 1641
599 1638 600 1639 626 509
597 1639 626 1640 600 509
623 1639 599 509 624 509
597 535 597 1639 600 535
597 509 623 1639 600 509
623 1639 600 1639 599 1640
626
name 5_BLUE
9065 4404 618 505 619 505
618 506 620 505 618 505
620 505 619 505 621 503
619 1623 620 1622 620 1621
620 1622 620 504 619 1623
620 1623 620 1623 617 1626
619 505 619 1623 620 1622
620 505 619 505 614 510
618 506 618 505 616 1624
619 504 620 504 619 1622
619 1623 618 1624 618 1623
617
name 9_PINK
9047 4402 616 507 618 504
619 504 618 505 618 505
615 508 618 504 619 504
618 1622 618 1622 619 1622
619 1624 618 504 618 1623
618 1623 619 1622 619 505
618 1623 619 504 618 505
620 1622 618 505 619 505
619 502 619 1622 619 506
619 1621 620 1623 618 504
619 1622 619 1622 617 1624
619
name FLASH
9066 4406 637 486 638 485
640 484 637 487 637 485
637 486 638 485 635 488
641 1602 640 1601 633 1608
634 1608 631 492 633 1609
633 1609 634 1607 631 1609
631 1610 630 493 632 1609
638 485 631 491 636 487
637 487 635 487 638 486
630 1611 638 484 632 1609
641 1599 637 1604 629 1612
638
name BRIGHTNESS_DOWN
9059 4403 619 504 619 505
619 506 618 505 618 505
619 504 618 505 619 504
620 1623 617 1624 618 1624
619 1622 619 505 619 1623
618 1623 619 1623 619 1623
619 505 619 505 619 505
617 507 619 503 619 504
619 506 616 507 616 1625
618 1624 619 1623 618 1624
618 1625 618 1623 619 1623
618
name BRIGHTNESS_UP
9055 4404 618 504 619 504
619 504 619 505 618 505
618 505 617 506 618 505
618 1623 619 1622 619 1624
618 1623 619 505 618 1622
619 1623 616 1625 619 505
618 504 619 505 619 504
616 507 618 505 619 504
618 505 618 1623 616 1627
615 1625 618 1623 619 1623
618 1624 618 1622 619 1622
618
name STROBE
9052 4403 618 504 619 504
618 506 615 507 618 504
619 504 619 504 619 504
619 1622 617 1624 619 1622
618 1623 619 504 617 1624
619 1622 619 1623 619 1622
619 1622 619 1622 618 1622
620 504 619 505 618 505
614 509 618 505 614 509
618 504 619 507 614 1625
619 1623 616 1625 619 1622
619
name FADE
9040 4403 616 504 618 504
620 503 618 504 618 505
617 505 616 504 618 504
618 1621 617 1624 614 1625
617 1621 618 504 614 1625
617 1622 618 1622 617 1623
617 1622 618 504 618 504
617 1623 617 504 615 507
616 505 618 503 618 504
618 1622 618 1622 617 505
618 1623 617 1623 617 1622
617
name SMOOTH
9040 4401 619 504 618 504
618 504 617 505 618 503
618 503 618 503 617 505
617 1623 616 1623 618 1623
616 1624 617 505 617 1622
617 1622 616 1624 618 1621
617 1623 614 1623 619 503
617 1622 618 504 618 504
617 504 618 504 617 505
617 505 616 1623 618 504
617 1623 616 1622 618 1621
618
end raw_codes
end remote

View File

@@ -0,0 +1,40 @@
general:
alsa_device: softvol_effects
mqtt:
user: musicmouse
password: KNLEFLZF94yA6Zhj141
server: homeassistant
hass_url: https://ha.bauer.tech
hass_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4N2ExMzM2ZmQ4ZWQ0ZDgzOWZhMjU3NmZjYTg1NWQ1ZiIsImlhdCI6MTcwNDAyOTUyOSwiZXhwIjoyMDE5Mzg5NTI5fQ.vx9L5bTSey98uc8TwodShaEXSMr-hjXugPsNviR_fEw"
button_leds_brightness: 0.5
volume_increment: 5
min_volume: 23
max_volume: 70
serial_port: "/dev/ttyUSB0"
figures:
elefant:
id: "88041174e9"
colors: ["#ffff00", "#00c8ff", "#094b46", "#c20099"]
fuchs:
id: "8804ce7230"
colors: ["#F4D35E", "#F95738", "#F95738", "#083d77"]
eule:
id: "88040d71f0"
colors: ["#e5a200", "#f8e300", "w33", "w99"]
omnom:
id: "88043c6ede"
colors: ["#005102", "#fec800", "#005102", "#3bc405"]
eichhoernchen:
id: "88040b78ff"
colors: ["#ff0ada", "#4BC6B9", "#69045a", "#4BC6B9"]
hund:
id: "8804bc7444"
colors: ["#ffff00", "#00c8ff", "#094b46", "#c20099"]
hase:
id: "88044670ba"
colors: ["#ffff00", "#00c8ff", "#094b46", "#c20099"]
schneemann:
id: "88043f71c2"
colors: ["#ff0ada", "#4BC6B9", "#69045a", "#4BC6B9"]

View File

@@ -0,0 +1,11 @@
[Unit]
Description=MusicMouse Player
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/opt/musicmouse_venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
[global]
workgroup = WORKGROUP
logging = syslog@1
server role = standalone server
obey pam restrictions = no
unix password sync = no
[MusicMouse]
browseable = yes
path = /media/musicmouse
guest ok = no
writeable = yes
create mask = 666
force create mode = 666

View File

@@ -0,0 +1,38 @@
---
- name: Packages
apt:
name:
- python3
- python3-pip
- python3-vlc # also in venv - but this installs vlc + deps
- samba
- name: Checkout Musicmouse repo
ansible.builtin.git:
repo: 'ssh://git@git.bauer.tech:2222/martin/musicmouse.git'
dest: '/opt/musicmouse'
version: 'release/1.1'
accept_hostkey: true
- name: Create and update virtual env
ansible.builtin.pip:
requirements: /opt/musicmouse/espmusicmouse/host_driver/requirements.txt
virtualenv: /opt/musicmouse_venv
virtualenv_command: "/usr/bin/python3 -m venv"
- name: Create media directory
file:
path: /media/musicmouse
state: directory
- name: Install config file
copy: src=config.yml dest=/media/musicmouse/config.yml
- name: Install systemd service file
copy: src=musicmouse.service dest=/etc/systemd/system/
- name: Add script to autostart and start now
systemd: name=musicmouse state=restarted enabled=yes daemon_reload=yes
- name: Samba setup
copy: src=smb.conf dest=/etc/samba/
- name: Restart samba
systemd: name=smbd state=restarted enabled=yes
# manual steps:
# - set samba passwords with smbpasswd
# - copy music into /media/musicmouse (or via samba share)
# - upload host driver?
# - manual patching of hassclient necessary :( loop has to be passed in, to not use running_event_loop

10
todo.md
View File

@@ -23,3 +23,13 @@ dpkg-buildpackage -b -uc
[General] [General]
Discoverable=false Discoverable=false
Alias=bla Alias=bla
Environment:
- fish
- apt install fish
- oh my fish
- fish config
- ripgrep (apt install ripgrep)
- fd (apt install fd-find)
- apt install neovim tmux fd-find ripgrep lsd broot fzf tmux glances

View File

@@ -18,3 +18,16 @@
- pi-disable-onboard-bluetooth - pi-disable-onboard-bluetooth
- bluetooth-monitor - bluetooth-monitor
- hosts: musicmouse
roles:
- pi-standard-setup
- pi-hifiberry-amp
- pi-musicmouse
- pi-squeezelite-custom
- pi-shairport
- pi-lirc
- bluetooth-monitor
- hosts: homeassistant
roles:
- bluetooth-monitor