Compare commits

...

4 Commits

Author SHA1 Message Date
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
22 changed files with 56773 additions and 144 deletions

2
.gitignore vendored
View File

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

View File

@ -1,5 +1,8 @@
{
"python.linting.pylintEnabled": false,
"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
- bluetooth-monitor
- hosts: musicmouse
roles:
- pi-standard-setup
- pi-hifiberry-amp
- pi-musicmouse
- pi-squeezelite-custom
- pi-shairport
- pi-lirc
- bluetooth-monitor
#- hosts: octopi
# roles:
# - pi-dhtsensor
#- hosts: newrpi
# roles:
# - pi-standard-setup
# - pi-lirc

View File

@ -12,12 +12,16 @@ all:
sensor_room_name_ascii: prusaprinter
sensor_room_name: prusaprinter
dht_pin: 26
main_user: root
bedroompi:
squeezelite_name: BedroomPi
shairport_name: BedroomPi
alsa_card_name: Codec
sensor_room_name_ascii: schlafzimmer
sensor_room_name: Schlafzimmer
my_bt_monitor_watchdog_seconds: 600
my_btmonitor_restart_ble_interface: hci0
main_user: root
kitchenpi:
squeezelite_name: KitchenPi
shairport_name: KitchenPi
@ -25,6 +29,7 @@ all:
sensor_room_name_ascii: kueche
sensor_room_name: Küche
hifiberry_overlay: hifiberry-amp
main_user: root
esszimmerradio: # oben, eltern
squeezelite_name: Esszimmer
shairport_name: _Oben_Esszimmer
@ -32,6 +37,7 @@ all:
squeezeserver: 192.168.178.100
configure_wifi: true
alsa_card_name: 1
main_user: root
musikserverwohnzimmeroben: # oben, eltern
squeezelite_name: Wohnzimmer
shairport_name: _Oben_Wohnzimmer
@ -40,12 +46,15 @@ all:
sensor_room_name_ascii: wohnzimmeroben
sensor_room_name: WohnzimmerOben
hifiberry_overlay: hifiberry-dacplus
my_btmonitor_pl0: 68 # default is 73 - increase to make more sensitve (i.e. lower distances)
main_user: root
musicmouse:
squeezelite_name: MusicMouse
shairport_name: MusicMouse
alsa_card_name: 0
alsa_card_name: 1
hifiberry_overlay: hifiberry-dacplus
sensor_room_name: Kinderzimmer
sensor_room_name_ascii: kinderzimmer
main_user: root
newrpi:
squeezelite_name: MyTestRaspberry
shairport_name: MyTestRaspberry
@ -56,7 +65,12 @@ all:
server:
sensor_room_name: 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:
ansible_user: root
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.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from Cryptodome.Cipher import AES
from functools import partial
import asyncio_mqtt
import json
from typing import Dict
import collections
import numpy as np
from datetime import datetime
import os
import time
# ------------------- Config ----------------------------------------------------------------
@ -21,116 +20,51 @@ config = {
"password": "{{my_btmonitor_mqtt_password}}",
"room": "{{sensor_room_name_ascii}}"
},
"irk_to_devicename": {
"aa67542b82c0e05d65c27fb7e313aba5": "martins_apple_watch",
"840e3892644c1ebd1594a9069c14ce0d" : "martins_iphone",
}
"watchdog_seconds": {{my_bt_monitor_watchdog_seconds | default(None)}},
"restart_ble_interface": {{my_btmonitor_restart_ble_interface | default(None)}},
}
# --------------------------------------------------------------------------------------------
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]
stop_event = asyncio.Event()
time_last_package_received = datetime.now()
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_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)
async def on_device_found_callback(mqtt_client, room, device, advertising_data):
global time_last_package_received
time_last_package_received = datetime.now()
rssi = advertising_data.rssi
tx_power = advertising_data.tx_power
if decoded_device_id and tx_power is not None and rssi is not None:
topic = f"my_btmonitor/devices/{decoded_device_id}/{room}"
distance = estimate_distance(rssi, tx_power, {{my_btmonitor_pl0 | default('73')}} )
filtered_distance = filter_distance(distance)
data = {"id": decoded_device_id,
"name": decoded_device_id,
if tx_power is not None and rssi is not None:
topic = f"my_btmonitor/raw_measurements/{room}"
data = {"address": device.address,
"rssi": rssi,
"tx_power": tx_power,
"distance": filtered_distance,
"unfiltered_distance": distance,
}
await mqtt_client.publish(topic, json.dumps(data).encode())
#print(data)
"tx_power": tx_power}
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)
async def main():
stop_event = asyncio.Event()
async def watchdog():
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']
while True:
try:
async with asyncio_mqtt.Client(hostname=mqtt_conf["hostname"],
username=mqtt_conf["username"],
password=mqtt_conf['password']) as mqtt_client:
cb = partial(on_device_found_callback, config['irk_to_devicename'], mqtt_client, mqtt_conf['room'])
username=mqtt_conf["username"],
password=mqtt_conf['password']) as mqtt_client:
cb = partial(on_device_found_callback, mqtt_client, mqtt_conf['room'])
active_scan = True
if active_scan:
async with BleakScanner(cb) as scanner:
@ -146,4 +80,18 @@ async def main():
print("Error", e)
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

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]
Discoverable=false
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
- 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