BTHome v2 Parser/Serializer for Elixir
View SourceA comprehensive, type-safe implementation of the BTHome v2 protocol for Elixir. This library provides serialization and deserialization of sensor data according to the BTHome v2 specification.
Features
- 🔒 Type Safety - Uses structs for measurements and decoded data
- ✅ Validation - Comprehensive validation of measurement types and values
- 🚨 Error Handling - Structured errors with context information
- ⚡ Performance - Compile-time optimizations for fast lookups
- 🔄 Compatibility - Supports both struct and map-based APIs
- 📊 Complete Coverage - Supports all BTHome v2 sensor types
- 🛡️ Error Recovery - Graceful handling of unknown object IDs
Installation
Add bthome
to your list of dependencies in mix.exs
:
def deps do
[
{:bthome, "~> 0.1.0"}
]
end
Quick Start
# Create measurements using the builder pattern (recommended)
binary = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:motion, true)
|> BTHome.serialize!()
# => <<64, 2, 41, 9, 33, 1>>
# Serialize to binary format
{:ok, binary} = BTHome.serialize([temp, motion])
# => {:ok, <<64, 2, 41, 9, 33, 1>>}
# Deserialize back to structs
{:ok, decoded} = BTHome.deserialize(binary)
# => {:ok, %BTHome.DecodedData{
# version: 2,
# encrypted: false,
# trigger_based: false,
# measurements: [
# %BTHome.Measurement{type: :temperature, value: 23.45, unit: "°C"},
# %BTHome.Measurement{type: :motion, value: true, unit: nil}
# ]
# }}
# Deserialize back (recommended)
measurements = BTHome.deserialize_measurements!(binary)
# => %{temperature: 23.45, motion: true}
# Direct access to values
temp = measurements.temperature # 23.45
motion = measurements.motion # true
Supported Sensor Types
Environmental Sensors
Type | Unit | Description | Range |
---|---|---|---|
:temperature | °C | Temperature | -327.68 to 327.67 °C |
:humidity | % | Relative humidity | 0 to 655.35% |
:pressure | hPa | Atmospheric pressure | 0 to 167772.15 hPa |
:illuminance | lux | Light level | 0 to 167772.15 lux |
:battery | % | Battery level | 0 to 255% |
:energy | kWh | Energy consumption | 0 to 16777.215 kWh |
:power | W | Power consumption | 0 to 167772.15 W |
:voltage | V | Voltage | 0 to 65.535 V |
:pm2_5 | µg/m³ | PM2.5 particles | 0 to 65535 µg/m³ |
:pm10 | µg/m³ | PM10 particles | 0 to 65535 µg/m³ |
:co2 | ppm | CO2 concentration | 0 to 65535 ppm |
:tvoc | µg/m³ | Total VOC | 0 to 65535 µg/m³ |
Binary Sensors
Type | Description |
---|---|
:motion | Motion detection |
:door | Door state (open/closed) |
:window | Window state (open/closed) |
:occupancy | Room occupancy |
:presence | Presence detection |
:smoke | Smoke detection |
:gas_detected | Gas detection |
:carbon_monoxide | CO detection |
:battery_low | Low battery warning |
:battery_charging | Charging status |
:connectivity | Connection status |
:problem | Problem/fault status |
:safety | Safety status |
:tamper | Tamper detection |
:vibration | Vibration detection |
API Documentation
Creating Measurements
# Recommended: Using the measurement function with validation
{:ok, temp} = BTHome.measurement(:temperature, 23.45)
{:ok, motion} = BTHome.measurement(:motion, true)
# With custom unit override
{:ok, temp_f} = BTHome.measurement(:temperature, 74.21, unit: "°F")
# Direct struct creation (advanced)
%BTHome.Measurement{
type: :temperature,
value: 23.45,
unit: "°C"
}
Serialization
# Basic serialization
measurements = [temp, motion]
{:ok, binary} = BTHome.serialize(measurements)
# With encryption flag
{:ok, encrypted_binary} = BTHome.serialize(measurements, true)
# Legacy map format (still supported)
legacy_measurements = [
%{type: :temperature, value: 23.45},
%{type: :humidity, value: 67.8}
]
{:ok, binary} = BTHome.serialize(legacy_measurements)
Builder Pattern API
For a more fluent, pipeable approach to creating BTHome packets:
# Create and serialize in a single pipeline
{:ok, binary} = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:motion, true)
|> BTHome.add_measurement(:humidity, 67.8)
|> BTHome.serialize()
# With encryption
{:ok, encrypted_binary} = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:battery, 85)
|> BTHome.serialize(true)
# Error handling with builder pattern
result = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:invalid_type, 42) # This will cause an error
|> BTHome.serialize()
case result do
{:ok, binary} -> process_binary(binary)
{:error, error} -> handle_error(error)
end
# Custom measurement options
{:ok, binary} = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 74.21, unit: "°F")
|> BTHome.add_measurement(:voltage, 3.3, object_id: 0x0C)
|> BTHome.serialize()
The builder pattern provides several advantages:
- Fluent API: Chain operations naturally with the pipe operator
- Error Accumulation: Invalid measurements are stored but don't break the chain
- Validation: Each measurement is validated when added
- Flexibility: Mix with existing APIs or use standalone
Deserialization
Recommended: Use deserialize_measurements!/1
for the most ergonomic API:
# Simple, direct access to measurements
binary = <<64, 2, 202, 9, 3, 191, 19>>
measurements = BTHome.deserialize_measurements!(binary)
# Direct access to values
temp = measurements.temperature # 25.06
humidity = measurements.humidity # 50.23
# Handle multiple measurements of the same type
measurements.voltage # [3.1, 2.8] - list for multiple values
# Binary sensors
measurements.motion # true/false
Alternative: Use the tuple-returning version for explicit error handling:
case BTHome.deserialize_measurements(binary) do
{:ok, measurements} ->
IO.puts("Temperature: #{measurements.temperature}°C")
{:error, reason} ->
IO.puts("Failed to decode: #{reason}")
end
Low-level: Access the full decoded structure:
{:ok, decoded} = BTHome.deserialize(binary)
# Access metadata
decoded.version # => 2
decoded.encrypted # => false
decoded.trigger_based # => false
# Access measurements as structs
[temp_measurement, humidity_measurement] = decoded.measurements
temp_measurement.value # 25.06
temp_measurement.unit # "°C"
Convenience Functions
deserialize_measurements!/1
(Recommended)
The most ergonomic way to access measurement data. Returns measurements as a map and raises on error:
# Simple, direct access - no pattern matching needed
binary = <<64, 2, 202, 9, 3, 191, 19>>
measurements = BTHome.deserialize_measurements!(binary)
# Direct access to values
temp = measurements.temperature # 25.06
humidity = measurements.humidity # 50.23
# Handle multiple measurements of the same type
binary_with_multiple_temps = <<64, 2, 202, 9, 18, 2, 100, 5>>
measurements = BTHome.deserialize_measurements!(binary_with_multiple_temps)
measurements.temperature # [25.06, 13.0] - returns list for multiple values
# Binary sensors
binary_sensor = <<64, 15, 1>>
measurements = BTHome.deserialize_measurements!(binary_sensor)
measurements.generic_boolean # true
deserialize_measurements/1
For explicit error handling, use the tuple-returning version:
case BTHome.deserialize_measurements(binary) do
{:ok, measurements} ->
# Process measurements
IO.puts("Temperature: #{measurements.temperature}°C")
{:error, %BTHome.Error{message: message}} ->
IO.puts("Decoding failed: #{message}")
end
Validation
# Validate individual measurement
case BTHome.validate_measurement(measurement) do
:ok -> IO.puts("Valid measurement")
{:error, error} -> IO.puts("Invalid: #{error.message}")
end
# Validate list of measurements
case BTHome.validate_measurements(measurements) do
:ok -> BTHome.serialize(measurements)
{:error, error} -> handle_validation_error(error)
end
Error Handling
All functions return structured errors with context:
{:error, %BTHome.Error{
type: :validation, # :validation, :encoding, or :decoding
message: "Unsupported measurement type: :invalid",
context: %{type: :invalid} # Additional debugging info
}}
Advanced Usage
Custom Object IDs
# Override default object ID (advanced use case)
{:ok, measurement} = BTHome.measurement(:temperature, 23.45, object_id: 0x02)
Backwards Compatibility
The library maintains backwards compatibility with the legacy API:
BTHome.serialize(measurements)
BTHome.deserialize(binary)
BTHome.measurement(:temperature, 23.45)
Performance Considerations
The library uses compile-time optimizations for maximum performance:
- O(1) type lookups using compile-time maps
- Set-based binary sensor detection
- Minimal runtime overhead for validation
Error Recovery
The decoder includes error recovery for unknown object IDs:
# If binary contains unknown object IDs, they are skipped
# and parsing continues with known measurements
{:ok, decoded} = BTHome.deserialize(binary_with_unknown_data)
# Successfully returns known measurements, skips unknown ones
Examples
IoT Sensor Data
# Traditional approach
measurements = [
%{type: :temperature, value: 22.5},
%{type: :humidity, value: 45.0},
%{type: :battery, value: 85}
]
{:ok, binary} = BTHome.serialize(measurements)
data = BTHome.deserialize_measurements!(binary)
# => %{temperature: 22.5, humidity: 45.0, battery: 85}
# IoT Sensor Data - Builder pattern approach
binary = BTHome.Packet.new()
|> BTHome.Packet.add_measurement(:temperature, 22.5)
|> BTHome.Packet.add_measurement(:humidity, 45.0)
|> BTHome.Packet.add_measurement(:battery, 85)
|> BTHome.Packet.serialize!()
data = BTHome.deserialize_measurements!(binary)
# => %{temperature: 22.5, humidity: 45.0, battery: 85}
Home Automation
# Traditional approach
measurements = [
%{type: :motion, value: true},
%{type: :door, value: false},
%{type: :temperature, value: 21.0}
]
{:ok, binary} = BTHome.serialize(measurements)
data = BTHome.deserialize_measurements!(binary)
# => %{motion: true, door: false, temperature: 21.0}
# Home Automation - Builder pattern approach
binary = BTHome.Packet.new()
|> BTHome.Packet.add_measurement(:motion, true)
|> BTHome.Packet.add_measurement(:door, false)
|> BTHome.Packet.add_measurement(:temperature, 21.0)
|> BTHome.Packet.serialize!()
data = BTHome.deserialize_measurements!(binary)
# => %{motion: true, door: false, temperature: 21.0}
Environmental Monitoring
# Environmental Monitoring - Traditional approach
measurements = [
%{type: :temperature, value: 23.1},
%{type: :humidity, value: 58.3},
%{type: :pressure, value: 1013.25},
%{type: :pm2_5, value: 12},
%{type: :pm10, value: 18},
%{type: :co2, value: 420}
]
{:ok, binary} = BTHome.serialize(measurements)
data = BTHome.deserialize_measurements!(binary)
# => %{temperature: 23.1, humidity: 58.3, pressure: 1013.25, pm2_5: 12, pm10: 18, co2: 420}
# Environmental Monitoring - Builder pattern approach
binary = BTHome.Packet.new()
|> BTHome.Packet.add_measurement(:temperature, 23.1)
|> BTHome.Packet.add_measurement(:humidity, 58.3)
|> BTHome.Packet.add_measurement(:pressure, 1013.25)
|> BTHome.Packet.add_measurement(:pm2_5, 12)
|> BTHome.Packet.add_measurement(:pm10, 18)
|> BTHome.Packet.add_measurement(:co2, 420)
|> BTHome.Packet.serialize!()
data = BTHome.deserialize_measurements!(binary)
# => %{temperature: 23.1, humidity: 58.3, pressure: 1013.25, pm2_5: 12, pm10: 18, co2: 420}
Testing
Run the test suite:
mix test
The library includes comprehensive tests covering:
- All sensor types and their ranges
- Serialization/deserialization round trips
- Validation edge cases
- Error recovery scenarios
- Backwards compatibility
Contributing
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
License
This project is licensed under the MIT License - see the LICENSE file for details.
References
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/bthome.