From 7dbababa248a3092aebf722584bf8344721f7d8f Mon Sep 17 00:00:00 2001 From: Andrew J Theismann Date: Thu, 15 Jan 2026 14:04:28 -0500 Subject: [PATCH 1/3] add otodata propane monitor --- .../ble_monitor/ble_parser/__init__.py | 5 + .../ble_monitor/ble_parser/otodata.py | 133 ++++++++++++++++++ custom_components/ble_monitor/const.py | 2 + .../ble_monitor/test/test_otodata_parser.py | 77 ++++++++++ docs/_devices/Otodata_Propane_Monitor.md | 64 +++++++++ 5 files changed, 281 insertions(+) create mode 100644 custom_components/ble_monitor/ble_parser/otodata.py create mode 100644 custom_components/ble_monitor/test/test_otodata_parser.py create mode 100644 docs/_devices/Otodata_Propane_Monitor.md 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..29d075db --- /dev/null +++ b/custom_components/ble_monitor/ble_parser/otodata.py @@ -0,0 +1,133 @@ +"""Parser for Otodata propane tank monitor BLE advertisements""" +import logging +import struct +from struct import unpack + +from .helpers import to_mac, to_unformatted_mac + +_LOGGER = logging.getLogger(__name__) + + +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 (variable length): + - Bytes 0-1: Company ID (0x03B1 in little-endian) + - Bytes 2-9: Packet type identifier (e.g., "OTOTELE") + - Bytes 9+: Sensor data (format varies by packet type) + """ + msg_length = len(data) + firmware = "Otodata" + result = {"firmware": firmware} + + # 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 bytes 2-9 + try: + packet_type = data[2:9].decode('ascii', errors='ignore').strip() + if packet_type.startswith('OTO'): + device_type = f"Propane Tank Monitor" + else: + device_type = "Propane Tank Monitor" + + _LOGGER.info("Otodata packet type: %s, length: %d bytes", packet_type, msg_length) + except Exception: + device_type = "Propane Tank Monitor" + packet_type = "UNKNOWN" + + try: + # Parse sensor values based on packet type + # Tank is at 71% - searching for this value + + _LOGGER.info("=== Otodata Packet Analysis ===") + _LOGGER.info("Packet Type: %s", packet_type) + _LOGGER.info("Full hex: %s", data.hex()) + _LOGGER.info("MAC: %s", to_mac(mac)) + + # Log all bytes after the packet type identifier (starting at byte 9) + start_byte = 9 + _LOGGER.info("Data bytes (starting at position %d):", start_byte) + for i in range(start_byte, msg_length): + _LOGGER.info(" Byte %d: 0x%02X = %d", i, data[i], data[i]) + + # Search for tank level = 71% in various encodings + _LOGGER.info("Searching for tank level ~71%%...") + candidates = [] + + for i in range(start_byte, msg_length): + val = data[i] + # Direct match (69-73) + if 69 <= val <= 73: + candidates.append((i, val, "direct")) + _LOGGER.info(" *** CANDIDATE at byte %d: %d (direct) ***", i, val) + # Inverted (100 - value) + inverted = 100 - val + if 69 <= inverted <= 73: + candidates.append((i, inverted, "inverted")) + _LOGGER.info(" *** CANDIDATE at byte %d: 100-%d = %d (inverted/empty%%) ***", i, val, inverted) + + # Try 16-bit values (little-endian) + if msg_length >= start_byte + 2: + _LOGGER.info("16-bit values (little-endian):") + for i in range(start_byte, min(msg_length - 1, start_byte + 16)): + val_le = unpack(" 12: + empty_percent = data[12] + tank_level = 100 - empty_percent + _LOGGER.info("OTOTELE: Using byte 12 (inverted): 100-%d = %d%%", empty_percent, tank_level) + + # If we found candidates but haven't set tank_level yet + if tank_level is None and candidates: + # Use the first candidate found + byte_pos, value, method = candidates[0] + tank_level = value + _LOGGER.info("Using first candidate: byte %d = %d (%s)", byte_pos, value, method) + elif tank_level is None: + # Default fallback + tank_level = data[15] if msg_length > 15 else 0 + _LOGGER.info("No candidates found, using byte 15: %d", tank_level) + + result.update({ + "tank level": tank_level, + }) + + _LOGGER.info("Final result: tank_level=%d%% (expected ~71%%)", tank_level) + _LOGGER.info("=== End Analysis ===") + + + 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..8645c91a 100755 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -2206,6 +2206,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 +2357,7 @@ class BLEMonitorBinarySensorEntityDescription( 'MS2' : 'MOCREO', 'S-MATE' : 'Sonoff', 'R5' : 'Sonoff', + 'Propane Tank Monitor' : 'Otodata', } 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 From 7f86fc99051b45eee22ee28ac81ca58559930b81 Mon Sep 17 00:00:00 2001 From: Andrew J Theismann Date: Thu, 15 Jan 2026 18:00:00 -0500 Subject: [PATCH 2/3] Add support for Otodata propane tank monitors - Add parser for Otodata BLE advertisements (Company ID: 0x03B1) - Parse OTOTELE packets for tank level percentage - Parse OTO3281 packets for device model information - Add 'tank level' sensor type and Propane Tank Monitor device - Add 'tank level' to AUTO_SENSOR_LIST for entity creation - Tested with MT4AD-TM774 device --- custom_components/ble_monitor/__init__.py | 2 +- .../ble_monitor/ble_parser/otodata.py | 171 ++++++++++-------- custom_components/ble_monitor/const.py | 12 ++ 3 files changed, 110 insertions(+), 75 deletions(-) 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/otodata.py b/custom_components/ble_monitor/ble_parser/otodata.py index 29d075db..66f816b4 100644 --- a/custom_components/ble_monitor/ble_parser/otodata.py +++ b/custom_components/ble_monitor/ble_parser/otodata.py @@ -1,12 +1,14 @@ """Parser for Otodata propane tank monitor BLE advertisements""" import logging -import struct 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 @@ -16,15 +18,19 @@ def parse_otodata(self, data: bytes, mac: bytes): - OTOSTAT: Status packet - OTOTELE: Telemetry packet (contains sensor data like tank level) - Packet structure (variable length): - - Bytes 0-1: Company ID (0x03B1 in little-endian) - - Bytes 2-9: Packet type identifier (e.g., "OTOTELE") - - Bytes 9+: Sensor data (format varies by packet type) + 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": @@ -35,89 +41,106 @@ def parse_otodata(self, data: bytes, mac: bytes): ) return None - # Parse packet type from bytes 2-9 + # 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[2:9].decode('ascii', errors='ignore').strip() + 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.info("Otodata packet type: %s, length: %d bytes", packet_type, msg_length) + _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 sensor values based on packet type - # Tank is at 71% - searching for this value - - _LOGGER.info("=== Otodata Packet Analysis ===") - _LOGGER.info("Packet Type: %s", packet_type) - _LOGGER.info("Full hex: %s", data.hex()) - _LOGGER.info("MAC: %s", to_mac(mac)) - - # Log all bytes after the packet type identifier (starting at byte 9) - start_byte = 9 - _LOGGER.info("Data bytes (starting at position %d):", start_byte) - for i in range(start_byte, msg_length): - _LOGGER.info(" Byte %d: 0x%02X = %d", i, data[i], data[i]) - - # Search for tank level = 71% in various encodings - _LOGGER.info("Searching for tank level ~71%%...") - candidates = [] - - for i in range(start_byte, msg_length): - val = data[i] - # Direct match (69-73) - if 69 <= val <= 73: - candidates.append((i, val, "direct")) - _LOGGER.info(" *** CANDIDATE at byte %d: %d (direct) ***", i, val) - # Inverted (100 - value) - inverted = 100 - val - if 69 <= inverted <= 73: - candidates.append((i, inverted, "inverted")) - _LOGGER.info(" *** CANDIDATE at byte %d: 100-%d = %d (inverted/empty%%) ***", i, val, inverted) + # Parse different packet types + # Three packet types observed: + # - OTO3281 or OTO32##: Device identifier/info + # - OTOSTAT: Status information + # - OTOTELE: Telemetry data (primary sensor readings) - # Try 16-bit values (little-endian) - if msg_length >= start_byte + 2: - _LOGGER.info("16-bit values (little-endian):") - for i in range(start_byte, min(msg_length - 1, start_byte + 16)): - val_le = unpack(" 12: - empty_percent = data[12] - tank_level = 100 - empty_percent - _LOGGER.info("OTOTELE: Using byte 12 (inverted): 100-%d = %d%%", empty_percent, tank_level) - - # If we found candidates but haven't set tank_level yet - if tank_level is None and candidates: - # Use the first candidate found - byte_pos, value, method = candidates[0] - tank_level = value - _LOGGER.info("Using first candidate: byte %d = %d (%s)", byte_pos, value, method) - elif tank_level is None: - # Default fallback - tank_level = data[15] if msg_length > 15 else 0 - _LOGGER.info("No candidates found, using byte 15: %d", tank_level) - - result.update({ - "tank level": tank_level, - }) - - _LOGGER.info("Final result: tank_level=%d%% (expected ~71%%)", tank_level) - _LOGGER.info("=== End Analysis ===") - + # Telemetry packet - contains tank level + # Packet type ends at byte 10, data starts at byte 11 + # Byte 14: Empty percentage (100 - value = tank level) + # Example from logs: byte 14 = 0x1c (28) → 100 - 28 = 72% full + + if msg_length < 15: + _LOGGER.warning("OTOTELE packet too short: %d bytes", msg_length) + return None + + empty_percent = data[14] + tank_level = 100 - empty_percent + + _LOGGER.debug("OTOTELE: tank_level=%d%%", tank_level) + + # NOTE: Battery data location not yet identified in OTOTELE packets + # Byte 13 varies inconsistently and doesn't reliably represent battery level + # Battery percentage may be in OTO3281 or OTOSTAT packets, or require GATT connection + + 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) diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 8645c91a..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", @@ -2416,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', @@ -2537,6 +2548,7 @@ class BLEMonitorBinarySensorEntityDescription( "speed", "steps", "rssi", + "tank level", "temperature", "temperature probe 1", "temperature probe 2", From 2e6d6812be1b6d674a8ac76be4af13e199d28e9e Mon Sep 17 00:00:00 2001 From: Andrew J Theismann Date: Thu, 29 Jan 2026 21:56:54 -0500 Subject: [PATCH 3/3] update tank level to correct bytes --- .../ble_monitor/ble_parser/otodata.py | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/custom_components/ble_monitor/ble_parser/otodata.py b/custom_components/ble_monitor/ble_parser/otodata.py index 66f816b4..9331d3ff 100644 --- a/custom_components/ble_monitor/ble_parser/otodata.py +++ b/custom_components/ble_monitor/ble_parser/otodata.py @@ -69,28 +69,19 @@ def parse_otodata(self, data: bytes, mac: bytes): # Parse based on packet type if packet_type == "OTOTELE": - # Telemetry packet - contains tank level - # Packet type ends at byte 10, data starts at byte 11 - # Byte 14: Empty percentage (100 - value = tank level) - # Example from logs: byte 14 = 0x1c (28) → 100 - 28 = 72% full - + # 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 - - empty_percent = data[14] - tank_level = 100 - empty_percent - - _LOGGER.debug("OTOTELE: tank_level=%d%%", tank_level) - - # NOTE: Battery data location not yet identified in OTOTELE packets - # Byte 13 varies inconsistently and doesn't reliably represent battery level - # Battery percentage may be in OTO3281 or OTOSTAT packets, or require GATT connection - + + 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: