Skip to content

Commit

Permalink
Listen on host D-Bus for bluetooth
Browse files Browse the repository at this point in the history
  • Loading branch information
steersbob committed Sep 14, 2023
1 parent e182630 commit 1b808d2
Show file tree
Hide file tree
Showing 10 changed files with 376 additions and 507 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- uses: docker/setup-buildx-action@v2
- uses: actions/setup-python@v4
with:
python-version: "3.9"
python-version: "3.11"

- name: Get image metadata
id: meta
Expand Down
7 changes: 4 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
"python.defaultInterpreterPath": ".venv/bin/python",
"python.terminal.activateEnvInCurrentTerminal": true,
"python.terminal.activateEnvironment": true,
"python.linting.flake8Enabled": true,
"python.linting.pylintEnabled": false,
"python.testing.pytestArgs": [
"--no-cov",
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.languageServer": "Pylance"
"python.languageServer": "Pylance",
"[python]": {
"editor.defaultFormatter": "ms-python.autopep8"
}
}
9 changes: 2 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9-bullseye as base
FROM python:3.11-bookworm as base

ENV PIP_EXTRA_INDEX_URL=https://www.piwheels.org/simple
ENV PIP_FIND_LINKS=/wheeley
Expand All @@ -11,17 +11,12 @@ RUN set -ex \
&& pip3 wheel --wheel-dir=/wheeley -r /app/dist/requirements.txt \
&& pip3 wheel --wheel-dir=/wheeley /app/dist/*.tar.gz

FROM python:3.9-slim-bullseye
FROM python:3.11-slim-bookworm
WORKDIR /app

COPY --from=base /wheeley /wheeley

RUN set -ex \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
libbluetooth-dev \
libatlas-base-dev \
&& rm -rf /var/lib/apt/lists/* \
&& pip3 install --no-index --find-links=/wheeley brewblox-tilt \
&& pip3 freeze \
&& rm -rf /wheeley
Expand Down
16 changes: 12 additions & 4 deletions brewblox_tilt/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from brewblox_service import mqtt, scheduler, service

from brewblox_tilt import broadcaster, broadcaster_sim
Expand All @@ -17,12 +19,16 @@ def create_parser():
'Out-of-bounds measurement values will be discarded. [%(default)s]',
type=float,
default=2)
parser.add_argument('--scan-duration',
help='Duration (in seconds) of Bluetooth scans. [%(default)s] (minimum 1s)',
type=float,
default=5)
parser.add_argument('--inactive-scan-interval',
help='Interval (in seconds) between broadcasts while searching for devices. [%(default)s]',
help='Interval (in seconds) between Bluetooth scans. [%(default)s] (minimum 0s)',
type=float,
default=5)
parser.add_argument('--active-scan-interval',
help='Interval (in seconds) between broadcasts when devices are active. [%(default)s]',
help='Interval (in seconds) between Bluetooth scans. [%(default)s] (minimum 0s)',
type=float,
default=10)
parser.add_argument('--simulate',
Expand All @@ -32,8 +38,6 @@ def create_parser():
'The values for this argument will be used as color',
default=None)

# Assumes a default configuration of running with --net=host
parser.set_defaults(mqtt_protocol='wss', mqtt_host='172.17.0.1')
return parser


Expand All @@ -51,6 +55,10 @@ async def setup():
else:
broadcaster.setup(app)

if config.debug:
logging.getLogger('aiomqtt').setLevel(logging.INFO)
logging.getLogger('bleak.backends.bluezdbus.manager').setLevel(logging.INFO)

# We have no meaningful REST API, so we set listen_http to False
service.run_app(app, setup(), listen_http=False)

Expand Down
129 changes: 46 additions & 83 deletions brewblox_tilt/broadcaster.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import asyncio
import json
import time
from os import getenv
from pathlib import Path
from uuid import UUID

from aiohttp import web
from beacontools import (BeaconScanner, BluetoothAddressType,
IBeaconAdvertisement, IBeaconFilter)
from beacontools.scanner import HCIVersion, Monitor
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from brewblox_service import brewblox_logger, features, mqtt, repeater
from construct import Array, Byte, Const, Int8sl, Int16ub, Struct
from construct.core import ConstError

from brewblox_tilt import const, parser
from brewblox_tilt.models import ServiceConfig

LOGGER = brewblox_logger(__name__)
APPLE_VID = 0x004C
BEACON_STRUCT = Struct(
'type_length' / Const(b'\x02\x15'),
'uuid' / Array(16, Byte),
'major' / Int16ub,
'minor' / Int16ub,
'tx_power' / Int8sl,
)


HCI_SCAN_INTERVAL_S = 30
LOGGER = brewblox_logger(__name__)


def time_ms():
Expand All @@ -28,104 +37,58 @@ def __init__(self, app: web.Application):

config: ServiceConfig = app['config']
self.name = config.name
self.scan_duration = max(config.scan_duration, 1)
self.inactive_scan_interval = max(config.inactive_scan_interval, 0)
self.active_scan_interval = max(config.active_scan_interval, 0)
self.state_topic = f'{config.state_topic}/{self.name}'
self.history_topic = f'{config.history_topic}/{self.name}'
self.names_topic = f'brewcast/tilt/{self.name}/names'

self.scanner = None
self.scanner = BleakScanner(self.device_callback)
self.parser = parser.EventDataParser(app)
self.interval = 1
self.scan_interval = 1
self.prev_num_messages = 0
self.events: dict[str, parser.TiltEvent] = {}

@property
def scanner_active(self) -> bool:
return self.scanner and self.scanner._mon.is_alive()

async def on_event(self, mac: str, rssi: int, packet: IBeaconAdvertisement, info: dict):
LOGGER.debug(f'Recv {mac=} {packet.uuid=}, {packet.major=}, {packet.minor=}')
self.events[mac] = parser.TiltEvent(mac=mac,
uuid=packet.uuid,
major=packet.major,
minor=packet.minor,
txpower=packet.tx_power,
rssi=rssi)
def device_callback(self, device: BLEDevice, advertisement_data: AdvertisementData):
try:
mac = device.address
apple_data = advertisement_data.manufacturer_data[APPLE_VID]
packet = BEACON_STRUCT.parse(apple_data)
uuid = str(UUID(bytes=bytes(packet.uuid)))

if uuid not in const.TILT_UUID_COLORS.keys():
return

LOGGER.debug(f'Recv {mac=} {uuid=}, {packet.major=}, {packet.minor=}')
self.events[mac] = parser.TiltEvent(mac=mac,
uuid=uuid,
major=packet.major,
minor=packet.minor,
txpower=packet.tx_power,
rssi=advertisement_data.rssi)

except KeyError:
pass # Apple vendor ID not found
except ConstError:
pass # Not an iBeacon

async def on_names_change(self, topic: str, payload: str):
self.parser.apply_custom_names(json.loads(payload))

async def detect_device_id(self) -> int:
bt_dir = Path('/sys/class/bluetooth')
device_id: int = None
LOGGER.info('Looking for Bluetooth adapter...')
while device_id is None:
hci_devices = sorted(f.name for f in bt_dir.glob('./hci*'))
if hci_devices:
device_id = int(hci_devices[0].removeprefix('hci'))
LOGGER.info(f'Found Bluetooth adapter hci{device_id}')
else:
LOGGER.debug(f'No Bluetooth adapter available. Retrying in {HCI_SCAN_INTERVAL_S}s...')
await asyncio.sleep(HCI_SCAN_INTERVAL_S)
return device_id

def override_hci_version(self):
try:
hci_version_override = HCIVersion(int(getenv('HCI_VERSION')))
except (TypeError, ValueError):
hci_version_override = None

def wrapped_get_hci_version():
# https://github.com/citruz/beacontools/issues/65
max_hci_version = HCIVersion.BT_CORE_SPEC_4_2
adapter_hci_version = Monitor.get_hci_version(self.scanner._mon)

if hci_version_override is not None:
hci_version = hci_version_override
else:
hci_version = min(adapter_hci_version, max_hci_version)

LOGGER.info(f'HCI Version native = {repr(adapter_hci_version)}')
LOGGER.info(f'HCI Version env = {repr(hci_version_override)}')
LOGGER.info(f'HCI Version max = {repr(max_hci_version)}')
LOGGER.info(f'HCI Version used = {repr(hci_version)}')
return hci_version

self.scanner._mon.get_hci_version = wrapped_get_hci_version

async def prepare(self):
await mqtt.listen(self.app, self.names_topic, self.on_names_change)
await mqtt.subscribe(self.app, self.names_topic)

device_id = await self.detect_device_id()
loop = asyncio.get_running_loop()
self.scanner = BeaconScanner(
lambda *args: asyncio.run_coroutine_threadsafe(self.on_event(*args), loop),
bt_device_id=device_id,
device_filter=[
IBeaconFilter(uuid=uuid)
for uuid in const.TILT_UUID_COLORS.keys()
],
scan_parameters={
'address_type': BluetoothAddressType.PUBLIC,
})
self.override_hci_version()
self.scanner.start()

async def shutdown(self, app: web.Application):
await mqtt.unsubscribe(app, self.names_topic)
await mqtt.unlisten(app, self.names_topic, self.on_names_change)
if self.scanner:
self.scanner.stop()
self.scanner = None

async def run(self):
await asyncio.sleep(self.interval)
await asyncio.sleep(self.scan_interval)

if not self.scanner_active:
LOGGER.error('Bluetooth scanner exited prematurely')
raise web.GracefulExit(1)
async with self.scanner:
await asyncio.sleep(self.scan_duration)

messages = self.parser.parse(list(self.events.values()))
self.events.clear()
Expand All @@ -136,9 +99,9 @@ async def run(self):

# Adjust scan interval based on whether devices are detected or not
if curr_num_messages == 0 or curr_num_messages < prev_num_messages:
self.interval = self.inactive_scan_interval
self.scan_interval = self.inactive_scan_interval
else:
self.interval = self.active_scan_interval
self.scan_interval = self.active_scan_interval

# Always broadcast a presence message
# This will make the service show up in the UI even without active Tilts
Expand Down
1 change: 1 addition & 0 deletions brewblox_tilt/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class ServiceConfig(BaseServiceConfig):
lower_bound: float
upper_bound: float
scan_duration: float
active_scan_interval: float
inactive_scan_interval: float
simulate: Optional[list[str]]
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
version: "3.7"
services:
eventbus:
image: brewblox/mosquitto:develop
image: ghcr.io/brewblox/mosquitto:develop
ports:
- "1883:1883"
tilt:
# Manually build the "local" image before use
image: ghcr.io/brewblox/brewblox-tilt:local
privileged: true
network_mode: host
command: --mqtt-protocol=mqtt --debug --simulate orange red
command: --debug
restart: unless-stopped
volumes:
- type: bind
Expand All @@ -18,3 +17,6 @@ services:
- type: bind
source: ./share
target: /share
- type: bind
source: /var/run/dbus
target: /var/run/dbus
Loading

0 comments on commit 1b808d2

Please sign in to comment.