├── gateway ├── mesh │ ├── nodes │ │ ├── __init__.py │ │ ├── generic.py │ │ └── light.py │ ├── __init__.py │ ├── composition.py │ ├── manager.py │ └── node.py ├── mqtt │ ├── bridges │ │ ├── __init__.py │ │ └── light.py │ ├── __init__.py │ ├── bridge.py │ └── messenger.py ├── tools │ ├── __init__.py │ ├── tasks.py │ ├── config.py │ └── store.py ├── modules │ ├── __init__.py │ ├── scanner.py │ ├── manager.py │ └── provisioner.py └── gateway.py ├── pyproject.toml ├── .gitignore ├── docker ├── scripts │ ├── entrypoint.sh │ ├── install-ell.sh │ ├── install-json-c.sh │ └── install-bluez.sh └── config │ └── config.yaml.sample ├── docker-compose.yaml ├── requirements.txt ├── Dockerfile └── README.md /gateway/mesh/nodes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gateway/mqtt/bridges/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 -------------------------------------------------------------------------------- /gateway/mesh/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import NodeManager 2 | from .node import Node 3 | -------------------------------------------------------------------------------- /gateway/mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | from .messenger import HassMqttMessenger 2 | from .bridge import HassMqttBridge 3 | -------------------------------------------------------------------------------- /gateway/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | from .store import Store 3 | from .tasks import Tasks 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | 4 | # configuration files 5 | config.yaml 6 | store.yaml 7 | store.bak.yaml 8 | 9 | docker/config/[0-9]* -------------------------------------------------------------------------------- /docker/scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | service dbus start 4 | /usr/libexec/bluetooth/bluetooth-meshd & 5 | 6 | python3 gateway.py --reload & 7 | /bin/bash 8 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | app: 4 | build: . 5 | volumes: 6 | - ./docker/config:/var/lib/bluetooth/mesh 7 | restart: "always" 8 | network_mode: "host" 9 | privileged: true 10 | tty: true 11 | -------------------------------------------------------------------------------- /docker/scripts/install-ell.sh: -------------------------------------------------------------------------------- 1 | # 2 | # Docker helper script to get Embedded Linux library 3 | # 4 | set -e 5 | 6 | # clone repository 7 | git clone https://git.kernel.org/pub/scm/libs/ell/ell.git 8 | cd ell 9 | 10 | # checkout recent version 11 | git checkout 0.54 -------------------------------------------------------------------------------- /docker/config/config.yaml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | mqtt: 3 | broker: 4 | # username: 5 | # password: 6 | node_id: mqtt_mesh 7 | mesh: 8 | : 9 | uuid: 10 | name: 11 | type: light # Only type supported for now. 12 | relay: false # Whether this node should act as a Bluetooth Relay 13 | -------------------------------------------------------------------------------- /docker/scripts/install-json-c.sh: -------------------------------------------------------------------------------- 1 | # 2 | # Docker helper script to install json-c 3 | # 4 | set -e 5 | 6 | # clone recent version 7 | wget https://github.com/json-c/json-c/archive/refs/tags/json-c-0.16-20220414.tar.gz 8 | tar -xvf json-c-0.16-20220414.tar.gz 9 | cd json-c-json-c-0.16-20220414/ 10 | 11 | # configure 12 | mkdir json-c-build 13 | cd json-c-build/ 14 | cmake -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_STATIC_LIBS=OFF .. 15 | 16 | # build and install 17 | make 18 | make install -------------------------------------------------------------------------------- /docker/scripts/install-bluez.sh: -------------------------------------------------------------------------------- 1 | # 2 | # Docker helper script to install BlueZ with bluetooth mesh support 3 | # 4 | set -e 5 | 6 | # clone recent version 7 | wget https://github.com/bluez/bluez/archive/refs/tags/5.66.tar.gz 8 | tar -xvf 5.66.tar.gz 9 | cd bluez-5.66 10 | 11 | # configure 12 | ./bootstrap 13 | ./configure --enable-mesh --enable-testing --enable-tools --prefix=/usr --mandir=/usr/share/man --sysconfdir=/etc --localstatedir=/var 14 | 15 | # build and install 16 | make 17 | make install 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asyncio-mqtt==0.12.1 2 | bitstring==3.1.9 3 | black==22.10.0 4 | git+https://github.com/dominikberse/python-bluetooth-mesh@bac0c43553b23a8e68897a8d36a6f06c8cade684 5 | cffi==1.15.1 6 | construct==2.9.45 7 | crc==0.3.0 8 | cryptography==3.3.2 9 | dbus-next==0.2.3 10 | docopt==0.6.2 11 | ecdsa==0.15 12 | marshmallow==3.17.1 13 | numpy==1.23.3 14 | packaging==21.3 15 | paho-mqtt==1.6.1 16 | pluggy==1.0.0 17 | prompt-toolkit==2.0.10 18 | pycparser==2.21 19 | pymesh==1.0.2 20 | pyparsing==3.0.9 21 | six==1.16.0 22 | typing-extensions==4.3.0 23 | wcwidth==0.2.5 24 | -------------------------------------------------------------------------------- /gateway/modules/__init__.py: -------------------------------------------------------------------------------- 1 | class Module: 2 | """ 3 | Base class for application modules 4 | 5 | Defines interfaces that can be used to hide modules behind various views, 6 | like i.e. a command line interface or an HTTP or MQTT interface. 7 | """ 8 | 9 | def __init__(self): 10 | pass 11 | 12 | def initialize(self, app, store, config): 13 | """ 14 | Do additional initialization after Bluetooth layer is available 15 | """ 16 | 17 | self.app = app 18 | self.store = store 19 | self.config = config 20 | 21 | def setup_cli(self, parser): 22 | """ 23 | Setup argparse sub parser for direct CLI usage 24 | """ 25 | pass 26 | 27 | async def handle_cli(self, args): 28 | """ 29 | Run from CLI 30 | """ 31 | pass 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM balenalib/rpi-python:3.10.10-bullseye-build-20231106 2 | 3 | RUN apt-get -y update && apt-get -y upgrade && apt-get -y install \ 4 | build-essential \ 5 | python3-docutils \ 6 | udev \ 7 | systemd \ 8 | cmake \ 9 | autoconf \ 10 | libtool \ 11 | libdbus-1-dev \ 12 | libudev-dev \ 13 | libical-dev \ 14 | libreadline-dev 15 | 16 | RUN apt-get -y install libell-dev bluez bluez-meshd 17 | 18 | WORKDIR /opt/build 19 | COPY docker/scripts/install-json-c.sh . 20 | RUN sh ./install-json-c.sh 21 | 22 | # install bridge 23 | WORKDIR /opt/hass-ble-mesh 24 | COPY ./requirements.txt . 25 | RUN pip3 install --upgrade pip && pip3 install "cython<3.0.0" wheel && pip install "pyyaml==6.0" --no-build-isolation && pip3 install -r requirements.txt 26 | 27 | WORKDIR /opt/hass-ble-mesh 28 | COPY ./gateway gateway 29 | 30 | # mount config 31 | WORKDIR /var/lib/bluetooth/mesh 32 | VOLUME /var/lib/bluetooth/mesh 33 | ENV GATEWAY_BASEDIR=/var/lib/bluetooth/mesh 34 | 35 | # run bluetooth service and bridge 36 | WORKDIR /opt/hass-ble-mesh/gateway 37 | COPY docker/scripts/entrypoint.sh . 38 | ENTRYPOINT [ "/bin/bash", "entrypoint.sh" ] 39 | -------------------------------------------------------------------------------- /gateway/modules/scanner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | 4 | from uuid import UUID 5 | 6 | from . import Module 7 | 8 | 9 | class ScannerModule(Module): 10 | """ 11 | Handle all scan related tasks 12 | """ 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | self._unprovisioned = set() 18 | 19 | def _scan_result(self, rssi, data, options): 20 | """ 21 | The method is called from the bluetooth-meshd daemon when a 22 | unique UUID has been seen during UnprovisionedScan() for 23 | unprovsioned devices. 24 | """ 25 | 26 | try: 27 | uuid = UUID(bytes=data[:16]) 28 | self._unprovisioned.add(uuid) 29 | logging.info(f"Found unprovisioned node: {uuid}") 30 | except: 31 | logging.exception("Failed to retrieve UUID") 32 | 33 | async def handle_cli(self, args): 34 | await self.scan() 35 | 36 | # print user friendly results 37 | print(f"\nFound {len(self._unprovisioned)} nodes:") 38 | for uuid in self._unprovisioned: 39 | print(f"\t{uuid}") 40 | 41 | async def scan(self): 42 | logging.info("Scanning for unprovisioned devices...") 43 | 44 | await self.app.management_interface.unprovisioned_scan(seconds=10) 45 | await asyncio.sleep(10.0) 46 | -------------------------------------------------------------------------------- /gateway/tools/tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | 5 | class Tasks: 6 | """ 7 | Simple task pool 8 | 9 | TODO: This class can be extended in order to manage failed tasks. 10 | Currently failed tasks are logged but otherwise ignored. 11 | """ 12 | 13 | def __init__(self): 14 | self._tasks = set() 15 | 16 | async def __aenter__(self): 17 | return self 18 | 19 | async def __aexit__(self, exc_type, exc, tb): 20 | await self._shutdown() 21 | 22 | async def _shutdown(self): 23 | for task in self._tasks: 24 | if task.done(): 25 | continue 26 | try: 27 | task.cancel() 28 | await task 29 | except asyncio.CancelledError: 30 | pass 31 | 32 | async def _runner(self, task, name): 33 | if name: 34 | logging.debug(f"Spawning task to {name}...") 35 | try: 36 | await task 37 | except: 38 | logging.exception("Task failed") 39 | if name: 40 | logging.debug(f"{name} completed") 41 | 42 | def spawn(self, task, name=None): 43 | self._tasks.add(asyncio.create_task(self._runner(task, name))) 44 | 45 | async def gather(self): 46 | logging.info(f"Awaiting {len(self._tasks)} tasks") 47 | await asyncio.gather(*self._tasks) 48 | -------------------------------------------------------------------------------- /gateway/mesh/composition.py: -------------------------------------------------------------------------------- 1 | class Model: 2 | def __init__(self, data): 3 | self._model_id = data.get("model_id") 4 | 5 | @property 6 | def model_id(self): 7 | return self._model_id 8 | 9 | 10 | class Element: 11 | def __init__(self, data): 12 | self._data = data 13 | 14 | self._sig_models = list(map(Model, data.get("sig_models"))) 15 | self._vendor_models = list(map(Model, data.get("vendor_models"))) 16 | 17 | @property 18 | def sig_models(self): 19 | return self._sig_models 20 | 21 | @property 22 | def vendor_models(self): 23 | return self._vendor_models 24 | 25 | def supports(self, model): 26 | """ 27 | Check if the element supports (contains) the given model 28 | """ 29 | model_ids = model.MODEL_ID 30 | 31 | for sig_model in self._sig_models: 32 | if sig_model.model_id in model_ids: 33 | return True 34 | 35 | for vendor_model in self._vendor_models: 36 | if vendor_model.model_id in model_ids: 37 | return True 38 | 39 | return False 40 | 41 | 42 | class Composition: 43 | def __init__(self, data): 44 | self._data = data 45 | 46 | self._elements = list(map(Element, data.get("elements"))) 47 | 48 | def __str__(self): 49 | return str(self._data) 50 | 51 | @property 52 | def elements(self): 53 | return self._elements 54 | 55 | def element(self, index): 56 | return self._elements[index] 57 | -------------------------------------------------------------------------------- /gateway/modules/manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from uuid import UUID 5 | 6 | from bluetooth_mesh import models 7 | 8 | from . import Module 9 | 10 | 11 | class ManagerModule(Module): 12 | """ 13 | Node managment functionality 14 | """ 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | self._get_result = None 20 | 21 | def initialize(self, app, store, config): 22 | super().initialize(app, store, config) 23 | 24 | def setup_cli(self, parser): 25 | parser.add_argument("operation") 26 | parser.add_argument("field") 27 | parser.add_argument("uuid") 28 | 29 | async def handle_cli(self, args): 30 | try: 31 | uuid = UUID(args.uuid) 32 | except: 33 | print("Invalid uuid") 34 | return 35 | 36 | node = self.app.nodes.get(uuid) 37 | if node is None: 38 | print("Unknown node") 39 | return 40 | 41 | if args.operation == "get": 42 | if args.field == "ttl": 43 | await self._get(uuid, node.unicast, "default_ttl") 44 | if args.field == "composition": 45 | await self._get(uuid, node.unicast, "composition_data") 46 | 47 | print("\nGet returned:") 48 | node.print_info(self._get_result) 49 | return 50 | 51 | if args.operation == "set": 52 | return 53 | 54 | print(f"Unknown operation {args.operation}") 55 | 56 | async def _get(self, uuid, address, getter): 57 | logging.info(f"Get {getter} from {uuid} ({address})...") 58 | 59 | client = self.app.elements[0][models.ConfigClient] 60 | getter = getattr(client, f"get_{getter}") 61 | data = await getter([address], net_index=0) 62 | 63 | self._get_result = data[address] 64 | -------------------------------------------------------------------------------- /gateway/tools/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import logging 3 | 4 | 5 | class Config: 6 | def __init__(self, filename=None, config=None): 7 | self._filename = filename 8 | 9 | # load user configuration 10 | if self._filename: 11 | with open(self._filename, "r") as config_file: 12 | self._config = yaml.safe_load(config_file) 13 | 14 | elif config is not None: 15 | self._config = config 16 | 17 | else: 18 | raise Exception("Invalid config initialization") 19 | 20 | def _get(self, path, section, info): 21 | if "." in path: 22 | prefix, remainder = path.split(".", 1) 23 | subsection = self._get(prefix, section, info) 24 | return self._get(remainder, subsection, info) 25 | 26 | if path not in section: 27 | if "raise" in info: 28 | raise info["raise"] 29 | return info["fallback"] 30 | 31 | return section[path] 32 | 33 | def require(self, path): 34 | return self._get( 35 | path, 36 | self._config, 37 | { 38 | "raise": Exception(f"{path} missing in config"), 39 | }, 40 | ) 41 | 42 | def optional(self, path, fallback=None): 43 | return self._get( 44 | path, 45 | self._config, 46 | { 47 | "fallback": fallback, 48 | }, 49 | ) 50 | 51 | def node_config(self, uuid): 52 | """ 53 | Get config for given node 54 | """ 55 | mesh = self.optional("mesh", None) or {} 56 | 57 | for id, info in mesh.items(): 58 | if info.get("uuid") == str(uuid): 59 | return Config(config={"id": id, **info}) 60 | 61 | logging.warning(f"Missing configuration for node {uuid}") 62 | return Config(config={}) 63 | 64 | def items(self): 65 | return self._config.items() 66 | -------------------------------------------------------------------------------- /gateway/tools/store.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | 4 | 5 | class Store: 6 | """ 7 | Provides a simple database structure 8 | """ 9 | 10 | def __init__(self, delegate=None, location=None, data=None): 11 | self._location = location 12 | self._delegate = delegate 13 | 14 | if not self._location and not self._delegate: 15 | raise Exception("Either delegate or location must be specified") 16 | 17 | if self._location: 18 | if os.path.exists(self._location): 19 | with open(self._location, "r") as store_file: 20 | self._data = yaml.safe_load(store_file) 21 | else: 22 | # create initial base store 23 | self._data = {} 24 | 25 | if self._delegate: 26 | if data is None: 27 | raise Exception("Substore data not available") 28 | self._data = data 29 | 30 | def persist(self): 31 | if self._delegate: 32 | # persist using parent location 33 | self._delegate.persist() 34 | 35 | if self._location: 36 | # persist to actual location 37 | with open(self._location, "w") as store_file: 38 | yaml.dump(self._data, store_file) 39 | 40 | def section(self, name, subclass=None): 41 | """ 42 | Return a new sub-store that will persist to same location 43 | """ 44 | if name not in self._data: 45 | self._data[name] = {} 46 | if subclass is None: 47 | subclass = Store 48 | return subclass(delegate=self, data=self._data[name]) 49 | 50 | def get(self, name, fallback=None): 51 | if name not in self._data: 52 | self._data[name] = fallback 53 | return self._data[name] 54 | 55 | def set(self, name, value): 56 | self._data[name] = value 57 | 58 | def has(self, name): 59 | return name in self._data 60 | 61 | def delete(self, name): 62 | del self._data[name] 63 | 64 | def reset(self): 65 | self._data.clear() 66 | 67 | def items(self): 68 | return self._data.items() 69 | -------------------------------------------------------------------------------- /gateway/mqtt/bridge.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import asyncio 4 | 5 | 6 | class HassMqttBridge: 7 | """ 8 | Base class for all MQTT messenger bridges 9 | 10 | Instances of this class are responsible for bridging between a Home Assistant 11 | device type and a specific type of Bluetooth Mesh node. 12 | 13 | This class provides default implementations, that should work for most common nodes. 14 | They can however be overriden, if more sophisticated behaviour is required. 15 | """ 16 | 17 | def __init__(self, messenger): 18 | self._messenger = messenger 19 | 20 | @property 21 | def component(self): 22 | return None 23 | 24 | def _property_change(self, node, property, value): 25 | try: 26 | # get handler from property name 27 | handler = getattr(self, f"_notify_{property}") 28 | except: 29 | logging.warning(f"Missing handler for property {property}") 30 | return 31 | 32 | # TODO: track task 33 | asyncio.create_task(handler(node, value)) 34 | 35 | async def listen(self, node): 36 | """ 37 | Listen for incoming messages and node changes 38 | """ 39 | 40 | # send node configuration for MQTT discovery 41 | await node.ready.wait() 42 | await self.config(node) 43 | 44 | # listen for node changes (this will also push the initial state) 45 | node.subscribe(self._property_change, resend=True) 46 | 47 | # listen for incoming MQTT messages 48 | async with self._messenger.filtered_messages(self.component, node) as messages: 49 | async for message in messages: 50 | logging.info(f"Received message on {message.topic}:\n{message.payload}") 51 | 52 | # get command from topic and load message 53 | command = message.topic.split("/")[-1] 54 | 55 | try: 56 | # get handler from command name 57 | handler = getattr(self, f"_mqtt_{command}") 58 | except: 59 | logging.warning(f"Missing handler for command {command}") 60 | continue 61 | 62 | payload = json.loads(message.payload.decode()) 63 | await handler(node, payload) 64 | 65 | async def config(self, node): 66 | """ 67 | Send discovery message 68 | """ 69 | pass 70 | -------------------------------------------------------------------------------- /gateway/mesh/manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from uuid import UUID 4 | 5 | 6 | class NodeManager: 7 | """ 8 | Specific store to manage nodes 9 | 10 | Notice that the interface is slightly different, due to the use of UUIDs as keys. 11 | Therefore no inheritance from tools.Store is implemented. 12 | """ 13 | 14 | def __init__(self, store, config, types): 15 | self._store = store 16 | self._types = types 17 | self._nodes = {} 18 | 19 | # create node instances of specific types 20 | for uuid, info in self._store.items(): 21 | node_config = config.node_config(uuid) 22 | self._nodes[uuid] = self._make_node(UUID(uuid), info, node_config) 23 | 24 | def __len__(self): 25 | return len(self._nodes) 26 | 27 | def _make_node(self, uuid, info, node_config=None): 28 | typename = info.get("type") 29 | 30 | # check if the user changed the node type in the configuration 31 | # this needs to be done here, before the actual node class is instanciated 32 | if node_config: 33 | user_typename = node_config.optional("type", typename) 34 | if user_typename != typename: 35 | logging.warning(f'Node type changed for {uuid} from "{typename}" to "{user_typename}"') 36 | 37 | typename = user_typename 38 | info["type"] = typename 39 | 40 | if typename is None or typename not in self._types: 41 | raise Exception(f'Invalid node type "{typename}" for {uuid}') 42 | 43 | # create node instance of specific type 44 | return self._types[typename](uuid, config=node_config, **info) 45 | 46 | def get(self, uuid): 47 | return self._nodes.get(str(uuid)) 48 | 49 | def has(self, uuid): 50 | return str(uuid) in self._nodes 51 | 52 | def persist(self): 53 | # TODO: maybe update store instead of recreating it 54 | # this is currently neccessary to reflect deletions 55 | self._store.reset() 56 | 57 | for node in self._nodes.values(): 58 | self._store.set(str(node.uuid), node.yaml()) 59 | self._store.persist() 60 | 61 | def add(self, node): 62 | if str(node.uuid) in self._nodes: 63 | logging.warning(f"Node {node} already exists") 64 | self._nodes[str(node.uuid)] = node 65 | 66 | def create(self, uuid, info): 67 | self.add(self._make_node(uuid, info)) 68 | 69 | def delete(self, uuid): 70 | del self._nodes[str(uuid)] 71 | 72 | def all(self): 73 | return self._nodes.values() 74 | 75 | def reset(self): 76 | self._nodes.clear() 77 | -------------------------------------------------------------------------------- /gateway/mesh/node.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from tools import Config 4 | 5 | 6 | class Node: 7 | """ 8 | Base class for Bluetooth Mesh nodes 9 | 10 | Abstracts from the Bluetooth Mesh architecture and provides a basic 11 | event interface for other application components. 12 | """ 13 | 14 | def __init__(self, uuid, type, unicast, count, configured=False, config=None): 15 | self.uuid = uuid 16 | self.type = type 17 | self.unicast = unicast 18 | self.count = count 19 | self.configured = configured 20 | self.config = config or Config(config={}) 21 | 22 | # event system for property changes 23 | self._retained = {} 24 | self._subscribers = set() 25 | # event system for node initialization 26 | self.ready = asyncio.Event() 27 | 28 | def __str__(self): 29 | id = self.config.optional("id") 30 | 31 | if id: 32 | return f"{id} ({self.uuid}, {self.unicast:04})" 33 | return f"{self.uuid} ({self.unicast:04})" 34 | 35 | async def bind(self, app): 36 | """ 37 | Configure the node to work with the available mesh clients 38 | 39 | Subclasses can use this function to configure Bluetooth Mesh 40 | models on the remote node. 41 | """ 42 | self._app = app 43 | 44 | def subscribe(self, subscriber, resend=True): 45 | """ 46 | Subscribe to state changes 47 | """ 48 | self._subscribers.add(subscriber) 49 | 50 | for property, value in self._retained.items(): 51 | subscriber(self, property, value) 52 | 53 | def notify(self, property, value): 54 | """ 55 | Notify all subscribers about state change 56 | """ 57 | self._retained[property] = value 58 | 59 | for subscriber in self._subscribers: 60 | subscriber(self, property, value) 61 | 62 | def retained(self, property, fallback): 63 | """ 64 | Get the latest value for that property 65 | """ 66 | return self._retained.get(property, fallback) 67 | 68 | def print_info(self, additional=None): 69 | print( 70 | f"\t{self.uuid}:\n" 71 | f"\t\ttype: {self.type}\n" 72 | f"\t\tunicast: {self.unicast} ({self.count})\n" 73 | f"\t\tconfigured: {self.configured}", 74 | ) 75 | 76 | for key, value in self.config.items(): 77 | print(f"\t\t{key}: {value}") 78 | 79 | if additional: 80 | for key, value in additional.items(): 81 | print(f"\t\t{key}: {value}") 82 | 83 | print() 84 | 85 | def yaml(self): 86 | # UUID is used as key and does not need to be stored 87 | return { 88 | "type": self.type, 89 | "unicast": self.unicast, 90 | "count": self.count, 91 | "configured": self.configured, 92 | } 93 | -------------------------------------------------------------------------------- /gateway/mesh/nodes/generic.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from mesh import Node 5 | from mesh.composition import Composition, Element 6 | 7 | from bluetooth_mesh import models 8 | 9 | def on_message(source, destination, app_index, message): 10 | timestamp = datetime.now().strftime("%Y-%m-%d %T.%f") 11 | print(f"{timestamp} {source:04x} -> {destination:04x}: {message!r}") 12 | 13 | class Generic(Node): 14 | """ 15 | Generic Bluetooth Mesh node 16 | 17 | Provides additional functionality compared to the very basic Node class, 18 | like composition model helpers and node configuration. 19 | """ 20 | 21 | OnlineProperty = "online" 22 | 23 | def __init__(self, *args, **kwargs): 24 | super().__init__(*args, **kwargs) 25 | 26 | # stores the node's composition data 27 | self._composition = None 28 | # lists all bound model 29 | self._bound_models = set() 30 | 31 | def _is_model_bound(self, model): 32 | """ 33 | Check if the given model is supported and bound 34 | """ 35 | return model in self._bound_models 36 | 37 | async def fetch_composition(self): 38 | """ 39 | Fetch the composition data 40 | 41 | This data contains information about the node's capabilities. 42 | Use the helper functions to retrieve information. 43 | """ 44 | client = self._app.elements[0][models.ConfigClient] 45 | data = await client.get_composition_data([self.unicast], net_index=0, timeout=10) 46 | # TODO: multi page composition data support 47 | page_zero = data.get(self.unicast, {}).get("zero") 48 | self._composition = Composition(page_zero) 49 | 50 | async def bind(self, app): 51 | await super().bind(app) 52 | 53 | # update the composition data 54 | await self.fetch_composition() 55 | 56 | logging.debug(f"Node composition:\n{self._composition}") 57 | 58 | async def bind_model(self, model): 59 | """ 60 | Bind the given model to the application key 61 | 62 | If the node supports the given model, it is bound to the appliaction key 63 | and listed within the supported models. 64 | 65 | If the node does not support the given model, the request is skipped. 66 | """ 67 | 68 | if self._composition is None: 69 | logging.info(f"No composition data for {self}") 70 | return False 71 | 72 | element = self._composition.element(0) 73 | if not element.supports(model): 74 | logging.info(f"{self} does not support {model}") 75 | return False 76 | 77 | # configure model 78 | client = self._app.elements[0][models.ConfigClient] 79 | await client.bind_app_key( 80 | self.unicast, net_index=0, element_address=self.unicast, app_key_index=self._app.app_keys[0][0], model=model 81 | ) 82 | self._bound_models.add(model) 83 | 84 | logging.info(f"{self} bound {model}") 85 | return True 86 | -------------------------------------------------------------------------------- /gateway/mqtt/messenger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from asyncio_mqtt.client import Client, MqttError 5 | from contextlib import AsyncExitStack 6 | 7 | from mesh import Node 8 | from tools import Tasks 9 | 10 | from .bridges import light 11 | 12 | 13 | BRIDGES = { 14 | "light": light.GenericLightBridge, 15 | } 16 | 17 | 18 | class HassMqttMessenger: 19 | """ 20 | Provides home assistant specific MQTT functionality 21 | 22 | Manages a set of bridges for specific device types and 23 | manages tasks to receive and handle incoming messages. 24 | """ 25 | 26 | def __init__(self, config, nodes): 27 | self._config = config 28 | self._nodes = nodes 29 | self._bridges = {} 30 | self._paths = {} 31 | 32 | self._client = Client( 33 | self._config.require("mqtt.broker"), 34 | username=self._config.optional("mqtt.username"), 35 | password=self._config.optional("mqtt.password"), 36 | ) 37 | self._topic = config.optional("mqtt.topic", "mqtt_mesh") 38 | 39 | # initialize bridges 40 | for name, constructor in BRIDGES.items(): 41 | self._bridges[name] = constructor(self) 42 | 43 | @property 44 | def client(self): 45 | return self._client 46 | 47 | @property 48 | def topic(self): 49 | return self._topic 50 | 51 | def node_topic(self, component, node): 52 | """ 53 | Return base topic for a specific node 54 | """ 55 | if isinstance(node, Node): 56 | node = node.config.require("id") 57 | 58 | return f"homeassistant/{component}/{self._topic}/{node}" 59 | 60 | def filtered_messages(self, component, node, topic="#"): 61 | """ 62 | Shorthand to get messages for a specific node 63 | """ 64 | return self._client.filtered_messages(f"{self.node_topic(component, node)}/{topic}") 65 | 66 | async def publish(self, component, node, topic, message, **kwargs): 67 | """ 68 | Send a state update for a specific nde 69 | """ 70 | if isinstance(message, dict): 71 | message = json.dumps(message) 72 | 73 | await self._client.publish(f"{self.node_topic(component, node)}/{topic}", str(message).encode(), **kwargs) 74 | 75 | async def run(self, app): 76 | async with AsyncExitStack() as stack: 77 | tasks = await stack.enter_async_context(Tasks()) 78 | 79 | # connect to MQTT broker 80 | await stack.enter_async_context(self._client) 81 | 82 | # spawn tasks for every node 83 | for node in self._nodes.all(): 84 | bridge = self._bridges.get(node.type) 85 | 86 | if bridge is None: 87 | logging.warning(f"No MQTT bridge for node {node} ({node.type})") 88 | return 89 | 90 | tasks.spawn(bridge.listen(node), f"bridge {node}") 91 | 92 | # global subscription to messages 93 | await self._client.subscribe("homeassistant/#") 94 | 95 | # wait for all tasks 96 | await tasks.gather() 97 | -------------------------------------------------------------------------------- /gateway/mqtt/bridges/light.py: -------------------------------------------------------------------------------- 1 | from sre_constants import BIGCHARSET 2 | from mqtt.bridge import HassMqttBridge 3 | from mesh.nodes.light import Light 4 | 5 | 6 | class GenericLightBridge(HassMqttBridge): 7 | """ 8 | Generic bridge for lights 9 | """ 10 | 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | 14 | @property 15 | def component(self): 16 | return "light" 17 | 18 | async def config(self, node): 19 | color_modes = set() 20 | message = { 21 | "~": self._messenger.node_topic(self.component, node), 22 | "name": node.config.optional("name"), 23 | "unique_id": node.config.require("id"), 24 | "object_id": node.config.require("id"), 25 | "command_topic": "~/set", 26 | "state_topic": "~/state", 27 | "availability_topic": "~/availability", 28 | "schema": "json", 29 | } 30 | 31 | if node.supports(Light.BrightnessProperty): 32 | message["brightness_scale"] = 50 33 | message["brightness"] = True 34 | 35 | if node.supports(Light.TemperatureProperty): 36 | color_modes.add("color_temp") 37 | # convert from Kelvin to mireds 38 | # TODO: look up max/min values from device 39 | # message['min_mireds'] = 1000000 // 7000 40 | # message['max_mireds'] = 1000000 // 2000 41 | 42 | if color_modes: 43 | message["color_mode"] = True 44 | message["supported_color_modes"] = list(color_modes) 45 | 46 | await self._messenger.publish(self.component, node, "config", message) 47 | 48 | async def _state(self, node, onoff): 49 | """ 50 | Send a generic state message covering the nodes full state 51 | 52 | If the light is on, all properties are set to their retained state. 53 | If the light is off, properties are not passed at all. 54 | """ 55 | message = {"state": "ON" if onoff else "OFF"} 56 | 57 | if onoff and node.supports(Light.BrightnessProperty): 58 | message["brightness"] = node.retained(Light.BrightnessProperty, 100) 59 | if onoff and node.supports(Light.TemperatureProperty): 60 | message["color_temp"] = node.retained(Light.TemperatureProperty, 100) 61 | 62 | await self._messenger.publish(self.component, node, "state", message) 63 | 64 | async def _mqtt_set(self, node, payload): 65 | if "color_temp" in payload: 66 | await node.set_mireds(payload["color_temp"]) 67 | if "brightness" in payload: 68 | await node.set_brightness(payload["brightness"]) 69 | if payload.get("state") == "ON": 70 | await node.turn_on() 71 | if payload.get("state") == "OFF": 72 | await node.turn_off() 73 | 74 | async def _notify_onoff(self, node, onoff): 75 | await self._state(node, onoff) 76 | 77 | async def _notify_brightness(self, node, brightness): 78 | await self._state(node, brightness > 0) 79 | 80 | async def _notify_availability(self, node, state): 81 | await self._messenger.publish(self.component, node, "availability", state) -------------------------------------------------------------------------------- /gateway/mesh/nodes/light.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .generic import Generic 5 | 6 | from bluetooth_mesh import models 7 | from bluetooth_mesh.messages import LightLightnessOpcode 8 | 9 | 10 | class Light(Generic): 11 | """ 12 | Generic interface for light nodes 13 | 14 | Tracks the available feature of the light. Currently supports 15 | - GenericOnOffServer 16 | - turn on and off 17 | - LightLightnessServer 18 | - set brightness 19 | - LightCTLServer 20 | - set color temperature 21 | 22 | For now only a single element is supported. 23 | """ 24 | 25 | OnOffProperty = "onoff" 26 | BrightnessProperty = "brightness" 27 | TemperatureProperty = "temperature" 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | 32 | self._features = set() 33 | 34 | def supports(self, property): 35 | return property in self._features 36 | 37 | async def refresh(self): 38 | await self.ready.wait() 39 | while True: 40 | await self.get_availability() 41 | await asyncio.sleep(60) 42 | 43 | def lightness_cb(self, source: int, 44 | net_index: int, 45 | destination, 46 | message): 47 | if (self.unicast == source): 48 | self.notify("availability", "online") 49 | self.notify(Light.BrightnessProperty, message["light_lightness_status"]["present_lightness"]) 50 | 51 | async def turn_on(self): 52 | await self.set_onoff_unack(True, transition_time=0.5) 53 | 54 | async def turn_off(self): 55 | await self.set_onoff_unack(False, transition_time=0.5) 56 | 57 | async def set_brightness(self, brightness): 58 | if self._is_model_bound(models.LightLightnessServer): 59 | await self.set_lightness_unack(brightness, transition_time=0.5) 60 | elif self._is_model_bound(models.LightCTLServer): 61 | await self.set_ctl_unack(brightness=brightness) 62 | 63 | async def set_kelvin(self, temperature): 64 | if self._is_model_bound(models.LightCTLServer): 65 | await self.set_ctl_unack(temperature) 66 | 67 | async def set_mireds(self, temperature): 68 | if self._is_model_bound(models.LightCTLServer): 69 | await self.set_ctl_unack(1000000 // temperature) 70 | 71 | async def bind(self, app): 72 | await super().bind(app) 73 | 74 | if await self.bind_model(models.GenericOnOffServer): 75 | self._features.add(Light.OnOffProperty) 76 | await self.get_onoff() 77 | 78 | if await self.bind_model(models.LightLightnessServer): 79 | self._features.add(Light.OnOffProperty) 80 | self._features.add(Light.BrightnessProperty) 81 | await self.get_lightness() 82 | 83 | if await self.bind_model(models.LightCTLServer): 84 | self._features.add(Light.TemperatureProperty) 85 | self._features.add(Light.BrightnessProperty) 86 | await self.get_ctl() 87 | 88 | client = self._app.elements[0][models.LightLightnessClient] 89 | client.app_message_callbacks[LightLightnessOpcode.LIGHT_LIGHTNESS_STATUS] \ 90 | .add(self.lightness_cb) 91 | 92 | async def set_onoff_unack(self, onoff, **kwargs): 93 | self.notify(Light.OnOffProperty, onoff) 94 | 95 | client = self._app.elements[0][models.GenericOnOffClient] 96 | await client.set_onoff_unack(self.unicast, self._app.app_keys[0][0], onoff, **kwargs) 97 | 98 | async def get_availability(self): 99 | client = self._app.elements[0][models.GenericOnOffClient] 100 | state = await client.get_light_status([self.unicast], self._app.app_keys[0][0]) 101 | 102 | result = state[self.unicast] 103 | if result is None: 104 | logging.warn(f"Received invalid result {state}") 105 | self.notify("availability", "offline") 106 | elif not isinstance(result, BaseException): 107 | self.notify("availability", "online") 108 | 109 | async def get_onoff(self): 110 | client = self._app.elements[0][models.GenericOnOffClient] 111 | state = await client.get_light_status([self.unicast], self._app.app_keys[0][0]) 112 | 113 | result = state[self.unicast] 114 | if result is None: 115 | logging.warn(f"Received invalid result {state}") 116 | elif not isinstance(result, BaseException): 117 | self.notify(Light.OnOffProperty, result["present_onoff"]) 118 | 119 | async def set_lightness_unack(self, lightness, **kwargs): 120 | self.notify(Light.BrightnessProperty, lightness) 121 | 122 | client = self._app.elements[0][models.LightLightnessClient] 123 | await client.set_lightness_unack(self.unicast, self._app.app_keys[0][0], lightness, **kwargs) 124 | 125 | async def get_lightness(self): 126 | client = self._app.elements[0][models.LightLightnessClient] 127 | state = await client.get_lightness([self.unicast], self._app.app_keys[0][0]) 128 | 129 | result = state[self.unicast] 130 | if result is None: 131 | logging.warn(f"Received invalid result {state}") 132 | elif not isinstance(result, BaseException): 133 | self.notify(Light.BrightnessProperty, result["present_lightness"]) 134 | 135 | async def set_ctl_unack(self, temperature=None, brightness=None, **kwargs): 136 | if temperature: 137 | self.notify(Light.TemperatureProperty, temperature) 138 | else: 139 | temperature = self.retained(Light.TemperatureProperty, 255) 140 | if brightness: 141 | self.notify(Light.BrightnessProperty, temperature) 142 | else: 143 | brightness = self.retained(Light.BrightnessProperty, 100) 144 | 145 | client = self._app.elements[0][models.LightCTLClient] 146 | await client.set_ctl_unack(self.unicast, self._app.app_keys[0][0], temperature, brightness, **kwargs) 147 | 148 | async def get_ctl(self): 149 | client = self._app.elements[0][models.LightCTLClient] 150 | state = await client.get_ctl([self.unicast], self._app.app_keys[0][0]) 151 | 152 | result = state[self.unicast] 153 | if result is None: 154 | logging.warn(f"Received invalid result {state}") 155 | elif not isinstance(result, BaseException): 156 | print(result) 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bluetooth Mesh for Home Assistant 2 | 3 | This project aims to integrate Bluetooth Mesh devices into Home Assistant directly. 4 | 5 | The project is in a development state. The current approach is to use the Home Assistant MQTT integration on the Home Assistant side. Then for every Bluetooth Mesh device type a _bridge_ class is implemented, that maps the node's functionality to a Home Assistant device class. 6 | 7 | ## Poject State 8 | 9 | The basic requirements for this setup are already implemented: 10 | 11 | - MQTT integration using `asyncio_mqtt` 12 | - Bluetooth Mesh integration using `bluetooth_mesh` 13 | - Mechanisms to allow easy communication between both ends 14 | 15 | Additionally a command line interface for easy scanning and provisioning is available. 16 | 17 | ### Devices 18 | 19 | Currently the following bridges are implemented: 20 | 21 | - _Generic Light Bridge_: Maps a basic Bluetooth Mesh light to a Home Assistant Light. Supports on / off, brightness and color temperature. Since I do not have Bluetooth Mesh RGB Leds at hand, I do not plan on supporting them. The implementation should basically follow the color temperature though. 22 | 23 | ### Roadmap 24 | 25 | - Check relay setup 26 | - (done) Dockerize application 27 | - Provide as HACS integration 28 | - Extend README 29 | 30 | ## (Hopefully) easy setup 31 | 32 | The repository provides a docker container, that will setup BlueZ with mesh support and run the gateway. However, due to the use of the bluetooth hardware, I can not guarantee that it is working everywhere. For now I tested it on a Raspberry Pi 4 with Raspberry Pi OS 2022-09-22 (bullseye). If you are able to run it on other hardware just notify me as I will try to keep track of compatible setups. 33 | 34 | - If you have a blank Raspberry Pi you need to install docker and git first. 35 | 36 | - Clone the repository and create a `config.yaml` file under `docker/config/`: 37 | 38 | ``` 39 | mqtt: 40 | broker: 41 | [username: ] 42 | [password: ] 43 | node_id: mqtt_mesh 44 | mesh: 45 | : 46 | uuid: 47 | name: 48 | type: light # thats it for now 49 | [relay: ] # whether this node should act as relay 50 | ... 51 | ``` 52 | 53 | - **It is very important to disable bluetooth on the host system!** This is neccessary, because the bluetooth-mesh service needs exclusive access to the bluetooth device. 54 | 55 | ``` 56 | sudo systemctl stop bluetooth 57 | sudo systemctl disable bluetooth 58 | ``` 59 | 60 | - Start the container using docker compose and grab a coffee. This took one and a half hours for me to complete on a Raspberry Pi 4. It might seem stuck when compiling numpy, but this actually takes half an hour. 61 | 62 | ``` 63 | docker compose build 64 | docker compose up -d 65 | ``` 66 | 67 | Note that the container currently runs `/bin/bash` in the foreground, because the gateway exits if no nodes are provisioned. This will change in the future (I plan on implementing a simple web interface for provisioning). Also, there might be an error message on the very first startup. It should be gone on the second try. 68 | 69 | ### Using the command line within docker 70 | 71 | Since the web interface is not yet available, you need to use the command line to scan and provision devices. With the container running, you can access the command line inside the docker container from the host system using: 72 | 73 | ``` 74 | docker compose exec app /bin/bash 75 | ``` 76 | 77 | From there, it might be neccessary to stop the running python process. 78 | 79 | ``` 80 | ps -ef | grep gateway 81 | kill 82 | ``` 83 | 84 | I placed the configuration files in `/var/lib/bluetooth/mesh`, so you need to add `--basedir /var/lib/bluetooth/mesh` to every command. So for example the scan command would look like this: 85 | 86 | ``` 87 | python3 gateway.py --basedir /var/lib/bluetooth/mesh scan 88 | ``` 89 | 90 | Once you are done, switch back to the host system (simply `exit`) and restart the container for the changes to take effect. 91 | 92 | ``` 93 | docker compose restart 94 | ``` 95 | 96 | ## Manual setup 97 | 98 | If you do not want to use the docker image or for some reason it is not compatible, you can try to setup everything manually. However, this can be a little tricky. 99 | 100 | After cloning the repository, the easy part is to install the Python requirements using `pip3 install -r requirements.txt` (probably inside a virtual environment). The hard part is the get the `bluetooth-mesh` service running. This usually requires to build BlueZ from scratch and replace the available BlueZ installation. Have a look at the docker installation scripts, they should be a good starting point on what you need to do. 101 | 102 | Once you get it running, it might be neccessary to stop the default `bluetooth` service first and ensure that your bluetooth device (probably `hci0`) is not locked. Place the configuration file (see docker installation) inside the main folder and name it `config.yaml`. 103 | 104 | With that available, you should be able to run the application from the `gateway` folder (try `python3 gateway.py scan` first). 105 | 106 | ### Running the gateway 107 | 108 | Calling `python3 gateway.py` without further arguments will start the MQTT gateway and keep it alive. All provisioned devices should be discovered by Home Assistant and become available. If not, check the Home Assistant MQTT integration. If no devices are provisioned, the application will exit. 109 | 110 | ## Provisioning a device 111 | 112 | **Make sure you know how to reset your device in case something goes wrong here.** Also it might be neccessary to edit the `store.yaml` by hand in case something fails. 113 | 114 | _Remember that you need to add the `--basedir /var/lib/bluetooth/mesh` switch after `gateway.py` if you are using the command line within docker._ 115 | 116 | 1. Scan for unprovisioned devices with `python3 gateway.py scan`. 117 | 1. Create an entry for the device(s) you want to add in the `config.yaml`. 118 | 1. Provision the device with `python3 gateway.py prov --uuid add`. 119 | 1. Configure the device with `python3 gateway.py prov --uuid config`. 120 | _Do not skip this step, otherwise the device is not part of the application network and it will not respond properly._ 121 | 122 | - To list all provisioned devices use `python3 gateway.py prov list`. 123 | - You can remove and reset a device with `python3 gateway.py prov --uuid reset`. 124 | -------------------------------------------------------------------------------- /gateway/modules/provisioner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from uuid import UUID 5 | 6 | from bluetooth_mesh import models 7 | 8 | from . import Module 9 | 10 | 11 | class ProvisionerModule(Module): 12 | """ 13 | Provide provisioning functionality 14 | """ 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | self.provisioning_done = asyncio.Event() 20 | 21 | def initialize(self, app, store, config): 22 | super().initialize(app, store, config) 23 | 24 | # ensure new devices are provisioned correctly 25 | self._base_address = self.store.get("base_address", 4) 26 | self.store.persist() 27 | 28 | def setup_cli(self, parser): 29 | parser.add_argument("task") 30 | parser.add_argument("--uuid", default=None) 31 | 32 | async def handle_cli(self, args): 33 | if args.task == "list": 34 | self.print_node_list() 35 | return 36 | 37 | # configure all provisioned 38 | if args.task == "config" and args.uuid is None: 39 | for node in self.app.nodes.all(): 40 | if not node.configured: 41 | await self._configure(node) 42 | 43 | self.print_node_list() 44 | return 45 | 46 | # provision nodes from configuration 47 | if args.task == "add" and args.uuid is None: 48 | for _, info in self.app._config.require("mesh").items(): 49 | uuid = UUID(info["uuid"]) 50 | if not self.app.nodes.has(uuid): 51 | await self._provision(uuid) 52 | 53 | self.print_node_list() 54 | return 55 | 56 | # reset nodes from configuration 57 | if args.task == "reset" and args.uuid is None: 58 | for node in list(self.app.nodes.all()): 59 | if node.config.optional("id", None) is None: 60 | await self._reset(node) 61 | 62 | self.print_node_list() 63 | return 64 | 65 | try: 66 | uuid = UUID(args.uuid) 67 | except: 68 | print("Invalid uuid") 69 | return 70 | 71 | if args.task == "add": 72 | await self._provision(uuid) 73 | self.print_node_list() 74 | return 75 | 76 | node = self.app.nodes.get(uuid) 77 | if node is None: 78 | print("Unknown node") 79 | return 80 | 81 | if args.task == "config": 82 | await self._configure(node) 83 | return 84 | 85 | if args.task == "reset": 86 | await self._reset(node) 87 | self.print_node_list() 88 | return 89 | 90 | print(f"Unknown task {args.task}") 91 | 92 | def print_node_list(self): 93 | """ 94 | Print user friendly node list 95 | """ 96 | 97 | print(f"\nMesh contains {len(self.app.nodes)} node(s):") 98 | for node in self.app.nodes.all(): 99 | node.print_info() 100 | 101 | def _request_prov_data(self, count): 102 | """ 103 | This method is implemented by a Provisioner capable application 104 | and is called when the remote device has been fully 105 | authenticated and confirmed. 106 | 107 | :param count: consecutive unicast addresses the remote device is requesting 108 | :return: 109 | :param unet_index: Subnet index of the net_key 110 | :param uunicast: Primary Unicast address of the new node 111 | """ 112 | logging.info(f"Provisioning {count} new address(es)") 113 | 114 | prov_data = [0, self._base_address] 115 | self._base_address += count 116 | 117 | self.store.set("base_address", self._base_address) 118 | self.store.persist() 119 | 120 | return prov_data 121 | 122 | def _add_node_complete(self, uuid, unicast, count): 123 | """ 124 | This method is called when the node provisioning initiated 125 | by an AddNode() method call successfully completed. 126 | 127 | :param uuid: 16 byte remote device UUID 128 | :param unicast: primary address that has been assigned to the new node, and the address of it's config server 129 | :param count: number of unicast addresses assigned to the new node 130 | """ 131 | _uuid = UUID(bytes=uuid) 132 | 133 | self.app.nodes.create( 134 | _uuid, 135 | { 136 | "type": "generic", 137 | "unicast": unicast, 138 | "count": count, 139 | }, 140 | ) 141 | self.app.nodes.persist() 142 | 143 | logging.info(f"Provisioned {_uuid} as {unicast} ({count})") 144 | self.provisioning_done.set() 145 | 146 | def _add_node_failed(self, uuid, reason): 147 | """ 148 | This method is called when the node provisioning initiated by 149 | AddNode() has failed. Depending on how far Provisioning 150 | proceeded before failing, some cleanup of cached data may be 151 | required. 152 | 153 | :param uuid: 16 byte remote device UUID 154 | :param reason: reason for provisioning failure 155 | """ 156 | _uuid = UUID(bytes=uuid) 157 | 158 | logging.error(f"Failed to provision {_uuid}:\n{reason}") 159 | self.provisioning_done.set() 160 | 161 | async def _provision(self, uuid): 162 | logging.info(f"Provisioning node {uuid}...") 163 | 164 | # provision new node 165 | self.provisioning_done.clear() 166 | await self.app.management_interface.add_node(uuid) 167 | await self.provisioning_done.wait() 168 | 169 | async def _configure(self, node): 170 | logging.info(f"Configuring node {node}...") 171 | 172 | client = self.app.elements[0][models.ConfigClient] 173 | 174 | # add application key 175 | try: 176 | status = await client.add_app_key( 177 | node.unicast, 178 | net_index=0, 179 | app_key_index=self.app.app_keys[0][0], 180 | net_key_index=self.app.app_keys[0][1], 181 | app_key=self.app.app_keys[0][2], 182 | ) 183 | except: 184 | logging.exception(f"Failed to add app key for node {node}") 185 | 186 | status = await client.delete_app_key( 187 | node.unicast, net_index=0, app_key_index=self.app.app_keys[0][0], net_key_index=self.app.app_keys[0][1] 188 | ) 189 | status = await client.add_app_key( 190 | node.unicast, 191 | net_index=0, 192 | app_key_index=self.app.app_keys[0][0], 193 | net_key_index=self.app.app_keys[0][1], 194 | app_key=self.app.app_keys[0][2], 195 | ) 196 | 197 | # update friend state 198 | if node.config.optional("relay", False): 199 | status = await client.set_relay( 200 | node.unicast, 201 | net_index=0, 202 | relay=True, 203 | retransmit_count=2, 204 | ) 205 | 206 | # try to set node type from Home Assistant 207 | node.type = node.config.optional("type", node.type) 208 | 209 | node.configured = True 210 | self.app.nodes.persist() 211 | 212 | async def _reset(self, node): 213 | logging.info(f"Resetting node {node}...") 214 | 215 | client = self.app.elements[0][models.ConfigClient] 216 | 217 | await client.node_reset(node.unicast, net_index=0) 218 | 219 | self.app.nodes.delete(str(node.uuid)) 220 | self.app.nodes.persist() 221 | -------------------------------------------------------------------------------- /gateway/gateway.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import secrets 4 | import argparse 5 | import uuid 6 | import os 7 | 8 | from contextlib import AsyncExitStack, suppress 9 | 10 | from bluetooth_mesh.application import Application, Element 11 | from bluetooth_mesh.crypto import ApplicationKey, DeviceKey, NetworkKey 12 | from bluetooth_mesh.messages.config import GATTNamespaceDescriptor 13 | from bluetooth_mesh import models 14 | 15 | from tools import Config, Store, Tasks 16 | from mesh import Node, NodeManager 17 | from mqtt import HassMqttMessenger 18 | 19 | from modules.provisioner import ProvisionerModule 20 | from modules.scanner import ScannerModule 21 | from modules.manager import ManagerModule 22 | 23 | from mesh.nodes.light import Light 24 | 25 | 26 | logging.basicConfig(level=logging.DEBUG) 27 | 28 | 29 | MESH_MODULES = { 30 | "prov": ProvisionerModule(), 31 | "scan": ScannerModule(), 32 | "mgmt": ManagerModule(), 33 | } 34 | 35 | 36 | NODE_TYPES = { 37 | "generic": Node, 38 | "light": Light, 39 | } 40 | 41 | 42 | class MainElement(Element): 43 | """ 44 | Represents the main element of the application node 45 | """ 46 | 47 | LOCATION = GATTNamespaceDescriptor.MAIN 48 | MODELS = [ 49 | models.ConfigClient, 50 | models.HealthClient, 51 | models.GenericOnOffClient, 52 | models.LightLightnessClient, 53 | models.LightCTLClient, 54 | ] 55 | 56 | 57 | class MqttGateway(Application): 58 | 59 | COMPANY_ID = 0x05F1 # The Linux Foundation 60 | PRODUCT_ID = 1 61 | VERSION_ID = 1 62 | ELEMENTS = { 63 | 0: MainElement, 64 | } 65 | CRPL = 32768 66 | PATH = "/org/hass/mesh" 67 | 68 | def __init__(self, loop, basedir): 69 | super().__init__(loop) 70 | 71 | self._store = Store(location=os.path.join(basedir, "store.yaml")) 72 | self._config = Config(os.path.join(basedir, "config.yaml")) 73 | self._nodes = {} 74 | 75 | self._messenger = None 76 | 77 | self._app_keys = None 78 | self._dev_key = None 79 | self._primary_net_key = None 80 | self._new_keys = set() 81 | 82 | # load mesh modules 83 | for name, module in MESH_MODULES.items(): 84 | module.initialize(self, self._store.section(name), self._config) 85 | 86 | self._initialize() 87 | 88 | @property 89 | def dev_key(self): 90 | if not self._dev_key: 91 | raise Exception("Device key not ready") 92 | return self._dev_key 93 | 94 | @property 95 | def primary_net_key(self): 96 | if not self._primary_net_key: 97 | raise Exception("Primary network key not ready") 98 | return 0, self._primary_net_key 99 | 100 | @property 101 | def app_keys(self): 102 | if not self._app_keys: 103 | raise Exception("Application keys not ready") 104 | return self._app_keys 105 | 106 | @property 107 | def nodes(self): 108 | return self._nodes 109 | 110 | def _load_key(self, keychain, name): 111 | if name not in keychain: 112 | logging.info(f"Generating {name}...") 113 | keychain[name] = secrets.token_hex(16) 114 | self._new_keys.add(name) 115 | try: 116 | return bytes.fromhex(keychain[name]) 117 | except: 118 | raise Exception("Invalid device key") 119 | 120 | def _initialize(self): 121 | keychain = self._store.get("keychain") or {} 122 | local = self._store.section("local") 123 | nodes = self._store.section("nodes") 124 | 125 | # load or set application parameters 126 | self.address = local.get("address", 1) 127 | self.iv_index = local.get("iv_index", 5) 128 | 129 | # load or generate keys 130 | self._dev_key = DeviceKey(self._load_key(keychain, "device_key")) 131 | self._primary_net_key = NetworkKey(self._load_key(keychain, "network_key")) 132 | self._app_keys = [ 133 | # currently just a single application key supported 134 | (0, 0, ApplicationKey(self._load_key(keychain, "app_key"))), 135 | ] 136 | 137 | # initialize node manager 138 | self._nodes = NodeManager(nodes, self._config, NODE_TYPES) 139 | 140 | # initialize MQTT messenger 141 | self._messenger = HassMqttMessenger(self._config, self._nodes) 142 | 143 | # persist changes 144 | self._store.set("keychain", keychain) 145 | self._store.persist() 146 | 147 | async def _import_keys(self): 148 | logging.info("Importing keys...") 149 | 150 | if "primary_net_key" in self._new_keys: 151 | # register primary network key as subnet key 152 | await self.management_interface.import_subnet(0, self.primary_net_key[1]) 153 | logging.info("Imported primary net key as subnet key") 154 | 155 | if "app_key" in self._new_keys: 156 | # import application key into daemon 157 | await self.management_interface.import_app_key(*self.app_keys[0]) 158 | logging.info("Imported app key") 159 | 160 | # update application key for client models 161 | client = self.elements[0][models.GenericOnOffClient] 162 | await client.bind(self.app_keys[0][0]) 163 | client = self.elements[0][models.LightLightnessClient] 164 | await client.bind(self.app_keys[0][0]) 165 | client = self.elements[0][models.LightCTLClient] 166 | await client.bind(self.app_keys[0][0]) 167 | 168 | async def _try_bind_node(self, node): 169 | while not node.ready.is_set(): 170 | try: 171 | await node.bind(self) 172 | logging.info(f"Bound node {node}") 173 | node.ready.set() 174 | except: 175 | logging.exception(f"Failed to bind node {node}. Retry in 1 minute.") 176 | await asyncio.sleep(60) 177 | 178 | def scan_result(self, rssi, data, options): 179 | MESH_MODULES["scan"]._scan_result(rssi, data, options) 180 | 181 | def request_prov_data(self, count): 182 | return MESH_MODULES["prov"]._request_prov_data(count) 183 | 184 | def add_node_complete(self, uuid, unicast, count): 185 | MESH_MODULES["prov"]._add_node_complete(uuid, unicast, count) 186 | 187 | def add_node_failed(self, uuid, reason): 188 | MESH_MODULES["prov"]._add_node_failed(uuid, reason) 189 | 190 | def shutdown(self, tasks): 191 | self._messenger.shutdown() 192 | 193 | async def run(self, args): 194 | async with AsyncExitStack() as stack: 195 | tasks = await stack.enter_async_context(Tasks()) 196 | 197 | # connect to daemon 198 | self.token_ring.token = self._store.get("token") 199 | await stack.enter_async_context(self) 200 | await self.connect() 201 | 202 | # immediately store token after connect 203 | self._store.set("token", self.token_ring.token) 204 | self._store.persist() 205 | 206 | # leave network 207 | if args.leave: 208 | await self.leave() 209 | self._nodes.reset() 210 | self._nodes.persist() 211 | return 212 | 213 | try: 214 | # set overall application key 215 | await self.add_app_key(*self.app_keys[0]) 216 | except: 217 | logging.exception(f"Failed to set app key {self._app_keys[0][2].bytes.hex()}") 218 | 219 | # try to re-add application key 220 | await self.delete_app_key(self.app_keys[0][0], self.app_keys[0][1]) 221 | await self.add_app_key(*self.app_keys[0]) 222 | 223 | # force reloading keys 224 | if args.reload: 225 | self._new_keys.add("primary_net_key") 226 | self._new_keys.add("app_key") 227 | 228 | # configure all keys 229 | await self._import_keys() 230 | 231 | # run user task if specified 232 | if "handler" in args: 233 | await args.handler(args) 234 | return 235 | 236 | # initialize all nodes 237 | for node in self._nodes.all(): 238 | tasks.spawn(self._try_bind_node(node), f"bind {node}") 239 | tasks.spawn(node.refresh(), f"periodically refresh {node}") 240 | 241 | # start MQTT task 242 | tasks.spawn(self._messenger.run(self), "run messenger") 243 | 244 | # wait for all tasks 245 | await tasks.gather() 246 | 247 | 248 | def main(): 249 | parser = argparse.ArgumentParser() 250 | parser.add_argument("--leave", action="store_true") 251 | parser.add_argument("--reload", action="store_true") 252 | parser.add_argument("--basedir", default=os.getenv("GATEWAY_BASEDIR","..")) 253 | 254 | # module specific CLI interfaces 255 | subparsers = parser.add_subparsers() 256 | for name, module in MESH_MODULES.items(): 257 | subparser = subparsers.add_parser(name) 258 | subparser.set_defaults(handler=module.handle_cli) 259 | module.setup_cli(subparser) 260 | 261 | args = parser.parse_args() 262 | 263 | loop = asyncio.get_event_loop() 264 | app = MqttGateway(loop, args.basedir) 265 | 266 | with suppress(KeyboardInterrupt): 267 | loop.run_until_complete(app.run(args)) 268 | 269 | 270 | if __name__ == "__main__": 271 | main() 272 | --------------------------------------------------------------------------------