Skip to content

Commit 7f86fc9

Browse files
committed
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
1 parent 1f7ea80 commit 7f86fc9

3 files changed

Lines changed: 110 additions & 75 deletions

File tree

custom_components/ble_monitor/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@
131131
SERVICE_PARSE_DATA_SCHEMA = vol.Schema(
132132
{
133133
vol.Required(CONF_PACKET): cv.string,
134-
vol.Optional(CONF_GATEWAY_ID): cv.string
134+
vol.Optional(CONF_GATEWAY_ID): cv.string,
135135
}
136136
)
137137

custom_components/ble_monitor/ble_parser/otodata.py

Lines changed: 97 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Parser for Otodata propane tank monitor BLE advertisements"""
22
import logging
3-
import struct
43
from struct import unpack
54

65
from .helpers import to_mac, to_unformatted_mac
76

87
_LOGGER = logging.getLogger(__name__)
98

9+
# Cache device attributes from OTO3281 packets to use in OTOTELE packets
10+
_device_cache = {}
11+
1012

1113
def parse_otodata(self, data: bytes, mac: bytes):
1214
"""Otodata propane tank monitor parser
@@ -16,15 +18,19 @@ def parse_otodata(self, data: bytes, mac: bytes):
1618
- OTOSTAT: Status packet
1719
- OTOTELE: Telemetry packet (contains sensor data like tank level)
1820
19-
Packet structure (variable length):
20-
- Bytes 0-1: Company ID (0x03B1 in little-endian)
21-
- Bytes 2-9: Packet type identifier (e.g., "OTOTELE")
22-
- Bytes 9+: Sensor data (format varies by packet type)
21+
Packet structure (man_spec_data format):
22+
- Byte 0: Data length
23+
- Byte 1: Type flag (0xFF)
24+
- Bytes 2-3: Company ID (0x03B1 little-endian: \xb1\x03)
25+
- Bytes 4-10: Packet type identifier (7 chars, e.g., "OTOTELE")
26+
- Bytes 11+: Sensor data (format varies by packet type)
2327
"""
2428
msg_length = len(data)
2529
firmware = "Otodata"
2630
result = {"firmware": firmware}
2731

32+
_LOGGER.debug("Otodata parse_otodata called - length=%d", msg_length)
33+
2834
# Minimum packet size validation
2935
if msg_length < 18:
3036
if self.report_unknown == "Otodata":
@@ -35,89 +41,106 @@ def parse_otodata(self, data: bytes, mac: bytes):
3541
)
3642
return None
3743

38-
# Parse packet type from bytes 2-9
44+
# Parse packet type from man_spec_data
45+
# Byte 0: length, Byte 1: type flag, Bytes 2-3: company ID
46+
# Bytes 4-10 contain the 7-character packet type (OTO3281, OTOSTAT, OTOTELE)
47+
# Bytes 11+: Sensor data
3948
try:
40-
packet_type = data[2:9].decode('ascii', errors='ignore').strip()
49+
packet_type = data[4:11].decode('ascii', errors='ignore').strip()
50+
_LOGGER.debug("Otodata packet_type: %s", packet_type)
4151
if packet_type.startswith('OTO'):
4252
device_type = f"Propane Tank Monitor"
4353
else:
4454
device_type = "Propane Tank Monitor"
4555

46-
_LOGGER.info("Otodata packet type: %s, length: %d bytes", packet_type, msg_length)
56+
_LOGGER.debug("Otodata packet type: '%s', length: %d bytes", packet_type, msg_length)
4757
except Exception:
4858
device_type = "Propane Tank Monitor"
4959
packet_type = "UNKNOWN"
5060

5161
try:
52-
# Parse sensor values based on packet type
53-
# Tank is at 71% - searching for this value
54-
55-
_LOGGER.info("=== Otodata Packet Analysis ===")
56-
_LOGGER.info("Packet Type: %s", packet_type)
57-
_LOGGER.info("Full hex: %s", data.hex())
58-
_LOGGER.info("MAC: %s", to_mac(mac))
59-
60-
# Log all bytes after the packet type identifier (starting at byte 9)
61-
start_byte = 9
62-
_LOGGER.info("Data bytes (starting at position %d):", start_byte)
63-
for i in range(start_byte, msg_length):
64-
_LOGGER.info(" Byte %d: 0x%02X = %d", i, data[i], data[i])
65-
66-
# Search for tank level = 71% in various encodings
67-
_LOGGER.info("Searching for tank level ~71%%...")
68-
candidates = []
69-
70-
for i in range(start_byte, msg_length):
71-
val = data[i]
72-
# Direct match (69-73)
73-
if 69 <= val <= 73:
74-
candidates.append((i, val, "direct"))
75-
_LOGGER.info(" *** CANDIDATE at byte %d: %d (direct) ***", i, val)
76-
# Inverted (100 - value)
77-
inverted = 100 - val
78-
if 69 <= inverted <= 73:
79-
candidates.append((i, inverted, "inverted"))
80-
_LOGGER.info(" *** CANDIDATE at byte %d: 100-%d = %d (inverted/empty%%) ***", i, val, inverted)
62+
# Parse different packet types
63+
# Three packet types observed:
64+
# - OTO3281 or OTO32##: Device identifier/info
65+
# - OTOSTAT: Status information
66+
# - OTOTELE: Telemetry data (primary sensor readings)
8167

82-
# Try 16-bit values (little-endian)
83-
if msg_length >= start_byte + 2:
84-
_LOGGER.info("16-bit values (little-endian):")
85-
for i in range(start_byte, min(msg_length - 1, start_byte + 16)):
86-
val_le = unpack("<H", data[i:i+2])[0]
87-
_LOGGER.info(" Bytes [%d-%d]: %d", i, i+1, val_le)
88-
if 69 <= val_le <= 73:
89-
candidates.append((i, val_le, "16-bit LE"))
90-
_LOGGER.info(" *** CANDIDATE: %d (16-bit LE) ***", val_le)
91-
92-
# Determine which value to use based on packet type
93-
tank_level = None
68+
_LOGGER.debug("Processing %s packet (length: %d)", packet_type, msg_length)
9469

70+
# Parse based on packet type
9571
if packet_type == "OTOTELE":
96-
# Telemetry packet - most likely contains tank level
97-
# Based on analysis, byte 12 (0x1c=28) inverted gives 72% (close to 71%)
98-
if msg_length > 12:
99-
empty_percent = data[12]
100-
tank_level = 100 - empty_percent
101-
_LOGGER.info("OTOTELE: Using byte 12 (inverted): 100-%d = %d%%", empty_percent, tank_level)
102-
103-
# If we found candidates but haven't set tank_level yet
104-
if tank_level is None and candidates:
105-
# Use the first candidate found
106-
byte_pos, value, method = candidates[0]
107-
tank_level = value
108-
_LOGGER.info("Using first candidate: byte %d = %d (%s)", byte_pos, value, method)
109-
elif tank_level is None:
110-
# Default fallback
111-
tank_level = data[15] if msg_length > 15 else 0
112-
_LOGGER.info("No candidates found, using byte 15: %d", tank_level)
113-
114-
result.update({
115-
"tank level": tank_level,
116-
})
117-
118-
_LOGGER.info("Final result: tank_level=%d%% (expected ~71%%)", tank_level)
119-
_LOGGER.info("=== End Analysis ===")
120-
72+
# Telemetry packet - contains tank level
73+
# Packet type ends at byte 10, data starts at byte 11
74+
# Byte 14: Empty percentage (100 - value = tank level)
75+
# Example from logs: byte 14 = 0x1c (28) → 100 - 28 = 72% full
76+
77+
if msg_length < 15:
78+
_LOGGER.warning("OTOTELE packet too short: %d bytes", msg_length)
79+
return None
80+
81+
empty_percent = data[14]
82+
tank_level = 100 - empty_percent
83+
84+
_LOGGER.debug("OTOTELE: tank_level=%d%%", tank_level)
85+
86+
# NOTE: Battery data location not yet identified in OTOTELE packets
87+
# Byte 13 varies inconsistently and doesn't reliably represent battery level
88+
# Battery percentage may be in OTO3281 or OTOSTAT packets, or require GATT connection
89+
90+
result.update({
91+
"tank level": tank_level,
92+
})
93+
94+
# Add cached device attributes if available
95+
mac_str = to_unformatted_mac(mac)
96+
if mac_str in _device_cache:
97+
result.update(_device_cache[mac_str])
98+
99+
elif packet_type == "OTOSTAT":
100+
# Status packet - contains unknown device status values
101+
# Byte 12: Incrementing value (purpose unknown)
102+
# Byte 13: Constant 0x06 (purpose unknown)
103+
# Until we identify what these represent, we skip this packet type
104+
105+
_LOGGER.debug("OTOSTAT packet received - skipping (unknown data format)")
106+
107+
# Skip OTOSTAT - unknown data format
108+
return None
109+
110+
elif packet_type.startswith("OTO3") or packet_type.startswith("OTO32"):
111+
# Device info packet - contains product info and serial number
112+
# Example: 1affb1034f544f333238319060bc011018210384060304b0130205
113+
# Bytes 4-10: "OTO3281" - packet type identifier
114+
# Bytes 20-21: Model number (e.g., 0x13B0 = 5040 for TM5040)
115+
116+
if msg_length < 22:
117+
_LOGGER.warning("OTO3xxx packet too short: %d bytes", msg_length)
118+
return None
119+
120+
# Extract model number from bytes 20-21 (little-endian)
121+
# Full model format: MT4AD-TM5040 (5040 from bytes 20-21)
122+
if msg_length >= 22:
123+
model_number = unpack("<H", data[20:22])[0]
124+
product_name = f"MT4AD-TM{model_number}"
125+
else:
126+
product_name = packet_type[3:] if len(packet_type) > 3 else "Unknown"
127+
128+
# Cache device attributes to add to future OTOTELE packets
129+
mac_str = to_unformatted_mac(mac)
130+
_device_cache[mac_str] = {
131+
"product": f"Otodata {product_name}",
132+
"model": product_name,
133+
}
134+
135+
_LOGGER.info("Otodata device detected - Model: %s, MAC: %s",
136+
product_name, to_mac(mac))
137+
138+
# Don't create sensor entities for device info packets
139+
return None
140+
141+
else:
142+
_LOGGER.warning("Unknown Otodata packet type: %s", packet_type)
143+
return None
121144

122145
except (IndexError, struct.error) as e:
123146
_LOGGER.debug("Failed to parse Otodata data: %s", e)

custom_components/ble_monitor/const.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,16 @@ class BLEMonitorBinarySensorEntityDescription(
960960
state_class=SensorStateClass.MEASUREMENT,
961961
entity_category=EntityCategory.DIAGNOSTIC,
962962
),
963+
BLEMonitorSensorEntityDescription(
964+
key="tank level",
965+
sensor_class="MeasuringSensor",
966+
update_behavior="Averaging",
967+
name="ble tank level",
968+
unique_id="tank_",
969+
native_unit_of_measurement=PERCENTAGE,
970+
suggested_display_precision=0,
971+
state_class=SensorStateClass.MEASUREMENT,
972+
),
963973
BLEMonitorSensorEntityDescription(
964974
key="voltage",
965975
sensor_class="MeasuringSensor",
@@ -2416,6 +2426,7 @@ class BLEMonitorBinarySensorEntityDescription(
24162426
'TG-BT5-IN' : 'Mikrotik',
24172427
'TG-BT5-OUT' : 'Mikrotik',
24182428
'Electra Washbasin Faucet': 'Oras',
2429+
'Propane Tank Monitor' : 'Otodata',
24192430
'CGP22C' : 'Qingping',
24202431
'CGP23W' : 'Qingping',
24212432
'EClerk Eco' : 'Relsib',
@@ -2537,6 +2548,7 @@ class BLEMonitorBinarySensorEntityDescription(
25372548
"speed",
25382549
"steps",
25392550
"rssi",
2551+
"tank level",
25402552
"temperature",
25412553
"temperature probe 1",
25422554
"temperature probe 2",

0 commit comments

Comments
 (0)