BTHome v2 Parser/Serializer for Elixir

View Source

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.

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

TypeUnitDescriptionRange
:temperature°CTemperature-327.68 to 327.67 °C
:humidity%Relative humidity0 to 655.35%
:pressurehPaAtmospheric pressure0 to 167772.15 hPa
:illuminanceluxLight level0 to 167772.15 lux
:battery%Battery level0 to 255%
:energykWhEnergy consumption0 to 16777.215 kWh
:powerWPower consumption0 to 167772.15 W
:voltageVVoltage0 to 65.535 V
:pm2_5µg/m³PM2.5 particles0 to 65535 µg/m³
:pm10µg/m³PM10 particles0 to 65535 µg/m³
:co2ppmCO2 concentration0 to 65535 ppm
:tvocµg/m³Total VOC0 to 65535 µg/m³

Binary Sensors

TypeDescription
:motionMotion detection
:doorDoor state (open/closed)
:windowWindow state (open/closed)
:occupancyRoom occupancy
:presencePresence detection
:smokeSmoke detection
:gas_detectedGas detection
:carbon_monoxideCO detection
:battery_lowLow battery warning
:battery_chargingCharging status
:connectivityConnection status
:problemProblem/fault status
:safetySafety status
:tamperTamper detection
:vibrationVibration 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

  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

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/bthome.