Skip to content
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

feat: add support for the IAM-T1 Air Quality Sensor #35

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

hosswald
Copy link

@hosswald hosswald commented Oct 20, 2024

#34
I created an implementation for the IAM-T1 Air Quality sensor including tests. The following sensors are calculated:

  • Temperature (°C)
  • Humidity (%)
  • CO2 (PPM)
  • Pressure (hPa)

I took the byte numbers from https://smarthomescene.com/guides/how-to-integrate-inkbird-iam-t1-air-quality-monitor-in-home-assistant/ and figured out the service UUID with bluetoothctl. Then I wrote the test against a real payload from my device for which I knew the decoded values.

I'm not sure if my implementation works. Is is the first one to use a non-empty service_data and I never implemented anything to do with BLE. Is there any way I can test the implementation with my real IAM-T1, without having to build my own home assistant version? Please advise.

Copy link

codecov bot commented Oct 20, 2024

Codecov Report

Attention: Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.

Project coverage is 96.34%. Comparing base (64d17d7) to head (42a6e7d).
Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/inkbird_ble/parser.py 93.75% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #35       +/-   ##
===========================================
+ Coverage   86.15%   96.34%   +10.18%     
===========================================
  Files           2        2               
  Lines          65       82       +17     
  Branches        9       12        +3     
===========================================
+ Hits           56       79       +23     
+ Misses          5        1        -4     
+ Partials        4        2        -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

temp = int.from_bytes(service_data[5:7], "big") / 10
temp_sign = service_data[4] & 0xF
temp = temp if temp_sign == 0 else -temp
# TODO Celsius vs Fahrenheit?
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
# TODO Celsius vs Fahrenheit?

C to F happens downstream in Home Assistant

Copy link
Author

@hosswald hosswald Oct 20, 2024

Choose a reason for hiding this comment

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

I don't think this is true for this device. I ran gatttool while pressing the °C/°F button on the device multiple times.
$ gatttool -b xx:xx:xx:xx:xx:xx --interactive
[xx:xx:xx:xx:xx:xx ][LE]> connect
Attempting to connect to xx:xx:xx:xx:xx:xx
Connection successful
Notification handle = 0x002c value: 55 aa 01 10 00 00 c4 02 bc 02 89 03 f0 01 00 11
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 01 11
Notification handle = 0x002c value: 55 aa 01 06 10 02 a0 02 bc 02 89 03 f0 01 01 e6
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 00 10
Notification handle = 0x002c value: 55 aa 01 06 00 00 c4 02 bc 02 89 03 f0 01 01 08
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 01 11
Notification handle = 0x002c value: 55 aa 01 06 10 02 a0 02 bc 02 89 03 f0 01 01 e6
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 00 10
Notification handle = 0x002c value: 55 aa 01 06 00 00 c4 02 bc 02 89 03 f0 01 01 08
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 01 11
Notification handle = 0x002c value: 55 aa 01 06 10 02 a0 02 bc 02 89 03 f0 01 01 e6
Notification handle = 0x002c value: 55 aa 05 0c 00 00 00 00 00 00 00 10

00c4 (base 16) == 196 (base 10) == 19,6°C
02a0 (base 16) == 672 (base 10) == 67,2°F

The second last byte (00 vs 01) in that intermediate notification seems to indicate the unit.

I think this needs to be considered and converted here in case the device sends Fahrenheit.

Choose a reason for hiding this comment

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

I also agree that the upper nibble of that byte indicates whether the returned value is in Fahrenheit or in Celsius. As far as I found out to ensure compatibility with HA this should be converted to Celsius in case the Fahrenheit value has been received. Later on HA will then convert it back to Fahrenheit if a user wants to display it as such.

tests/test_parser.py Outdated Show resolved Hide resolved
tests/test_parser.py Outdated Show resolved Hide resolved
@@ -16,6 +16,8 @@
from home_assistant_bluetooth import BluetoothServiceInfo
from sensor_state_data import SensorLibrary

IAMT1_SERVICE_UUID = "0000ffe4-0000-1000-8000-00805f9b34fb"
Copy link
Member

Choose a reason for hiding this comment

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

0000ffe4 will match other devices as well as 0000-1000-8000-00805f9b34fb is the base part of the base UUID https://github.com/Bluetooth-Devices/bluetooth-data-tools/blob/8fdc7cade9c3b87dfdd77cb741d1fd83fbc154ea/src/bluetooth_data_tools/gap.py#L8

