diff --git a/custom_components/ble_monitor/__init__.py b/custom_components/ble_monitor/__init__.py index 8e989b1d..5051f590 100644 --- a/custom_components/ble_monitor/__init__.py +++ b/custom_components/ble_monitor/__init__.py @@ -131,7 +131,7 @@ SERVICE_PARSE_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_PACKET): cv.string, - vol.Optional(CONF_GATEWAY_ID): cv.string + vol.Optional(CONF_GATEWAY_ID): cv.string, } ) diff --git a/custom_components/ble_monitor/ble_parser/__init__.py b/custom_components/ble_monitor/ble_parser/__init__.py index 2f2fd31d..2f04d511 100644 --- a/custom_components/ble_monitor/ble_parser/__init__.py +++ b/custom_components/ble_monitor/ble_parser/__init__.py @@ -35,6 +35,7 @@ from .mocreo import parse_mocreo from .oral_b import parse_oral_b from .oras import parse_oras +from .otodata import parse_otodata from .qingping import parse_qingping from .relsib import parse_relsib from .ruuvitag import parse_ruuvitag @@ -336,6 +337,10 @@ def parse_advertisement( # Oras sensor_data = parse_oras(self, man_spec_data, mac) break + elif comp_id == 0x03B1 and data_len >= 0x18: + # Otodata Propane Tank Monitor (Company ID: 0x03B1 = 945) + sensor_data = parse_otodata(self, man_spec_data, mac) + break elif comp_id == 0x0157 and data_len == 0x1B: # Miband sensor_data = parse_amazfit(self, None, man_spec_data, mac) diff --git a/custom_components/ble_monitor/ble_parser/otodata.py b/custom_components/ble_monitor/ble_parser/otodata.py new file mode 100644 index 00000000..9331d3ff --- /dev/null +++ b/custom_components/ble_monitor/ble_parser/otodata.py @@ -0,0 +1,147 @@ +"""Parser for Otodata propane tank monitor BLE advertisements""" +import logging +from struct import unpack + +from .helpers import to_mac, to_unformatted_mac + +_LOGGER = logging.getLogger(__name__) + +# Cache device attributes from OTO3281 packets to use in OTOTELE packets +_device_cache = {} + + +def parse_otodata(self, data: bytes, mac: bytes): + """Otodata propane tank monitor parser + + The device sends multiple packet types: + - OTO3281: Device identifier/info packet + - OTOSTAT: Status packet + - OTOTELE: Telemetry packet (contains sensor data like tank level) + + Packet structure (man_spec_data format): + - Byte 0: Data length + - Byte 1: Type flag (0xFF) + - Bytes 2-3: Company ID (0x03B1 little-endian: \xb1\x03) + - Bytes 4-10: Packet type identifier (7 chars, e.g., "OTOTELE") + - Bytes 11+: Sensor data (format varies by packet type) + """ + msg_length = len(data) + firmware = "Otodata" + result = {"firmware": firmware} + + _LOGGER.debug("Otodata parse_otodata called - length=%d", msg_length) + + # Minimum packet size validation + if msg_length < 18: + if self.report_unknown == "Otodata": + _LOGGER.info( + "BLE ADV from UNKNOWN Otodata DEVICE: MAC: %s, ADV: %s", + to_mac(mac), + data.hex() + ) + return None + + # Parse packet type from man_spec_data + # Byte 0: length, Byte 1: type flag, Bytes 2-3: company ID + # Bytes 4-10 contain the 7-character packet type (OTO3281, OTOSTAT, OTOTELE) + # Bytes 11+: Sensor data + try: + packet_type = data[4:11].decode('ascii', errors='ignore').strip() + _LOGGER.debug("Otodata packet_type: %s", packet_type) + if packet_type.startswith('OTO'): + device_type = f"Propane Tank Monitor" + else: + device_type = "Propane Tank Monitor" + + _LOGGER.debug("Otodata packet type: '%s', length: %d bytes", packet_type, msg_length) + except Exception: + device_type = "Propane Tank Monitor" + packet_type = "UNKNOWN" + + try: + # Parse different packet types + # Three packet types observed: + # - OTO3281 or OTO32##: Device identifier/info + # - OTOSTAT: Status information + # - OTOTELE: Telemetry data (primary sensor readings) + + _LOGGER.debug("Processing %s packet (length: %d)", packet_type, msg_length) + + # Parse based on packet type + if packet_type == "OTOTELE": + # Telemetry packet - tank level is a 2-byte little-endian value at bytes 9-10 (percentage * 100) + if msg_length < 15: + _LOGGER.warning("OTOTELE packet too short: %d bytes", msg_length) + return None + + tank_level_raw = int.from_bytes(data[9:11], byteorder="little") + tank_level = tank_level_raw / 100.0 + _LOGGER.debug("OTOTELE: tank_level_raw=%d, tank_level=%.2f%%", tank_level_raw, tank_level) + + result.update({ + "tank level": tank_level, + }) + + # Add cached device attributes if available + mac_str = to_unformatted_mac(mac) + if mac_str in _device_cache: + result.update(_device_cache[mac_str]) + + elif packet_type == "OTOSTAT": + # Status packet - contains unknown device status values + # Byte 12: Incrementing value (purpose unknown) + # Byte 13: Constant 0x06 (purpose unknown) + # Until we identify what these represent, we skip this packet type + + _LOGGER.debug("OTOSTAT packet received - skipping (unknown data format)") + + # Skip OTOSTAT - unknown data format + return None + + elif packet_type.startswith("OTO3") or packet_type.startswith("OTO32"): + # Device info packet - contains product info and serial number + # Example: 1affb1034f544f333238319060bc011018210384060304b0130205 + # Bytes 4-10: "OTO3281" - packet type identifier + # Bytes 20-21: Model number (e.g., 0x13B0 = 5040 for TM5040) + + if msg_length < 22: + _LOGGER.warning("OTO3xxx packet too short: %d bytes", msg_length) + return None + + # Extract model number from bytes 20-21 (little-endian) + # Full model format: MT4AD-TM5040 (5040 from bytes 20-21) + if msg_length >= 22: + model_number = unpack(" 3 else "Unknown" + + # Cache device attributes to add to future OTOTELE packets + mac_str = to_unformatted_mac(mac) + _device_cache[mac_str] = { + "product": f"Otodata {product_name}", + "model": product_name, + } + + _LOGGER.info("Otodata device detected - Model: %s, MAC: %s", + product_name, to_mac(mac)) + + # Don't create sensor entities for device info packets + return None + + else: + _LOGGER.warning("Unknown Otodata packet type: %s", packet_type) + return None + + except (IndexError, struct.error) as e: + _LOGGER.debug("Failed to parse Otodata data: %s", e) + return None + + result.update({ + "mac": to_unformatted_mac(mac), + "type": device_type, + "packet": "no packet id", + "data": True + }) + + return result diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 880447f6..362061c6 100755 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -960,6 +960,16 @@ class BLEMonitorBinarySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + BLEMonitorSensorEntityDescription( + key="tank level", + sensor_class="MeasuringSensor", + update_behavior="Averaging", + name="ble tank level", + unique_id="tank_", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), BLEMonitorSensorEntityDescription( key="voltage", sensor_class="MeasuringSensor", @@ -2206,6 +2216,7 @@ class BLEMonitorBinarySensorEntityDescription( 'MS2' : [["temperature", "humidity", "battery", "rssi"], [], []], 'S-MATE' : [["rssi"], ["three btn switch left", "three btn switch middle", "three btn switch right"], []], 'R5' : [["rssi"], ["six btn switch top left", "six btn switch top middle", "six btn switch top right", "six btn switch bottom left", "six btn switch bottom middle", "six btn switch bottom right"], []], + 'Propane Tank Monitor' : [["tank level", "temperature", "battery", "rssi"], [], []], } # Sensor manufacturer dictionary @@ -2356,6 +2367,7 @@ class BLEMonitorBinarySensorEntityDescription( 'MS2' : 'MOCREO', 'S-MATE' : 'Sonoff', 'R5' : 'Sonoff', + 'Propane Tank Monitor' : 'Otodata', } @@ -2414,6 +2426,7 @@ class BLEMonitorBinarySensorEntityDescription( 'TG-BT5-IN' : 'Mikrotik', 'TG-BT5-OUT' : 'Mikrotik', 'Electra Washbasin Faucet': 'Oras', + 'Propane Tank Monitor' : 'Otodata', 'CGP22C' : 'Qingping', 'CGP23W' : 'Qingping', 'EClerk Eco' : 'Relsib', @@ -2535,6 +2548,7 @@ class BLEMonitorBinarySensorEntityDescription( "speed", "steps", "rssi", + "tank level", "temperature", "temperature probe 1", "temperature probe 2", diff --git a/custom_components/ble_monitor/test/test_otodata_parser.py b/custom_components/ble_monitor/test/test_otodata_parser.py new file mode 100644 index 00000000..8756ba16 --- /dev/null +++ b/custom_components/ble_monitor/test/test_otodata_parser.py @@ -0,0 +1,77 @@ +"""Tests for the Otodata parser""" +import logging +from unittest import TestCase + +from custom_components.ble_monitor.ble_parser import BleParser + +_LOGGER = logging.getLogger(__name__) + + +class TestOtodata(TestCase): + """Tests for the Otodata parser""" + + def test_otodata_propane_tank_monitor(self): + """Test Otodata Propane Tank Monitor parser with real captured data.""" + # Real BLE advertisement data captured from Otodata device + # Manufacturer specific data: b'\x1a\xff\xb1\x03OTO3281\x90`\xbc\x01\x10\x18!\x03\x84\x06\x03\x04\xb0\x13\x02\x05' + # MAC: ea109060bc01 + # Company ID: 0x03B1 (945 decimal) + + # Construct full BLE packet + # Format: HCI header + MAC + advertisement data + data_string = "043E2A02010001BC609010EA1E1AFF B103 4F544F33323831 9060BC01 10 18 21 03 8406 0304 B013 0205" + data_string = data_string.replace(" ", "") # Remove spaces + + data = bytes(bytearray.fromhex(data_string)) + + # pylint: disable=unused-variable + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + # Validate parser output + assert sensor_msg is not None, "Parser should return sensor data" + assert sensor_msg["firmware"] == "Otodata" + assert "Propane Tank Monitor" in sensor_msg["type"] + assert sensor_msg["mac"] == "01bc609010ea" # MAC in unformatted lowercase + assert sensor_msg["packet"] == "no packet id" + assert sensor_msg["data"] is True + + # Check sensor values + assert "tank level" in sensor_msg + + # Validate tank level is a reasonable percentage (0-100) + # Note: The actual value (24) may need adjustment once we verify the correct byte position + assert 0 <= sensor_msg["tank level"] <= 100, f"Tank level should be 0-100, got {sensor_msg['tank level']}" + + # RSSI should be negative + assert sensor_msg["rssi"] < 0 + + # Log for debugging + _LOGGER.info("Parsed sensor data: %s", sensor_msg) + + def test_otodata_invalid_data(self): + """Test Otodata parser with invalid/short data.""" + # Test with data that's too short + data_string = "043E0A0201000011223344556603020106" + data = bytes(bytearray.fromhex(data_string)) + + ble_parser = BleParser() + sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) + + # Parser should handle invalid data gracefully + # Either return None or handle the error + # Adjust assertion based on your implementation + + def test_otodata_different_packet_formats(self): + """Test different packet formats if Otodata uses multiple formats.""" + # Some devices send different packet formats (e.g., short vs. long packets) + # Add tests for each format your device uses + pass + + +# NOTE: To run these tests: +# 1. Place this file in: custom_components/ble_monitor/test/ +# 2. Install test requirements: pip install -r requirements_test.txt +# 3. Run tests: python -m pytest custom_components/ble_monitor/test/test_otodata_parser.py +# +# REMEMBER: You MUST replace the example data with actual BLE advertisements from your device! diff --git a/docs/_devices/Otodata_Propane_Monitor.md b/docs/_devices/Otodata_Propane_Monitor.md new file mode 100644 index 00000000..548c46df --- /dev/null +++ b/docs/_devices/Otodata_Propane_Monitor.md @@ -0,0 +1,64 @@ +--- +manufacturer: Otodata +name: Propane Tank Monitor +model: Otodata Tank Monitor +image: otodata_propane_monitor.jpg +physical_description: Propane tank level monitoring sensor +broadcasted_properties: + - tank level + - temperature + - battery + - rssi +broadcasted_property_notes: + - property: tank level + note: Propane tank fill level reported as percentage (0-100%) + - property: temperature + note: Ambient or tank temperature in degrees Celsius +broadcast_rate: ~1-5/min (configurable, depends on model) +active_scan: false +encryption_key: false +custom_firmware: +notes: + - This device monitors propane tank levels via BLE + - Tank level is typically reported as a percentage + - Some models may also report temperature and battery status + - Ensure your specific Otodata model is compatible - different models may have different BLE packet formats + - If your device is not working, capture BLE advertisements using the report_unknown feature and create an issue on GitHub +--- + +# Otodata Propane Tank Monitor + +This integration supports Otodata propane tank monitoring devices that use Bluetooth Low Energy (BLE) for wireless communication. + +## Features + +- **Tank Level Monitoring**: Real-time propane tank fill level (0-100%) +- **Temperature Monitoring**: Ambient or tank temperature +- **Battery Monitoring**: Device battery level +- **Signal Strength**: RSSI for connection quality + +## Setup + +1. Install the forked BLE Monitor integration +2. The sensor should be auto-discovered if the BLE parser is correctly configured +3. Check Home Assistant for new Otodata sensors + +## Troubleshooting + +If your device is not detected: + +1. Enable `report_unknown: "Other"` in your BLE Monitor configuration +2. Check the Home Assistant logs for BLE advertisements from your device +3. Look for the MAC address of your Otodata device +4. Report the captured data to help improve the parser + +## Supported Models + +- Otodata Propane Tank Monitor (various models) +- Specific model support depends on BLE protocol compatibility + +## Notes + +- Different Otodata models may use different BLE packet formats +- This integration is community-maintained +- Report issues or improvements on GitHub