Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/ble_monitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand Down
5 changes: 5 additions & 0 deletions custom_components/ble_monitor/ble_parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions custom_components/ble_monitor/ble_parser/otodata.py
Original file line number Diff line number Diff line change
@@ -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("<H", data[20:22])[0]
product_name = f"MT4AD-TM{model_number}"
else:
product_name = packet_type[3:] if len(packet_type) > 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
14 changes: 14 additions & 0 deletions custom_components/ble_monitor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2356,6 +2367,7 @@ class BLEMonitorBinarySensorEntityDescription(
'MS2' : 'MOCREO',
'S-MATE' : 'Sonoff',
'R5' : 'Sonoff',
'Propane Tank Monitor' : 'Otodata',
}


Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -2535,6 +2548,7 @@ class BLEMonitorBinarySensorEntityDescription(
"speed",
"steps",
"rssi",
"tank level",
"temperature",
"temperature probe 1",
"temperature probe 2",
Expand Down
77 changes: 77 additions & 0 deletions custom_components/ble_monitor/test/test_otodata_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for the Otodata parser"""
import logging
from unittest import TestCase

from custom_components.ble_monitor.ble_parser import BleParser
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from custom_components.ble_monitor.ble_parser import BleParser
from 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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn’t seem to do anything, I think it can be removed.

"""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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These commented lines can be removed.

# 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!
64 changes: 64 additions & 0 deletions docs/_devices/Otodata_Propane_Monitor.md
Original file line number Diff line number Diff line change
@@ -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
Loading