11"""Parser for Otodata propane tank monitor BLE advertisements"""
22import logging
3- import struct
43from struct import unpack
54
65from .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
1113def 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 )
0 commit comments