Reverse Engineering COTS Products For Your Project

Sometimes you’re working on a project and there’s one piece that’s 99% the same as an existing commercial off-the-shelf product, and wouldn’t it be great if you could just integrate that? Today, I’m going to try to do just that, with an off-the-shelf propane tank scale.

One of the things I’ve been meaning to integrate into the hot tub project is a way to gauge propane usage. I don’t fully trust it yet, but ultimately the propane heater is supposed to run on its own, indefinitely, maintaining tub temperature and obviating all but the most strictly necessary human interventions and oversights. Of course, that means it’ll consume propane, and since our house doesn’t have plumbed natural gas to tap, eventually it’ll run the tank empty. This hard fact leads to a couple requirements, in order of strictly necessary (1) to nice-to-have (3):

  1. Must detect and alert propane run-out, and disable heating loop pump.
    For one, I want to know when heating has become impossible to avoid the rude surprise of a too-cold tub just when I want to use it. For two, it’d be best not to waste 40W circulating water indefinitely for no purpose.
  2. Should gauge propane availability, and calculate whether set point is achievable before run-out and for how long.
    Wouldn’t it be cool if I could set 102°F in the controller interface, and get a readout back that says “not enough propane for that, sorry.” Or even “102 maintainable for 3 days until propane run-out.”
  3. Should calculate running cost.
    This one could be either live or after-the-fact calculation based on logged data. Either way, I’d like to know something like “those 3 degrees just cost you $2 in propane” and “maintaining the current temperature burns $4/day.” Live or post-facto, this requires detailed lbs/minute propane usage data.

Option 1: Inferred Metering

One option is to use operating parameters to infer propane usage. For instance, I know the lowest knob setting on the propane heater uses X lbs in Y minutes for a heating rate of ~4.5°F/hr. My control logic could detect this heating rate, infer this propane usage, and integrate accordingly. To satisfy requirement 1 above, all the logic would really need to do is detect “I’ve been ‘heating’ for 5 minutes, but the calculated heat rate is 0, so I must be out.” Indeed, I plan on implementing this logic regardless.

Option 2: Direct Measurement

Of course, implementing option 1 beyond just runout detection seems 1) error prone and 2) like a lot of manual work up front, burning for X minutes and manually measuring propane usage. A more accurate option would be to measure tank weight directly and simply use that data. Luckily, off the shelf solutions for this exist, so it’s “just” a question of integrating one.

Target 1: Flame King

flame king product image

The very first Amazon search result for “propane scale” happens to be both nearly perfect for our use case, and one of the cheapest options: the Flame King smart bluetooth propane tank scale for $20. This is a battery powered bluetooth device that directly measures tank weight. The Raspberry Pi has bluetooth! This is perfect! $20 buys us the following possible strategies

  1. Reverse engineer the BLE interface and use the device as-is, with some code on the Pi to poll the tank weight.
  2. Replace the firmware on the device to do this, if the protocol can’t be reversed
  3. Put in a new controller of our own design to read the sensor directly, then get that data to the Pi wired or wirelessly as we please.

Step 1: Teardown

The first step is to figure out what we’re working with.

As I suspected while shopping, this device uses 3 half-bridge load cells to measure tank weight. If all we got out of this is 3 load cells of sufficient weight handling and a plastic part to hold them correctly, that would probably be worth the $20. Even a fully custom hanging setup would probably be about as expensive, and less convenient. Looking at the controller itself, it uses a FSC-BT647A BLE module from Feasycom, and an STM-8 8-bit microcontroller for the brains. It looks like they’re using a jellybean op amp circuit to average and amplify the 3 load cells electrically, driving a single ADC channel. This would, being analog, be most of the work of replacing the controller entirely.

Step 2: BLE Analysis

So without modification at all, this device does on paper exactly what we need it to. But can we interface it? I started with the same techniques from my article Reverse Engineering Cheap BLE Devices and had a look at the BLE services and characteristics in LightBlue on my iPhone.