Copy link
Author

Choose a reason for hiding this comment

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

I added a match for the device name as well.

@bdraco bdraco changed the title IAM-T1 Air Quality Sensor feat: add support for the IAM-T1 Air Quality Sensor Oct 20, 2024
@bdraco
Copy link
Member

bdraco commented Oct 20, 2024

https://smarthomescene.com/guides/how-to-integrate-inkbird-iam-t1-air-quality-monitor-in-home-assistant/

It looks like this device only support GATT, but this library only supports reading advertising data.

@hosswald
Copy link
Author

https://smarthomescene.com/guides/how-to-integrate-inkbird-iam-t1-air-quality-monitor-in-home-assistant/

It looks like this device only support GATT, but this library only supports reading advertising data.

Is this a show stopper? Other libraries in Bluetooth-Devices seem to parse GATT. Is this something that could be added to this library as well? I don't know if this is useful information, but the device seems to send the notifications to connected devices without them needing to explicitly poll, see #35 (comment)

@bdraco
Copy link
Member

bdraco commented Oct 20, 2024

smarthomescene.com/guides/how-to-integrate-inkbird-iam-t1-air-quality-monitor-in-home-assistant
It looks like this device only support GATT, but this library only supports reading advertising data.

Is this a show stopper? Other libraries in Bluetooth-Devices seem to parse GATT. Is this something that could be added to this library as well? I don't know if this is useful information, but the device seems to send the notifications to connected devices without them needing to explicitly poll, see #35 (comment)

Yes. GATT supported would need to be added to the library first

@bdraco
Copy link
Member

bdraco commented Oct 20, 2024

Example implementation Bluetooth-Devices/xiaomi-ble#11

@theguy147
Copy link

theguy147 commented Jan 1, 2025

Example implementation Bluetooth-Devices/xiaomi-ble#11

Unfortunately, this does not appear to work with the Inkbird IAM-T1. This device only transmits live sensor data when actively connected and when additionally notifications are enabled.

More Details:

Looking at the different methods to fetch Bluetooth data I tried to modify this Inkbird integration to use a ActiveBluetoothProcessorCoordinator instead of the currently used PassiveBluetoothProcessorCoordinator and when implementing a whole bunch of other changes necessary in both this integration and the inkbird-ble repo, then I can add the IAM-T1 to home assistant but the sensor data is only read on first connection or on reloading the integration (or restarting home assistant). The reason for this is that the needs_poll_method of the ActiveBluetoothProcessorCoordinator is only called whenever the BLE advertisement data changes - and this never happens for the IAM-T1, because the actual sensor data is only contained in the notifications itself.

One solution for this that I can think of would be to use a DataUpdateCoordinator instead and connect to the IAM-T1 on a fixed interval, subscribe to the notifications, wait for the first notification to update the sensor data and then unsubscribe/disconnect again. This would be a very different approach to the current integration and therefore might be better as a separate integration.

Another resource intensive approach would be to keep the connection open and to keep notifications enabled all the time and then update the sensor data in HA from within the notification callback. Although this approach would be compatible with an ActiveBluetoothProcessorCoordinator, my intuition is that it would increase energy/battery consumption significantly. Also this would block the connection to the IAM-T1 and therefore the integration would have to be stopped so that one can use the inkbird companion app to connect to the IAM-T1 with a smartphone.

@bdraco
Copy link
Member

bdraco commented Jan 1, 2025

One solution for this that I can think of would be to use a DataUpdateCoordinator instead and connect to the IAM-T1 on a fixed interval, subscribe to the notifications, wait for the first notification to update the sensor data and then unsubscribe/disconnect again. This would be a very different approach to the current integration and therefore might be better as a separate integration.

This is the way.

If your device only communicates with an active Bluetooth connection and does not use Bluetooth advertisements:
https://developers.home-assistant.io/docs/core/bluetooth/bluetooth_fetching_data?_highlight=fetc#choosing-a-method-to-fetch-data

We generally only want one integration to talk Bluetooth to vendor devices, even if that means we need to have split branching in the integration. Generally we would only make completely new integration if it was Bluetooth vs Cloud communication.

@@ -16,6 +16,8 @@
from home_assistant_bluetooth import BluetoothServiceInfo
from sensor_state_data import SensorLibrary

