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.

Features

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:

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:

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:

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.