Packages

A comprehensive, type-safe implementation of the BTHome v2 protocol for Elixir. Provides serialization and deserialization of sensor data with full validation, error handling, and support for all BTHome v2 sensor types.

Current section

Files

Jump to
bthome README.md
Raw

README.md

# BTHome v2 Parser/Serializer for Elixir
A 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](https://bthome.io/).
## 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`:
```elixir
def deps do
[
{:bthome, "~> 0.1.0"}
]
end
```
## Quick Start
```elixir
# 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
```elixir
# 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
```elixir
# 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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
{: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:
```elixir
# 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:
```elixir
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
```elixir
# 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:
```elixir
{: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
```elixir
# 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:
```elixir
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:
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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:
```bash
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
1. Fork the repository
2. Create a feature branch
3. Add tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## References
- [BTHome v2 Specification](https://bthome.io/)
- [Bluetooth Low Energy](https://www.bluetooth.com/specifications/bluetooth-core-specification/)
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/bthome>.