IAMT1_SERVICE_UUID = "0000ffe4-0000-1000-8000-00805f9b34fb"
Copy link

Choose a reason for hiding this comment

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

I don't believe this is the right service. The device I have uses service FFE0 with characteristic FFE4.

Comment on lines +75 to +76
elif IAMT1_SERVICE_UUID in service_info.service_uuids:
self.set_device_name(f"{local_name} {short_address(address)}")
Copy link

Choose a reason for hiding this comment

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

I wouldn't expect this to work. The T1 I have doesn't advertise this service (or any service) so I would expect service_uuids to be empty here. Did this work?

The manufacturer data (0x3154 "AC-6200a135990\0" or maybe they meant "1TAC-..."?) feels like it may be a more reliable identifier (plus the device name?)

(FFE0 is also not an assigned service. It's a service that a lot of devices squat on without registering. So its existence doesn't tell you anything other than the device developer was sloppy, and possibly just following a random tutorial or copied HOPERF modules.)

Copy link

@theguy147 theguy147 Jan 5, 2025

Choose a reason for hiding this comment

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

In my attempts I also use the "T1AC" (endianness) to identify the IAM-T1. But I wouldn't use the number after that as I think that it is the serial number or at least device specific (mine have other digits).

EDIT: I looked at my code again and this is how I currently identify the IAM-T1

def is_iam_t1(service_info: BluetoothServiceInfo) -> bool:
    return 0x3154 in service_info.manufacturer_data.keys()

@rnapier
Copy link

rnapier commented Jan 5, 2025

@hosswald , @theguy147 Are either of you working actively on this? I picked up one of these sensors, and am digging into integrating it. I have extensive background in Bluetooth, but I'm just finding my way around HomeAssistant and trying to work out how to make this change correctly. (That said, while I kind of like the device, its Bluetooth/app protocol is so amateurish that I'm considering replacing it with something else.)

Quoting @theguy147:

Another resource intensive approach would be to keep the connection open and to keep notifications enabled all the time and then update the sensor data in HA from within the notification callback. Although this approach would be compatible with an ActiveBluetoothProcessorCoordinator, my intuition is that it would increase energy/battery consumption significantly. Also this would block the connection to the IAM-T1 and therefore the integration would have to be stopped so that one can use the inkbird companion app to connect to the IAM-T1 with a smartphone.

I don't think this is particularly resource intensive compared to other solutions. Maintaining a BLE connection is generally pretty cheap (much cheaper than scanning for a device), and this sensor only emits notifications 1/min at most (it can be configured to be slower than that). The main issue is that it will break the companion app. I don't currently see any fix for that. The companion app holds a permanent background connection that blocks all other connections. So if the companion app is running in the background, nothing else can get this info. The only way I see this working is for HA to hold the connection and declare that it's incompatible with the companion app. Even if HA were to poll with connect/read/disconnect (to leave the connection available most of the time), if the companion app were ever launched it would break HA until the companion app was force-quit. That's not really workable IMO.

@hosswald
Copy link
Author

hosswald commented Jan 5, 2025

@rnapier I'm not actively working on it, feel free to take over!

@theguy147
Copy link

@rnapier I am working on it but not very actively, only a couple of minutes here and there... So feel free to take this over if you want.

My implementation currently works with the IAM-T1 but as you explained well it blocks other connections such as the companion app and I am not happy about it. Because it is either the companion app OR the home assistant integration I was thinking about also implementing the other stuff you can do with the app, e.g., setting the notification interval. The mechanism is quite easy, actually. In the "ffe0" service the "ffe4" characteristic is for reading (notifications only) and the "ffe9" characteristic is for writing. If you write certain commands to "ffe9" you will get replies at "ffe4", e.g., the historical sensor data. Let me know if you are interested in all that and I can give you my notes with the discovered "secret" commands and the response formats. (Probably it doesnt make sense to implement fetching historical data for home assistant but it would make sense to have e.g. a setting to change the sensor update interval and maybe some of the other features like calibration).

@rnapier
Copy link

rnapier commented Jan 5, 2025

@theguy147 Thanks. I'd be happy to expand on your code if you push up a fork. The companion app really chews on my phone's battery, so I'm not really happy with having it installed and would rather integrate it into HA. I want to use its output to control my air exchanger anyway. Given the caveats on this device, I'm not sure if it should be considered a "core" integration. (I'm still learning my way around how HA is structured; what's the right approach here @bdraco?)

