-
-
Notifications
You must be signed in to change notification settings - Fork 276
feat: Add support for Otodata propane tank monitors #1521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
atheismann
wants to merge
4
commits into
custom-components:master
Choose a base branch
from
atheismann:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| _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): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.