screenshot of light blue showing propane scale services

There’s one service with a single characteristic that’s “write” – I’m guessing this is for control or firmware updating. There’s one other service with a single characteristic that’s “read notify” which seems like just what we’re after.

lightblue screenshot shwoing the 0xFFE4 characteristic

If we open this characteristic and click “listen,” sure enough, there’s a 6-byte word that repeatedly updates, changing value with changing weight. I noticed a couple things right off. The full word seems to be divided up into 4 regions:

R1R2 (Weight 1)R3 (Battery)R4 (Weight 2)
AA013A0864FD

Region 1 (AA01) never changes. I’m not sure if it’s a sanity check, or a firmware identifier, or what, but it’s always the same. Similarly, region 3 remains pretty constant. I did notice that, when I run the scale from a bench power supply instead of batteries, this section changes with voltage. As it happens right now, the scale has brand new batteries and 0x64 = 0d100, so this sure appears to be the battery level in decimal percent.

Regions 2 and 4 are more interesting. Not only do these both change with applied weight, they don’t remain constant. In the screenshot above, you can see that they adopt 2 values, despite me not changing the weight on the scale:

R1R2R3R4
AA013A0864FD
AA0147086480

Step 3: Collecting More Data

At this point, I decided to collect a little more data than I could manually screen-scrape, so I put together a python script in similar fashion to last time. Last time I did this, I used pygatt on Windows with a BlueGiga BLE112. This time, my main machine is back to being a Mac and I want the code to eventually run on the Pi itself, so I opted instead to prototype using Adafruit’s BluefruitLE library as the path of least resistance. Of course, being Adafruit, they have some helpful howto documentation, though unfortunately it doesn’t really address this use case directly. Luckily, PunchThrough, makers of the app we used above, have a slightly more pertinent tutorial that does pretty close to what we need. This is the code I ended up with (Github Gist). Side note: While searching for a cross-platform BLE library, I came across Bleak. It has good-looking cross platform support and even better-looking documentation. If I hadn’t come across the PunchThrough tutorial for the Adafruit library, I’d probably have tried Bleak instead. Maybe in the future.

# Adapted from tutorial code at https://punchthrough.com/bluetooth-low-energy-peripheral-testing/

import time
import uuid
import random
import Adafruit_BluefruitLE

DEVICE_NAME = "Gas Monitor"

# Define service and characteristic UUIDs used by the peripheral.
SERVICE_UUID = uuid.UUID('0000FFE0-0000-1000-8000-00805F9B34FB')
#TX_CHAR_UUID = uuid.UUID('00002222-0000-1000-8000-00805F9B34FB')
RX_CHAR_UUID = uuid.UUID('0000FFE4-0000-1000-8000-00805F9B34FB')

# Get the BLE provider for the current platform.
ble = Adafruit_BluefruitLE.get_provider()


def scan_for_peripheral(adapter):
    """Scan for BLE peripheral and return device if found"""
    print('  Searching for device...')
    try:
        adapter.start_scan()
        # Scan for the peripheral (will time out after 60 seconds
        # but you can specify an optional timeout_sec parameter to change it).
        device = ble.find_device(name=DEVICE_NAME)
        if device is None:
            raise RuntimeError('Failed to find device!')
        return device
    finally:
        # Make sure scanning is stopped before exiting.
        adapter.stop_scan()


def sleep_random(min_ms=1, max_ms=1000):
    """Add a random sleep interval between 1ms to 1000ms"""
    duration_sec = random.randrange(min_ms, max_ms)/1000
    print('   Sleeping for ' + str(duration_sec) + 'sec')
    time.sleep(duration_sec)


def main():
    """Main loop to process BLE events"""
    test_iteration = 0
    echo_mismatch_count = 0
    misc_error_count = 0

    # Clear any cached data because both BlueZ and CoreBluetooth have issues with
    # caching data and it going stale.
    ble.clear_cached_data()

    # Get the first available BLE network adapter and make sure it's powered on.
    adapter = ble.get_default_adapter()
    try:
        adapter.power_on()
        print('Using adapter: {0}'.format(adapter.name))

        # This loop contains the main logic for testing the BLE peripheral.
        # We scan and connect to the peripheral, discover services,
        # read/write to characteristics, and keep track of errors.
        # This test repeats 10 times.
        while test_iteration < 10:
            connected_to_peripheral = False

            while not connected_to_peripheral:
                try:
                    peripheral = scan_for_peripheral(adapter)
                    peripheral.connect(timeout_sec=10)
                    connected_to_peripheral = True
                    test_iteration += 1
                    print('-- Test iteration #{} --'.format(test_iteration))
                except BaseException as e:
                    print("Connection failed: " + str(e))
                    time.sleep(1)
                    print("Retrying...")

            try:
                print('  Discovering services and characteristics...')
                peripheral.discover([SERVICE_UUID], [RX_CHAR_UUID])

                # Find the service and its characteristics
                service = peripheral.find_service(SERVICE_UUID)
                #tx = service.find_characteristic(TX_CHAR_UUID)
                rx = service.find_characteristic(RX_CHAR_UUID)

                # Randomize the intervals between different operations
                # to simulate user-triggered BLE actions.
                # sleep_random(1, 1000)

                # Write random value to characteristic.
                # write_val = bytearray([random.randint(1, 255)])
                # print('  Writing ' + str(write_val) + ' to the write char')
                # tx.write_value(write_val)

                # sleep_random(1, 1000)
                time.sleep(1)
                
                # Function to receive RX characteristic changes.  Note that this will
                # be called on a different thread so be careful to make sure state that
                # the function changes is thread safe.  Use queue or other thread-safe
                # primitives to send data to other threads.
                def received(data):
                    print('Received: {0}'.format(data.hex()))
                    firstSection = int(data.hex()[0:4],16)
                    secondSection = int(data.hex()[4:8],16)
                    thirdSection = int(data.hex()[8:10],16)
                    fourthSection = int(data.hex()[10:],16)
                    print('{0} {1} {2} {3}'.format(firstSection, secondSection, thirdSection, fourthSection))
                    
                rx.start_notify(received)

                #read_val = rx.read_value()
                #print('  Read ' + str(read_val) + ' from the read char')
                # if write_val != read_val:
                #     echo_mismatch_count = echo_mismatch_count + 1
                #     print('  Read value does not match value written')

                time.sleep(60)
                peripheral.disconnect()
                # sleep_random(1, 1000)
            except BaseException as e:
                misc_error_count = misc_error_count + 1
                print('Unexpected error: ' + str(e))
                print('Current error count: ' + str(misc_error_count))
                time.sleep(1)
                print('Retrying...')

    finally:
        # Disconnect device on exit.
        peripheral.disconnect
        print('\nConnection count: ' + str(test_iteration))
        print('Echo mismatch count: ' + str(echo_mismatch_count))
        print('Misc error count: ' + str(misc_error_count))
 
 
# Initialize the BLE system.  MUST be called before other BLE calls!
ble.initialize()

# Start the mainloop to process BLE events, and run the provided function in
# a background thread.  When the provided main function stops running, returns
# an integer status code, or throws an error the program will exit.
ble.run_mainloop_with(main)

This code finds the scale and subscribes to notifications on the “weight” characteristic we identified earlier, then prints out the data as it’s received.

Step 4: Decoding the Data

I loaded up the scale with a few different test weights and collected the data below, noting that every loaded weight produced two output words that seemed to alternate at random, while 0 weight only produced the one word at the top of the table. The hex has been transliterated to decimal format.

Region 2Region 4Loaded Weight
02070lb
1306124915lb
973323615lb
332877420lb
2995918920lb
1307024235lb
1639812935lb