@theguy147
Copy link

@theguy147 Thanks. I'd be happy to expand on your code if you push up a fork. The companion app really chews on my phone's battery, so I'm not really happy with having it installed and would rather integrate it into HA. I want to use its output to control my air exchanger anyway. Given the caveats on this device, I'm not sure if it should be considered a "core" integration. (I'm still learning my way around how HA is structured; what's the right approach here @bdraco?)

My code currently just lives in my home assistant instance. In the next days I will try to find some time to put it into a fork and maybe clean one or two things up beforehand (I put a lot of bad style debugging code in there as I am also new to home assistant and first had to understand how everything works together - Obviously I did this in the worst way possible ;)


Here are some of my notes about the service and its characteristics:

service: 0000ffe0-0000-1000-8000-00805f9b34fb
writeCharacteristic: 0000ffe9-0000-1000-8000-00805f9b34fb
notifyCharacteristic: 0000ffe4-0000-1000-8000-00805f9b34fb

As has been established already to get readings from the IAM-T1 one needs to subscribe to the notifyCharacteristic and at the predefined intervals one then gets the live sensor data as a byte array:

This is the schema, where each hex byte is separated by a dash "-":

55-AA-isTemperatureNegative-Temperature-Temperature-Humidity-Humidity-Co2-Co2-Pressure-Pressure

And here is an example using construct to parse the values in variable data quickly:

from construct import Struct, Byte, Int16ub, Computed

IAM_T1_Sensors = Struct(
    "magic" / Byte[4],  # 4-byte magic value: 55-AA
    "ng" / Byte,  # Sign for temperature
    "temperature_raw" / Int16ub,  # Temperature raw value (TempHi << 8 | TempLo)
    "humidity_raw" / Int16ub,  # Humidity raw value (HumHi << 8 | HumLo)
    "co2" / Int16ub,  # CO2 raw value (Co2Hi << 8 | Co2Lo)
    "pressure" / Int16ub,  # Air pressure raw value (PresHi << 8 | PresLo)
    # Computed fields
    "temperature"
    / Computed(lambda ctx: (-1 if ctx.ng == 1 else 1) * ctx.temperature_raw / 10),
    "humidity" / Computed(lambda ctx: ctx.humidity_raw / 10),
)

# ...

sensors = IAM_T1_Sensors.parse(data)

print(f"Temperature: {sensors.temperature}")
print(f"Humidity: {sensors.humidity}")
print(f"Co2: {sensors.co2}")
print(f"Pressure: {sensors.pressure}")

Now the modification of Co2 settings by writing to the writeCharacteristic follows a similar pattern:

55-AA-05-0C-Mode-isCustom-isAutomatic-manualMode-manualCalibration-manualCalibration-endNum

where the Mode defines the refresh time interval like this:

- 0x00: refresh every 1 minute
- 0x01: refresh every 2 minutes
- 0x02: refresh every 5 minutes
- 0x04: refresh every 10 minutes

An example would be the following byte sequence:

55-AA-05-0C-01-00-00-00-00-00-11

By the way, the endNum is just something like a "sum hash" used similarly to a checksum:

def getEndNum(s: str) -> str:
    # sum up all bytes, then AND with 0xff and append this byte to the end of the string = "sum hash"
    return s + hex(sum(bytes.fromhex(s)) & 0xFF)[2:]

Generally, it appears as if most writes to writeCharacteristic are subsequently reflected to the notifyCharacteristic at least everything I tested apart from the fetching of historical sensor data...

I hope this helps. If anyone is interested I can also post my findings on the found commands and schemas for:

  • setting the calibration preferences
  • setting the co2 alarm preferences
  • setting the co2 mode preferences
  • fetching and parsing historical sensor data (although here there is still a small uncertainty how to parse the correct time as my calculations currently use the time when receving the first packet for this data and then use that time and the timestamp of the first datapoint to then calculate the other timestamps and i am not quite sure if that is the right way to go about it. But I guess for a home assistant integration there is no need for historical data as HA records them directly)

@rnapier
Copy link

rnapier commented Jan 11, 2025

I've switched to using an ESPHome Bluetooth Proxy for this, and it works quite well. Rather than creating a custom integration for this, is there a nice way to get the flexibility of ESPHome "built-in?" Is there a nice way to just configure it in YAML?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants