2023-02-20 20:27:17 +01:00
|
|
|
import json
|
|
|
|
import struct
|
|
|
|
from dataclasses import dataclass, field
|
2023-03-13 02:31:21 +01:00
|
|
|
from typing import Any, Callable, Dict, List, Optional
|
2023-02-20 20:27:17 +01:00
|
|
|
|
|
|
|
from bleak.backends.device import BLEDevice
|
|
|
|
from bleak.backends.scanner import AdvertisementData
|
2023-03-22 14:16:00 +01:00
|
|
|
from bluetooth_numbers import company, oui
|
2023-02-20 20:27:17 +01:00
|
|
|
|
2023-03-20 01:41:21 +01:00
|
|
|
from TheengsDecoder import decodeBLE, getAttribute, getProperties
|
2023-02-20 20:27:17 +01:00
|
|
|
|
|
|
|
from platypush.entities import Entity
|
|
|
|
from platypush.entities.batteries import Battery
|
2023-03-13 02:31:21 +01:00
|
|
|
from platypush.entities.bluetooth import BluetoothDevice, BluetoothService
|
2023-02-23 21:20:41 +01:00
|
|
|
from platypush.entities.contact import ContactSensor
|
2023-02-20 20:27:17 +01:00
|
|
|
from platypush.entities.electricity import (
|
|
|
|
CurrentSensor,
|
|
|
|
EnergySensor,
|
|
|
|
PowerSensor,
|
|
|
|
VoltageSensor,
|
|
|
|
)
|
2023-02-23 00:55:55 +01:00
|
|
|
from platypush.entities.heart import HeartRateSensor
|
2023-02-23 01:23:04 +01:00
|
|
|
from platypush.entities.humidity import DewPointSensor, HumiditySensor
|
2023-02-20 20:27:17 +01:00
|
|
|
from platypush.entities.illuminance import IlluminanceSensor
|
|
|
|
from platypush.entities.motion import MotionSensor
|
2023-02-23 01:42:26 +01:00
|
|
|
from platypush.entities.presence import PresenceSensor
|
2023-02-23 01:12:27 +01:00
|
|
|
from platypush.entities.pressure import PressureSensor
|
2023-02-20 20:27:17 +01:00
|
|
|
from platypush.entities.sensors import BinarySensor, NumericSensor, RawSensor
|
2023-02-23 00:50:06 +01:00
|
|
|
from platypush.entities.steps import StepsSensor
|
2023-02-20 20:27:17 +01:00
|
|
|
from platypush.entities.temperature import TemperatureSensor
|
2023-02-23 01:02:13 +01:00
|
|
|
from platypush.entities.time import TimeDurationSensor
|
2023-02-23 21:20:41 +01:00
|
|
|
from platypush.entities.weight import WeightSensor
|
2023-02-20 20:27:17 +01:00
|
|
|
|
2023-03-13 02:31:21 +01:00
|
|
|
from .._model import Protocol, ServiceClass
|
|
|
|
|
2023-02-20 20:27:17 +01:00
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class TheengsEntity:
|
|
|
|
"""
|
|
|
|
Utility class to store the data parsed from the Theengs library.
|
|
|
|
"""
|
|
|
|
|
|
|
|
data: dict = field(default_factory=dict)
|
|
|
|
properties: dict = field(default_factory=dict)
|
2023-03-13 02:31:21 +01:00
|
|
|
manufacturer: Optional[str] = None
|
2023-02-20 20:27:17 +01:00
|
|
|
model: Optional[str] = None
|
|
|
|
model_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
2023-02-22 02:26:51 +01:00
|
|
|
# pylint: disable=too-few-public-methods
|
|
|
|
class NullSensor:
|
|
|
|
"""
|
|
|
|
Dummy class to model sensors with null values (hence without sufficient
|
|
|
|
information for the application to infer the type).
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, *_, **__):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2023-02-20 20:27:17 +01:00
|
|
|
# Maps property names to transformer methods (first mapper choice).
|
|
|
|
_property_to_entity: Dict[str, Callable[[Any, Dict[str, Any]], Entity]] = {
|
2023-02-23 01:02:13 +01:00
|
|
|
'activity heart rate': lambda value, _: HeartRateSensor(value=value),
|
2023-02-23 01:27:31 +01:00
|
|
|
'atmospheric pressure': lambda value, conf: PressureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-20 20:27:17 +01:00
|
|
|
'battery': lambda value, conf: Battery(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', '%'),
|
|
|
|
min=conf.get('min', 0),
|
|
|
|
max=conf.get('min', 100),
|
|
|
|
),
|
2023-02-23 21:20:41 +01:00
|
|
|
'contact': lambda value, _: ContactSensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
'current': lambda value, conf: CurrentSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'A'),
|
|
|
|
),
|
2023-02-23 01:23:04 +01:00
|
|
|
'dew_point_sensor': lambda value, conf: DewPointSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-23 01:02:13 +01:00
|
|
|
'duration': lambda value, conf: TimeDurationSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-20 20:27:17 +01:00
|
|
|
'energy': lambda value, conf: EnergySensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'kWh'),
|
|
|
|
),
|
2023-02-23 01:42:26 +01:00
|
|
|
'heart rate': lambda value, _: HeartRateSensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
'humidity': lambda value, conf: HumiditySensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', '%'),
|
|
|
|
min=conf.get('min', 0),
|
|
|
|
max=conf.get('min', 100),
|
|
|
|
),
|
2023-02-23 01:12:27 +01:00
|
|
|
'light level': lambda value, conf: IlluminanceSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
|
|
|
'luminance': lambda value, conf: IlluminanceSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-23 21:20:41 +01:00
|
|
|
'moisture': lambda value, conf: HumiditySensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-23 01:12:27 +01:00
|
|
|
'motion': lambda value, _: MotionSensor(value=value),
|
2023-02-23 21:20:41 +01:00
|
|
|
'open': lambda value, _: BinarySensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
'power': lambda value, conf: PowerSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'W'),
|
|
|
|
),
|
2023-02-23 01:42:26 +01:00
|
|
|
'presence': lambda value, _: PresenceSensor(value=value),
|
2023-02-23 01:23:04 +01:00
|
|
|
'pressure': lambda value, conf: PressureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit'),
|
|
|
|
),
|
2023-02-23 00:50:06 +01:00
|
|
|
'steps': lambda value, _: StepsSensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
'temperature': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
2023-02-23 01:27:31 +01:00
|
|
|
'temperature2': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature3': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature4': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature5': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature6': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature7': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
|
|
|
'temperature8': lambda value, conf: TemperatureSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'C'),
|
|
|
|
),
|
2023-02-23 21:20:41 +01:00
|
|
|
'volt': lambda value, conf: VoltageSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'V'),
|
|
|
|
),
|
2023-02-20 20:27:17 +01:00
|
|
|
'voltage': lambda value, conf: VoltageSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'V'),
|
|
|
|
),
|
2023-02-23 21:20:41 +01:00
|
|
|
'weight': lambda value, conf: WeightSensor(
|
|
|
|
value=value,
|
|
|
|
unit=conf.get('unit', 'kg'),
|
|
|
|
),
|
2023-02-20 20:27:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
# Maps reported units to transformer methods (second mapper choice).
|
|
|
|
_unit_to_entity: Dict[str, Callable[[Any, Dict[str, Any]], Entity]] = {
|
|
|
|
'status': lambda value, _: BinarySensor(value=value),
|
2023-03-22 14:14:59 +01:00
|
|
|
'int': lambda value, _: NumericSensor(value=value),
|
|
|
|
'float': lambda value, _: NumericSensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
'%': lambda value, conf: NumericSensor(
|
|
|
|
value=value,
|
2023-03-22 14:14:59 +01:00
|
|
|
unit=conf.get('unit', '%'),
|
2023-02-20 20:27:17 +01:00
|
|
|
min=conf.get('min', 0),
|
|
|
|
max=conf.get('min', 100),
|
|
|
|
),
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Maps value types to transformer methods (third mapper choice).
|
|
|
|
_value_type_to_entity: Dict[type, Callable[[Any, Dict[str, Any]], Entity]] = {
|
|
|
|
bool: lambda value, _: BinarySensor(value=value),
|
2023-03-22 14:14:59 +01:00
|
|
|
int: lambda value, _: NumericSensor(value=value),
|
|
|
|
float: lambda value, _: NumericSensor(value=value),
|
|
|
|
str: lambda value, _: RawSensor(value=value),
|
2023-02-22 02:26:51 +01:00
|
|
|
bytes: lambda value, _: RawSensor(value=value),
|
|
|
|
bytearray: lambda value, _: RawSensor(value=value),
|
2023-02-20 20:27:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2023-03-22 22:39:01 +01:00
|
|
|
def _parse_services(
|
|
|
|
device: BLEDevice, data: AdvertisementData
|
|
|
|
) -> List[BluetoothService]:
|
2023-03-13 02:31:21 +01:00
|
|
|
"""
|
|
|
|
:param device: The target device.
|
2023-03-22 22:39:01 +01:00
|
|
|
:param data: Published beacon data.
|
2023-03-13 02:31:21 +01:00
|
|
|
:return: The parsed BLE services as a list of
|
|
|
|
:class:`platypush.entities.bluetooth.BluetoothService`.
|
|
|
|
"""
|
|
|
|
services: List[BluetoothService] = []
|
2023-03-22 22:39:01 +01:00
|
|
|
for srv in data.service_uuids or []:
|
2023-03-13 02:31:21 +01:00
|
|
|
try:
|
|
|
|
uuid = BluetoothService.to_uuid(srv)
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
# Not a valid UUID.
|
|
|
|
continue
|
|
|
|
|
|
|
|
srv_cls = ServiceClass.get(uuid)
|
|
|
|
services.append(
|
|
|
|
BluetoothService(
|
2023-03-23 17:10:37 +01:00
|
|
|
id=f'{device.address}::{uuid}',
|
2023-03-13 02:31:21 +01:00
|
|
|
uuid=uuid,
|
2023-03-19 12:54:09 +01:00
|
|
|
name=f'[{uuid}]' if srv_cls == ServiceClass.UNKNOWN else str(srv_cls),
|
2023-03-13 02:31:21 +01:00
|
|
|
protocol=Protocol.L2CAP,
|
|
|
|
is_ble=True,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return services
|
|
|
|
|
|
|
|
|
2023-02-20 20:27:17 +01:00
|
|
|
def device_to_entity(device: BLEDevice, data: AdvertisementData) -> BluetoothDevice:
|
|
|
|
"""
|
|
|
|
Convert the data received from a Bluetooth advertisement packet into a
|
|
|
|
compatible Platypush :class:`platypush.entity.bluetooth.BluetoothDevice`
|
|
|
|
entity, with the discovered services and characteristics exposed as children
|
|
|
|
entities.
|
|
|
|
"""
|
|
|
|
|
|
|
|
theengs_entity = _parse_advertisement_data(data)
|
2023-03-13 02:31:21 +01:00
|
|
|
props = (device.details or {}).get('props', {})
|
2023-03-22 22:39:01 +01:00
|
|
|
manufacturer = _parse_manufacturer(device, theengs_entity, data)
|
2023-02-20 20:27:17 +01:00
|
|
|
parent_entity = BluetoothDevice(
|
|
|
|
id=device.address,
|
|
|
|
model=theengs_entity.model,
|
|
|
|
reachable=True,
|
2023-03-13 02:31:21 +01:00
|
|
|
supports_ble=True,
|
|
|
|
supports_legacy=False,
|
|
|
|
address=device.address,
|
|
|
|
name=device.name or device.address,
|
|
|
|
connected=props.get('Connected', False),
|
2023-03-22 22:39:01 +01:00
|
|
|
rssi=data.rssi,
|
2023-03-13 02:31:21 +01:00
|
|
|
tx_power=props.get('TxPower'),
|
|
|
|
manufacturer=manufacturer,
|
2023-03-22 22:39:01 +01:00
|
|
|
children=_parse_services(device, data),
|
2023-02-20 20:27:17 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
parsed_entities = {
|
|
|
|
# Check if we can infer an entity mapper from the property name.
|
|
|
|
conf.get('name', name): _property_to_entity.get(
|
|
|
|
conf.get('name'),
|
|
|
|
# If not, check if we can infer an entity mapper from the reported unit.
|
|
|
|
_unit_to_entity.get(
|
|
|
|
conf.get('unit'),
|
|
|
|
# If not, check if we can infer an entity mapper from the value type.
|
|
|
|
_value_type_to_entity.get(
|
|
|
|
type(theengs_entity.data.get(name)),
|
2023-02-22 02:26:51 +01:00
|
|
|
# If not, default to a NullSensor.
|
|
|
|
lambda *_: NullSensor(),
|
2023-02-20 20:27:17 +01:00
|
|
|
),
|
|
|
|
),
|
|
|
|
)(theengs_entity.data.get(name), conf)
|
|
|
|
for name, conf in theengs_entity.properties.items()
|
|
|
|
}
|
|
|
|
|
|
|
|
for prop, entity in parsed_entities.items():
|
2023-02-22 02:26:51 +01:00
|
|
|
if isinstance(entity, NullSensor):
|
|
|
|
# Skip entities that we couldn't parse.
|
|
|
|
continue
|
|
|
|
|
2023-03-19 12:55:14 +01:00
|
|
|
entity.id = f'{parent_entity.address}::{prop}'
|
|
|
|
entity.name = prop.title()
|
2023-02-20 20:27:17 +01:00
|
|
|
parent_entity.children.append(entity)
|
2023-03-13 02:31:21 +01:00
|
|
|
entity.parent = parent_entity
|
2023-02-20 20:27:17 +01:00
|
|
|
|
|
|
|
return parent_entity
|
|
|
|
|
|
|
|
|
2023-03-22 22:39:01 +01:00
|
|
|
def _parse_manufacturer(
|
|
|
|
device: BLEDevice, entity: TheengsEntity, data: AdvertisementData
|
|
|
|
) -> Optional[str]:
|
2023-03-22 14:16:00 +01:00
|
|
|
"""
|
|
|
|
:param device: The target device.
|
|
|
|
:param entity: The entity that maps the received beacon data.
|
2023-03-22 22:39:01 +01:00
|
|
|
:param data:
|
2023-03-22 14:16:00 +01:00
|
|
|
:return: The parsed manufacturer name.
|
|
|
|
"""
|
|
|
|
|
|
|
|
# If the manufacturer has already been parsed, return it.
|
|
|
|
if entity.manufacturer:
|
|
|
|
return entity.manufacturer
|
|
|
|
|
|
|
|
# Otherwise, infer it from the first three bytes of the MAC address.
|
|
|
|
manufacturer = oui.get(':'.join(device.address.split(':')[:3]).upper())
|
|
|
|
if manufacturer:
|
|
|
|
return manufacturer
|
|
|
|
|
|
|
|
# Otherwise, infer it from the reported manufacturer_data.
|
2023-03-22 22:39:01 +01:00
|
|
|
for key in data.manufacturer_data:
|
2023-03-22 14:16:00 +01:00
|
|
|
manufacturer = company.get(key)
|
|
|
|
if manufacturer:
|
|
|
|
return manufacturer
|
|
|
|
|
|
|
|
# Otherwise, we couldn't parse the manufacturer.
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2023-02-20 20:27:17 +01:00
|
|
|
def _parse_advertisement_data(data: AdvertisementData) -> TheengsEntity:
|
|
|
|
"""
|
|
|
|
:param data: The data received from a Bluetooth advertisement packet.
|
|
|
|
:return: A :class:`platypush.entity.bluetooth.TheengsEntity` instance that
|
|
|
|
maps the parsed attributes.
|
|
|
|
"""
|
|
|
|
|
2023-03-13 02:31:21 +01:00
|
|
|
entity_args, properties, manufacturer, model, model_id = ({}, {}, None, None, None)
|
2023-02-20 20:27:17 +01:00
|
|
|
|
|
|
|
if data.service_data:
|
|
|
|
parsed_data = list(data.service_data.keys())[0]
|
|
|
|
# TheengsDecoder only accepts 16 bit uuid's, this converts the 128 bit uuid to 16 bit.
|
|
|
|
entity_args['servicedatauuid'] = parsed_data[4:8]
|
|
|
|
parsed_data = str(list(data.service_data.values())[0].hex())
|
|
|
|
entity_args['servicedata'] = parsed_data
|
|
|
|
|
|
|
|
if data.manufacturer_data:
|
|
|
|
parsed_data = str(
|
|
|
|
struct.pack('<H', list(data.manufacturer_data.keys())[0]).hex()
|
|
|
|
)
|
|
|
|
parsed_data += str(list(data.manufacturer_data.values())[0].hex())
|
|
|
|
entity_args['manufacturerdata'] = parsed_data
|
|
|
|
|
|
|
|
if data.local_name:
|
|
|
|
entity_args['name'] = data.local_name
|
|
|
|
|
|
|
|
if entity_args:
|
|
|
|
encoded_ret = decodeBLE(json.dumps(entity_args))
|
|
|
|
|
|
|
|
if encoded_ret:
|
|
|
|
entity_args = json.loads(encoded_ret)
|
|
|
|
|
|
|
|
if entity_args.get('model_id'):
|
|
|
|
properties = json.loads(getProperties(entity_args['model_id'])).get(
|
|
|
|
'properties', {}
|
|
|
|
)
|
|
|
|
model = getAttribute(entity_args['model_id'], 'model')
|
|
|
|
|
|
|
|
model_id = entity_args.pop('model_id', None)
|
|
|
|
|
|
|
|
return TheengsEntity(
|
|
|
|
data=entity_args,
|
|
|
|
properties=properties,
|
2023-03-13 02:31:21 +01:00
|
|
|
manufacturer=manufacturer,
|
2023-02-20 20:27:17 +01:00
|
|
|
model=model,
|
|
|
|
model_id=model_id,
|
|
|
|
)
|