This actually contradicts my screenshot above showing two different words, though honestly I may have put some weight on the scale to illustrate the data changing – I don’t recall now. It also raises an interesting point: I converted the data above to decimal, but that might hide patterns from view. Looking at the screenshot data above, Region 2 ends in 08 in both words. I wonder if there’s a similar pattern here that I’ve accidentally masked by only printing decimal in the python output? I went back and added hex representation to the table above:

R1 (dec)R1 (Hex)R4 (dec)R4 (Hex)Weight
00000207CF0lb
130613305249F915lb
97332605236EC15lb
332878207744A20lb
299597507189BD20lb
13070330E242F235lb
16398400E1298135lb

Sure enough, the lower byte of R1 stays the same regardless of weight. I originally thought the data was deliberately obfuscated in some way, but maybe I simply got my regions wrong. It’s comforting to see a byte that increases linearly with weight, but that leaves me wondering why R4 and the first byte of R1 also change. Clearly, there’s more data to collect and analyze!

To Be Continued.

16 Comments

  1. tismon
    September 17, 2021

    Nice work so far!
    I’m hoping to eventually hack together a way to get a wired output to use with really any home automation system. My plan is to get a water leak sensor from Yolink and tie it into the light contacts on this system.
    https://smile.amazon.com/AP-Products-1212-13-024-1000-Monitor/dp/B01C5RQI74

    We’ll see if I ever get around to thi.

  2. althost2
    January 19, 2022

    Thanks for doing this! Helped me get started and figure out how the propane scale sends data. I think the scale sends six bytes of data, byte 3 and 4 are the scale data. Byte 4 is the MSB for the two bytes of the scale reading. Looked at the values in binary was easier to sort out the pattern than in hex or decimal. Also, used the bleak library as you suggested.

    Code is here:
    https://github.com/althost2/SignalK/blob/main/PropaneLevelSensor/PropaneScaleBleak.py

  3. Erik
    July 6, 2023

    Thanks for sharing this great project! Did you ever figure out how to interpret the readings? I have messed around with home built propane scales based on load cells and HX711s but they all end up too fragile to work in practice. I want to get rid of the soldering and also get a neat case that can withstand outdoor weather. This off the shelf scale combined with ESPHome BLE and HA would tick all the boxes.

  4. July 8, 2023

    Unfortunately I haven’t really returned to the project, so no. It looks like AltHost2 above might have, though! I may have to try their code and see if I can integrate it into HomeAssistant 🙂

  5. Erik
    August 4, 2023

    Did you give ALTHOST2’s code a try? I would like to do so myself but the scale is not available in Europe to a price low enough for me that it is worth taking the chance . This scale would be such a great addition to my off-grid smart cabin!

  6. August 9, 2023

    @Erik I haven’t, but I’m intrigued now. I’ll add it to my to-do list and get back to you!

  7. October 4, 2023

    @erik I just did, and it works a treat! Testing it from macOS, I had to swap the MAC address out for a UUID, but you’ll have to use some scanning mode (with bleak or whatever else) to find your MAC address anyway, the way that code is written.

    It definitely works though, and I’ll probably be incorporating it into my hot tub controller for monitoring next time I dive into revisions of that!

  8. Erik
    October 8, 2023

    Nice! I will try to get hold of one of those scales asap. Big thanks!

  9. Steve
    July 17, 2024

    It looks like you need to reverse the two bytes in R1, so 40 0E becomes 0E 40…
    Maybe R4 is some kind of CRC / Checksum

  10. Erik
    December 16, 2024

    Took some time but finally ordered a scale and set up an ESP32 device in ESPHome. Struggled for days with getting the values picked up until i realized I had misread the service UUID in Nrf Connect (I had used FFE4 instead of FFE0 !) With that out of the way, I could just follow your great instructions above for reversing the byte order in a lambda in ESPHome. “float scale_raw = (unit16_t)(x[3] << 8 + x[2});"

    Btw, does anyone you you know what the small push button next to the battery compartment does? Is it a tare or lbs/kg thing?

    Many thanks

  11. December 17, 2024

    Hey very neat! Do you have more info about what you set up? It sounds like you used ESPHome to make a dedicated device that monitors the scale via bluetooth? Why that vs implementing it in home assistant directly?

  12. Erik
    December 17, 2024

    You mean like a new integration in Home assistant? Simple as I do not have any experience from building integrations. In ESPHome I could easily convert the raw scale readings into kilograms using an ESPHome “calibrate linear” function and in addition create the sensors I need for my setup. I use different sized propane tanks in the off-grid cabin, and with a simple input_select (also created in ESPHome), I now just pick the bottle type I need in Home assistant from a drop down and another sensor in ESPHome subtracts the empty bottle weight an publishes 20-40-60-80 pct full data to HA. Not rocket science but a fairly easy way to solve my usecase 😉

  13. December 17, 2024

    Could you share your config files? I’d love to use them! Maybe a GitHub gist or something?

  14. Erik
    December 17, 2024

    I’d be happy to but must admit I am a complete noob when it comes to coding, git etc.. I know it is not common practice to share code directly in a thread here is a copy of the relevant lines in my ESPHome yaml. Pls feel free to remove the post if you think I am making a mess
    esp32_ble_tracker:

    ble_client:
    – mac_address: DC:0D:30:70:36:3F
    id: smartscale

    globals:
    – id: empty_weight_kg
    type: std::map
    initial_value:
    ‘{
    {“PC5”, 3.8},
    {“PC10”, 5.4},
    {“P11”, 12}
    }’

    – id: popane_full_kg
    type: std::map
    initial_value:
    ‘{
    {“PC5”, 5},
    {“PC10”, 10},
    {“P11”, 11}
    }’

    select:
    – platform: template
    id: bottle_type
    name: “Bottle type”
    optimistic: true
    # most common bottle types in my country
    options:
    – PC5
    – PC10
    initial_option: PC10
    restore_value: true

    sensor:
    # PROPANE SCALE 1
    – platform: ble_client
    type: characteristic
    ble_client_id: smartscale
    id: smartscale_raw
    name: “${friendly_name} Raw”
    notify: true
    internal: true
    update_interval: 10s
    service_uuid: “FFE0″ #”0000ffe1-0000-1000-8000-00805f9b34fb”
    characteristic_uuid: “FFE4″ #”0000ffe4-0000-1000-8000-00805f9b34fb”

    lambda: |-
    float scale_raw = ( (uint16_t)(x[3]< 0
    – 1600 -> 6.0
    – lambda: |-
    if (x <= 0.1) {
    return 0.0;
    } else {
    return x;
    }
    update_interval: never
    # upon a scale reading, aslo update the the sensor showing bottle pct left in tank
    on_value:
    then:
    – lambda: |-
    float empty_w = id(empty_weight_kg)[ id(bottle_type).state ];
    float full_w = id(popane_full_kg)[ id(bottle_type).state ];
    float pct = round( ( id(smartscale_kg).state – empty_w) / full_w * 10) /10 * 100;
    id(propane_pct).publish_state(pct);

    # Bottle pct left
    – platform: template
    id: propane_pct
    name: "${friendly_name} pct"
    icon: mdi:propane-tank-outline
    internal: False
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: never
    # applying a median filter to avoid pct values jumping around (needed in my case)
    filters:
    – median:

  15. December 17, 2024

    Here is much better than nowhere! I’m excited to try this myself – I didn’t even know ESPHome could be a BLE client like that!

  16. Erik
    December 17, 2024

    A swiss army knife! I just finalized another project for the cabin using ESPHome where I created a 433 MHz remote with very few lines of code. The remote lets me start my Champion generator directly from home assistant but also has an input switch which I hooked up to the relays of my Victron PV system. So in the event of that internet goes down and the battery is running low, the PV system itself will trigger the remote and start the generator. Quality of life

Leave a Reply

Your email address will not be published. Required fields are marked *