diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index b5b89b6a1..58735af9f 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -289,6 +289,13 @@ def parse_advertisement( # Jaalee sensor_data = parse_jaalee(self, man_spec_data, mac, rssi) break + elif len(man_spec_data_list) == 2: + # Teltonika Eye (can send both iBeacon and Teltonika data in one message) + second_man_spec_data = man_spec_data_list[1] + second_comp_id = (second_man_spec_data[3] << 8) | second_man_spec_data[2] + if second_comp_id == 0x089A: + sensor_data = parse_teltonika(self, second_man_spec_data, local_name, mac, rssi) + break else: # iBeacon sensor_data, tracker_data = parse_ibeacon(self, man_spec_data, mac, rssi) @@ -325,6 +332,10 @@ def parse_advertisement( man_spec_data = b"".join(man_spec_data_list) sensor_data = parse_hormann(self, man_spec_data, mac, rssi) break + elif comp_id == 0x089A: + # Teltonika + sensor_data = parse_teltonika(self, man_spec_data, local_name, mac, rssi) + break elif comp_id == 0x094F and data_len == 0x15: # Mikrotik sensor_data = parse_mikrotik(self, man_spec_data, mac, rssi) diff --git a/custom_components/ble_monitor/ble_parser/bthome_const.py b/custom_components/ble_monitor/ble_parser/bthome_const.py index a2fac78ad..cb8431e55 100644 --- a/custom_components/ble_monitor/ble_parser/bthome_const.py +++ b/custom_components/ble_monitor/ble_parser/bthome_const.py @@ -123,7 +123,7 @@ class MeasTypes: factor=0.01, ), 0x15: MeasTypes( - meas_format="battery", + meas_format="battery low", ), 0x16: MeasTypes( meas_format="battery charging", diff --git a/custom_components/ble_monitor/ble_parser/teltonika.py b/custom_components/ble_monitor/ble_parser/teltonika.py index a990214bc..3f290cdea 100644 --- a/custom_components/ble_monitor/ble_parser/teltonika.py +++ b/custom_components/ble_monitor/ble_parser/teltonika.py @@ -11,8 +11,11 @@ def parse_teltonika(self, data, complete_local_name, source_mac, rssi): """Teltonika parser""" result = {"firmware": "Teltonika"} teltonika_mac = source_mac + device_id = (data[3] << 8) | data[2] - if complete_local_name == "PUCK_T1": + if device_id == 0x089A: + device_type = "EYE sensor" + elif complete_local_name == "PUCK_T1": device_type = "Blue Puck T" elif complete_local_name == "PUCK_TH": device_type = "Blue Puck RHT" @@ -62,6 +65,49 @@ def parse_teltonika(self, data, complete_local_name, source_mac, rssi): # Battery (batt,) = unpack("h", sensor_data[0:2]) + result.update({"temperature": temp / 100}) + sensor_data = sensor_data[2:] + if flags & (1 << 1): # bit 1 + # Humidity + humi = sensor_data[0] + result.update({"humidity": humi}) + sensor_data = sensor_data[1:] + if flags & (1 << 2): # bit 2 + # Magnetic sensor presence + if flags & (1 << 3): # bit 3 + # magnetic field is detected + result.update({"magnetic field detected": 1}) + else: + # magnetic field is not detected + result.update({"magnetic field detected": 0}) + if flags & (1 << 4): # bit 4 + # Movement sensor counter + # Most significant bit indicates movement state + # 15 least significant bits represent count of movement events. + moving = sensor_data[0] & (1 << 7) + count = ((sensor_data[0] & 0b01111111) << 8) + sensor_data[1] + result.update({"moving": moving, "movement counter": count}) + sensor_data = sensor_data[2:] + if flags & (1 << 5): # bit 5 + # Movement sensor angle + # Most significant byte – pitch (-90/+90) + # Two least significant bytes – roll (-180/+180) + (pitch, roll,) = unpack(">bh", sensor_data[0:3]) + result.update({"roll": roll, "pitch": pitch}) + sensor_data = sensor_data[3:] + if flags & (1 << 6): # bit 6 + # Low battery indication sensor presence + result.update({"battery low": 1}) + if flags & (1 << 7): # bit 7 + # Battery voltage value presence + volt = round(2.0 + sensor_data[0] * 0.01, 3) + result.update({"voltage": volt}) data_size -= packet_size packet_start += packet_size diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 5b7b44c37..c2f686270 100755 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -134,6 +134,15 @@ class BLEMonitorBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.BATTERY_CHARGING, force_update=True, ), + BLEMonitorBinarySensorEntityDescription( + key="battery low", + sensor_class="BaseBinarySensor", + update_behavior="Instantly", + name="battery low", + unique_id="bl_", + device_class=BinarySensorDeviceClass.BATTERY, + force_update=True, + ), BLEMonitorBinarySensorEntityDescription( key="carbon monoxide", sensor_class="BaseBinarySensor", @@ -465,6 +474,16 @@ class BLEMonitorBinarySensorEntityDescription( device_class=None, force_update=False, ), + BLEMonitorBinarySensorEntityDescription( + key="magnetic field detected", + sensor_class="BaseBinarySensor", + update_behavior="Instantly", + name="magnetic field", + unique_id="magnetic_field_", + icon="mdi:magnet", + device_class=None, + force_update=False, + ), BLEMonitorBinarySensorEntityDescription( key="bed occupancy", sensor_class="BaseBinarySensor", @@ -1035,6 +1054,30 @@ class BLEMonitorBinarySensorEntityDescription( suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, ), + BLEMonitorSensorEntityDescription( + key="roll", + sensor_class="InstantUpdateSensor", + update_behavior="Instantly", + name="roll", + unique_id="roll_", + icon="mdi:horizontal-rotate-clockwise", + native_unit_of_measurement="°", + device_class=None, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + BLEMonitorSensorEntityDescription( + key="pitch", + sensor_class="InstantUpdateSensor", + update_behavior="Instantly", + name="pitch", + unique_id="pitch_", + icon="mdi:rotate-right-variant", + native_unit_of_measurement="°", + device_class=None, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), BLEMonitorSensorEntityDescription( key="distance", sensor_class="InstantUpdateSensor", @@ -1226,6 +1269,18 @@ class BLEMonitorBinarySensorEntityDescription( suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, ), + BLEMonitorSensorEntityDescription( + key="movement counter", + sensor_class="StateChangedSensor", + update_behavior="StateChange", + name="movement counter", + unique_id="mv_cnt_", + icon="mdi:counter", + native_unit_of_measurement=None, + device_class=None, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), BLEMonitorSensorEntityDescription( key="score", sensor_class="StateChangedSensor", @@ -1796,18 +1851,23 @@ class BLEMonitorBinarySensorEntityDescription( # Sensors that support automatic adding of sensors and binary sensors AUTO_MANUFACTURER_DICT = { 'Amazfit Smart Scale' : 'Amazfit', + 'Blustream' : 'Blustream', 'BTHome' : 'BTHome', 'HHCCJCY10' : 'HHCC', + 'HolyIOT BLE tracker' : 'HolyIOT', + 'Supramatic E4 BS' : 'Hörmann', 'IBS-TH' : 'Inkbird', 'IBS-TH2/P01B' : 'Inkbird', 'JHT' : 'Jaalee', 'TG-BT5-IN' : 'Mikrotik', 'TG-BT5-OUT' : 'Mikrotik', + 'Electra Washbasin Faucet': 'Oras', 'EClerk Eco' : 'Relsib', 'WT51' : 'Relsib', 'Blue Puck T' : 'Teltonika', 'Blue Coin T' : 'Teltonika', 'Blue Puck RHT' : 'Teltonika', + 'EYE sensor' : 'Teltonika', 'TP357' : 'Thermopro', 'TP359' : 'Thermopro', 'Tilt Red' : 'Tilt', @@ -1819,16 +1879,13 @@ class BLEMonitorBinarySensorEntityDescription( 'Tilt Yellow' : 'Tilt', 'Tilt Pink' : 'Tilt', 'MMC-W505' : 'Xiaomi', - 'Electra Washbasin Faucet': 'Oras', - 'Supramatic E4 BS' : 'Hörmann', - 'Blustream' : 'Blustream', - 'HolyIOT BLE tracker' : 'HolyIOT', } # Binary Sensors that are automatically added if device is in AUTO_MANUFACTURER_DICT AUTO_BINARY_SENSOR_LIST = [ "battery charging", + "battery low", "carbon monoxide", "cold", "connectivity", @@ -1841,6 +1898,7 @@ class BLEMonitorBinarySensorEntityDescription( "impact", "light", "lock", + "magnetic field detected", "motion", "moisture detected", "moving", @@ -1884,13 +1942,16 @@ class BLEMonitorBinarySensorEntityDescription( "illuminance", "impedance", "moisture", + "movement counter", "non-stabilized weight", "opening percentage", + "pitch", "pm2.5", "pm10", "power", "pressure", "pulse", + "roll", "rotation", "speed", "steps", diff --git a/custom_components/ble_monitor/sensor.py b/custom_components/ble_monitor/sensor.py index 239136560..3fb2e52cf 100644 --- a/custom_components/ble_monitor/sensor.py +++ b/custom_components/ble_monitor/sensor.py @@ -339,6 +339,8 @@ class BaseSensor(RestoreSensor, SensorEntity): # | |**pulse # | |**shake # | |**rotation + # | |**roll + # | |**pitch # | |**distance # | |**distance mm # | |**duration @@ -354,6 +356,7 @@ class BaseSensor(RestoreSensor, SensorEntity): # | | |**major # | | |**minor # | | |**count + # | | |**movement counter # | | |**score # | | |**air quality # | | |**text diff --git a/custom_components/ble_monitor/test/test_bthome_v2.py b/custom_components/ble_monitor/test/test_bthome_v2.py index 5e07f0975..606f58e7e 100644 --- a/custom_components/ble_monitor/test/test_bthome_v2.py +++ b/custom_components/ble_monitor/test/test_bthome_v2.py @@ -305,6 +305,23 @@ def test_bthome_v2_moisture(self): assert sensor_msg["moisture"] == 3.07 assert sensor_msg["rssi"] == -52 + def test_bthome_v2_battery_low(self): + """Test BTHome parser for battery low measurement""" + data_string = "043E1602010000A5808FE648540A0201060616D2FC401501CC" + data = bytes(bytearray.fromhex(data_string)) + + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg["firmware"] == "BTHome V2" + assert sensor_msg["type"] == "BTHome" + assert sensor_msg["mac"] == "5448E68F80A5" + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] + assert sensor_msg["battery low"] == 1 + assert sensor_msg["rssi"] == -52 + def test_bthome_v2_battery_charging(self): """Test BTHome parser for battery charging measurement""" data_string = "043E1602010000A5808FE648540A0201060616D2FC401601CC" diff --git a/custom_components/ble_monitor/test/test_teltonika.py b/custom_components/ble_monitor/test/test_teltonika.py index 405ff33eb..9875d09f1 100644 --- a/custom_components/ble_monitor/test/test_teltonika.py +++ b/custom_components/ble_monitor/test/test_teltonika.py @@ -102,3 +102,26 @@ def test_ela_blue_puck_T_with_batt(self): assert sensor_msg["temperature"] == 27.12 assert sensor_msg["battery"] == 13 assert sensor_msg["rssi"] == -35 + + def test_teltonika_eye(self): + """Test Teltonika parser for Teltonika Eye sensor.""" + data_string = "043E46020103001897035ECFD03a0201061AFF4C000215FFFFFFFF0B8C404510C655AAB636EBEFBB700055020C094D50315F313233343536370EFF9A0801B708B4120CCB0BFFC767DD" + data = bytes(bytearray.fromhex(data_string)) + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + assert sensor_msg["firmware"] == "Teltonika" + assert sensor_msg["type"] == "EYE sensor" + assert sensor_msg["mac"] == "D0CF5E039718" + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] + assert sensor_msg["temperature"] == 22.28 + assert sensor_msg["humidity"] == 18 + assert sensor_msg["magnetic field detected"] == 0 + assert sensor_msg["moving"] == 0 + assert sensor_msg["movement counter"] == 3275 + assert sensor_msg["roll"] == -57 + assert sensor_msg["pitch"] == 11 + assert sensor_msg["voltage"] == 3.03 + assert sensor_msg["rssi"] == -35 diff --git a/docs/_devices/blue_coin_t.md b/docs/_devices/Teltonika_blue_coin_t.md similarity index 100% rename from docs/_devices/blue_coin_t.md rename to docs/_devices/Teltonika_blue_coin_t.md diff --git a/docs/_devices/blue_pluck_rht.md b/docs/_devices/Teltonika_blue_pluck_rht.md similarity index 100% rename from docs/_devices/blue_pluck_rht.md rename to docs/_devices/Teltonika_blue_pluck_rht.md diff --git a/docs/_devices/blue_pluck_t.md b/docs/_devices/Teltonika_blue_pluck_t.md similarity index 100% rename from docs/_devices/blue_pluck_t.md rename to docs/_devices/Teltonika_blue_pluck_t.md diff --git a/docs/_devices/Teltonika_eye_beacon.md b/docs/_devices/Teltonika_eye_beacon.md new file mode 100644 index 000000000..b7e571734 --- /dev/null +++ b/docs/_devices/Teltonika_eye_beacon.md @@ -0,0 +1,31 @@ +--- +manufacturer: Teltonika +name: Eye Beacon +model: Eye Beacon +image: Teltonika_eye.png +physical_description: Rounded beacon, no screen +broadcasted_properties: + - temperature + - humidity + - roll + - pitch + - magnetic field detected + - moving + - movement counter + - battery low + - rssi +broadcasted_property_notes: +broadcast_rate: +active_scan: see notes +encryption_key: +custom_firmware: +notes: + - Teltonika EYE sensors can send BLE data in 3 formats +1. iBeacon + EYE Sensors +2. Eddystone + EYE Sensors +3. EYE Sensors + +For iBeacon + EYE Sensors and Eddystone + EYE Sensors protocols only iBeacon/Eddystone packet is broadcasted and will be seen by both active and passive scans, to see the EYE Sensors packet you need to use active scan. In other words in an environment where no BLE devices are scanning with an active scan or in case when there are no scanning devices at all, only the iBeacon/Eddystone packet will be sent by the BTS device to conserve energy. + +This means that if you use the iBeacon (1) or Eddystone format (2), you will need to enable active scan. If you are only using EYE sensors format (3), you can use passive scanning in BLE monitor. +--- diff --git a/docs/assets/images/Teltonika_eye.png b/docs/assets/images/Teltonika_eye.png new file mode 100644 index 000000000..3a90483a9 Binary files /dev/null and b/docs/assets/images/Teltonika_eye.png differ