BTHome (BTHome v0.1.0)

View Source

BTHome v2 main API module.

This module provides the primary interface for working with BTHome v2 sensor data. It handles serialization to/from binary format, validation, and measurement creation with full type safety and error handling.

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: Struct-based input with map output for deserialization

Supported Sensor Types

Environmental Sensors

  • :temperature - Temperature in °C
  • :humidity - Relative humidity in %
  • :pressure - Atmospheric pressure in hPa
  • :illuminance - Light level in lux
  • :battery - Battery level in %
  • :energy - Energy consumption in kWh
  • :power - Power consumption in W
  • :voltage - Voltage in V

Binary Sensors

  • :motion - Motion detection (true/false)
  • :door - Door state (open/closed)
  • :window - Window state (open/closed)
  • :occupancy - Room occupancy (occupied/vacant)
  • :smoke - Smoke detection (detected/clear)
  • :battery_low - Low battery warning (low/ok)

Basic Usage

# Create measurements using the struct API
{:ok, temp} = BTHome.measurement(:temperature, 23.45)
{:ok, motion} = BTHome.measurement(:motion, true)

# Serialize to binary
{:ok, binary} = BTHome.serialize([temp, motion])

# Deserialize back to structs
{:ok, decoded} = BTHome.deserialize(binary)

Deserialization Output

# Deserialization returns convenient map format
{:ok, binary} = BTHome.serialize([temp, motion])
{:ok, %{measurements: measurements}} = BTHome.deserialize(binary)
# measurements is a list of maps for easy access

Validation

# Validate before serialization
case BTHome.validate_measurements(measurements) do
  :ok -> BTHome.serialize(measurements)
  {:error, reason} -> handle_error(reason)
end

Error Handling

All functions return tagged tuples with structured errors:

{:error, %BTHome.Error{
  type: :invalid_data,
  message: "Unsupported measurement type: :invalid",
  context: %{type: :invalid}
}}

Summary

Functions

Adds a measurement to a packet builder.

Deserializes BTHome v2 binary data into structured measurement data.

Deserializes encrypted BTHome v2 binary data.

Deserializes BTHome v2 binary data and returns measurements as a map.

Deserializes BTHome v2 binary data and returns measurements as a map, raising on error.

Creates encryption options from a device context map.

Creates encryption options for use with serialize/2.

Generates a random encryption key for BTHome v2.

Converts a hex string to an encryption key.

Converts an encryption key to a hex string.

Creates a validated measurement struct.

Creates a new encrypted packet builder.

Creates a new packet builder for the fluent/builder pattern API.

Quick serialization of measurements with encryption.

Serializes measurements into BTHome v2 binary format.

Serializes measurements or packet, raising on error.

Serializes an encrypted packet to binary format.

Serializes measurements with encryption.

Serializes measurements for a specific device.

Returns all supported measurement types and their properties.

Validates a single measurement for serialization.

Validates a list of measurements for serialization.

Functions

add_measurement(packet, type, value, opts \\ [])

@spec add_measurement(BTHome.Packet.t(), atom(), number() | boolean(), keyword()) ::
  BTHome.Packet.t()

Adds a measurement to a packet builder.

This function is designed to work with the builder pattern, allowing you to chain multiple measurements using the pipe operator. If any measurement fails validation, the error is captured and will be returned when serialize/1 is called.

Parameters

Returns

The updated packet builder struct.

Examples

# Single measurement
packet = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)

# Multiple measurements
packet = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:humidity, 67.8)
|> BTHome.add_measurement(:motion, true)

# With custom options
packet = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 74.21, unit: "°F")

deserialize(binary, opts \\ [])

@spec deserialize(
  binary(),
  keyword()
) :: {:ok, BTHome.DecodedData.t()} | {:error, String.t()}

Deserializes BTHome v2 binary data into structured measurement data.

Parses BTHome v2 binary format and returns a DecodedData struct containing protocol metadata and a list of Measurement structs. Handles error recovery for unknown object IDs by skipping invalid data.

For encrypted data, provide decryption options to automatically decrypt measurements.

Parameters

  • binary - Binary data containing BTHome v2 encoded measurements
  • opts - Options for decryption (optional)
    • :key - 16-byte encryption key
    • :mac_address - 6-byte MAC address

Returns

  • {:ok, decoded_data} - Successfully parsed data with measurements
  • {:error, error} - Parsing failed with error details

Examples

# Basic deserialization
iex> {:ok, result} = BTHome.deserialize(<<64, 2, 41, 9, 3, 124, 26>>)
iex> result.version
2
iex> result.encrypted
false
iex> length(result.measurements)
2

# For encrypted data, provide key and MAC address
# opts = [key: <<key_bytes>>, mac_address: <<mac_bytes>>]
# BTHome.deserialize(encrypted_binary, opts)

# With encryption flag
iex> BTHome.deserialize(<<65, 2, 41, 9>>)
{:ok, %BTHome.DecodedData{
  version: 2,
  encrypted: true,
  trigger_based: false,
  measurements: [],
  ciphertext: <<2, 41, 9>>
}}

# Invalid data
iex> BTHome.deserialize(<<1, 2, 3>>)
{:error, "Unsupported BTHome version: 0"}

deserialize_encrypted(binary, key, mac_address)

@spec deserialize_encrypted(binary(), binary(), binary()) ::
  {:ok, BTHome.DecodedData.t()} | {:error, String.t()}

Deserializes encrypted BTHome v2 binary data.

This is a convenience function for decrypting BTHome v2 data with the specified decryption parameters.

Parameters

  • binary - Encrypted BTHome v2 binary data
  • key - 16-byte encryption key
  • mac_address - 6-byte MAC address

Returns

  • {:ok, decoded_data} - Successfully decrypted data with measurements
  • {:error, error} - Decryption failed with error details

Examples

key = encryption_key
mac = device_mac_address

{:ok, decoded} = BTHome.deserialize_encrypted(encrypted_binary, key, mac)

deserialize_measurements(binary, opts \\ [])

@spec deserialize_measurements(
  binary(),
  keyword()
) :: {:ok, map()} | {:error, String.t()}

Deserializes BTHome v2 binary data and returns measurements as a map.

This is a convenience function that extracts measurements from the decoded data and returns them as a map where keys are measurement types and values are the measured values. For measurements with multiple instances (same type, different object IDs), the map will contain a list of values.

Parameters

  • binary - The BTHome v2 binary data to deserialize

Returns

  • {:ok, map} - A map of measurement types to values
  • {:error, Error.t()} - If deserialization fails

Examples

# Binary sensor measurement
iex> binary = <<0x40, 0x0F, 0x01>>
iex> BTHome.deserialize_measurements(binary)
{:ok, %{generic_boolean: true}}

# Temperature and humidity measurements
iex> binary = <<0x40, 0x02, 0xCA, 0x09, 0x03, 0xBF, 0x13>>
iex> {:ok, measurements} = BTHome.deserialize_measurements(binary)
iex> Map.has_key?(measurements, :temperature) and Map.has_key?(measurements, :humidity)
true

# Encrypted data (no measurements available)
iex> binary = <<0x41, 0x02, 0x29, 0x09>>
iex> BTHome.deserialize_measurements(binary)
{:ok, %{}}

deserialize_measurements!(binary)

@spec deserialize_measurements!(binary()) :: map() | no_return()

Deserializes BTHome v2 binary data and returns measurements as a map, raising on error.

This is the recommended function for most use cases as it provides the most ergonomic API for accessing measurement data. It returns measurements as a map where keys are measurement types and values are the measured values.

Parameters

  • binary - The BTHome v2 binary data to deserialize

Returns

A map of measurement types to values. Raises ArgumentError on failure.

Examples

# Binary sensor measurement
iex> binary = <<0x40, 0x0F, 0x01>>
iex> BTHome.deserialize_measurements!(binary)
%{generic_boolean: true}

# Temperature and humidity measurements
iex> binary = <<0x40, 0x02, 0xCA, 0x09, 0x03, 0xBF, 0x13>>
iex> measurements = BTHome.deserialize_measurements!(binary)
iex> Map.has_key?(measurements, :temperature) and Map.has_key?(measurements, :humidity)
true

# Invalid data raises an error
iex> BTHome.deserialize_measurements!(<<1, 2, 3>>)
** (ArgumentError) Unsupported BTHome version: 0

encryption_opts(device_context)

@spec encryption_opts(map()) :: {:ok, keyword()} | {:error, String.t()}

Creates encryption options from a device context map.

This convenience function extracts encryption parameters from a device context map, making it easier to work with device management systems.

Parameters

  • device_context - Map containing device information with keys:
    • :key or :encryption_key - 16-byte encryption key
    • :mac_address or :mac - 6-byte MAC address
    • :counter - 4-byte counter value

Returns

  • {:ok, opts} - Successfully created encryption options
  • {:error, error} - Missing or invalid parameters

Examples

device = %{
  encryption_key: key,
  mac_address: mac,
  counter: 1
}

{:ok, opts} = BTHome.encryption_opts(device)
{:ok, binary} = BTHome.serialize(measurements, opts)

encryption_opts(key, mac_address, counter)

@spec encryption_opts(binary(), binary(), non_neg_integer()) :: keyword()

Creates encryption options for use with serialize/2.

This convenience function creates properly formatted encryption options from individual parameters, reducing verbosity and potential errors.

Parameters

  • key - 16-byte encryption key
  • mac_address - 6-byte MAC address
  • counter - 4-byte counter value

Returns

Keyword list with encryption options ready for use with serialize/2.

Examples

key = BTHome.generate_encryption_key()
mac = <<0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC>>

# Create encryption options
opts = BTHome.encryption_opts(key, mac, 1)

# Use with serialize/2
{:ok, binary} = BTHome.serialize(measurements, opts)

generate_encryption_key()

@spec generate_encryption_key() :: binary()

Generates a random encryption key for BTHome v2.

Returns

16-byte random encryption key suitable for AES-128.

Examples

key = BTHome.generate_encryption_key()
byte_size(key)  # => 16

key_from_hex(hex_string)

@spec key_from_hex(String.t()) :: {:ok, binary()} | {:error, String.t()}

Converts a hex string to an encryption key.

Parameters

  • hex_string - 32-character hex string

Returns

  • {:ok, key} - Successfully converted key
  • {:error, error} - Invalid hex string

Examples

{:ok, key} = BTHome.key_from_hex("231d39c1d7cc1ab1aee224cd096db932")

key_to_hex(key)

@spec key_to_hex(binary()) :: String.t()

Converts an encryption key to a hex string.

Parameters

  • key - 16-byte encryption key

Returns

Hex string representation of the key.

Examples

hex = BTHome.key_to_hex(key)

measurement(type, value, opts \\ [])

@spec measurement(atom(), number() | boolean(), keyword()) ::
  {:ok, BTHome.Measurement.t()} | {:error, String.t()}

Creates a validated measurement struct.

This is the recommended way to create measurements as it provides validation during creation and automatically looks up metadata like units.

Parameters

  • type - The measurement type atom (must be supported)
  • value - The measurement value (number for sensors, boolean for binary sensors)
  • opts - Optional keyword list with :unit and :object_id overrides

Returns

  • {:ok, measurement} - Successfully created and validated measurement
  • {:error, error} - Creation failed with error details

Examples

# Environmental sensor
iex> BTHome.measurement(:temperature, 23.45)
{:ok, %BTHome.Measurement{type: :temperature, value: 23.45, unit: "°C"}}

# Binary sensor
iex> BTHome.measurement(:motion, true)
{:ok, %BTHome.Measurement{type: :motion, value: true, unit: nil}}

# With custom unit
iex> BTHome.measurement(:temperature, 74.21, unit: "°F")
{:ok, %BTHome.Measurement{type: :temperature, value: 74.21, unit: "°F"}}

# Invalid type
iex> BTHome.measurement(:invalid, 42)
{:error, "Unsupported measurement type: :invalid"}

new_encrypted_packet(key, mac_address, counter)

@spec new_encrypted_packet(binary(), binary(), non_neg_integer()) ::
  {BTHome.Packet.t(), keyword()}

Creates a new encrypted packet builder.

This convenience function creates a packet builder that can be used with encryption. The encryption options are returned separately to be used with serialize/2.

Parameters

  • key - 16-byte encryption key
  • mac_address - 6-byte MAC address
  • counter - 4-byte counter value

Returns

Tuple with packet struct and encryption options.

Examples

key = BTHome.generate_encryption_key()
mac = <<0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC>>

{packet, opts} = BTHome.new_encrypted_packet(key, mac, 1)

{:ok, packet} = BTHome.add_measurement(packet, :temperature, 23.5)
{:ok, binary} = BTHome.serialize(packet, opts)

new_packet()

@spec new_packet() :: BTHome.Packet.t()

Creates a new packet builder for the fluent/builder pattern API.

This function starts a new packet builder that can be used with the pipe operator to chain measurement additions and final serialization.

Returns

A new packet builder struct.

Examples

# Basic builder pattern usage
{:ok, binary} = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.add_measurement(:motion, true)
|> BTHome.serialize()

# With encryption
{:ok, binary} = BTHome.new_packet()
|> BTHome.add_measurement(:temperature, 23.45)
|> BTHome.serialize(true)

quick_serialize_encrypted(measurements, key, mac_address, counter)

@spec quick_serialize_encrypted(
  [BTHome.Measurement.t() | map()],
  binary(),
  binary(),
  non_neg_integer()
) :: {:ok, binary()} | {:error, String.t()}

Quick serialization of measurements with encryption.

This convenience function provides a one-liner for serializing measurements with encryption, reducing boilerplate code for simple use cases.

Parameters

  • measurements - List of measurements (structs or maps)
  • key - 16-byte encryption key
  • mac_address - 6-byte MAC address
  • counter - 4-byte counter value

Returns

  • {:ok, binary} - Successfully serialized encrypted binary
  • {:error, error} - Serialization failed with error details

Examples

{:ok, temp} = BTHome.measurement(:temperature, 23.5)
{:ok, humidity} = BTHome.measurement(:humidity, 65.0)
measurements = [temp, humidity]

{:ok, binary} = BTHome.quick_serialize_encrypted(
  measurements, key, mac, 1
)

serialize(measurements_or_packet, opts \\ [])

@spec serialize([BTHome.Measurement.t()] | BTHome.Packet.t(), boolean() | keyword()) ::
  {:ok, binary()} | {:error, String.t()}

Serializes measurements into BTHome v2 binary format.

Parameters

  • measurements_or_packet - List of measurements or a Packet struct
  • opts - Serialization options (keyword list)
    • :encrypt - Encryption options (keyword list or map, default: false)
      • :key - 16-byte encryption key (required)
      • :mac_address - 6-byte MAC address (required)
      • :counter - 4-byte counter value (required)

Returns

{:ok, binary} on success, {:error, reason} on failure.

Examples

# Basic usage
iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> {:ok, binary} = BTHome.serialize([temp])
iex> is_binary(binary)
true

# With encryption (keyword list - recommended)
iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> key = <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>>
iex> mac = <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>
iex> opts = [encrypt: [key: key, mac_address: mac, counter: 1]]
iex> {:ok, binary} = BTHome.serialize([temp], opts)
iex> <<device_info, _rest::binary>> = binary
iex> Bitwise.band(device_info, 0x01) == 1
true

# With encryption (map format)
iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> key = <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>>
iex> mac = <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>
iex> opts = [encrypt: %{key: key, mac_address: mac, counter: 1}]
iex> {:ok, binary} = BTHome.serialize([temp], opts)
iex> is_binary(binary)
true

serialize!(measurements_or_packet, opts \\ [])

@spec serialize!([BTHome.Measurement.t()] | BTHome.Packet.t(), boolean() | keyword()) ::
  binary()

Serializes measurements or packet, raising on error.

Similar to serialize/2 but raises an exception instead of returning an error tuple. This is useful for pipeline operations where you want to fail fast on errors.

Parameters

  • measurements_or_packet - List of measurements or a Packet struct
  • opts - Serialization options (same as serialize/2)

Returns

Binary BTHome v2 data on success, raises on error.

Examples

# Basic usage
iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> binary = BTHome.serialize!([temp])
iex> is_binary(binary)
true

# With builder pattern
iex> binary = BTHome.new_packet()
iex> |> BTHome.add_measurement(:temperature, 23.45)
iex> |> BTHome.serialize!()
iex> is_binary(binary)
true

# With encryption
iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> key = <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>>
iex> mac = <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>
iex> opts = [encrypt: [key: key, mac_address: mac, counter: 1]]
iex> binary = BTHome.serialize!([temp], opts)
iex> is_binary(binary)
true

Raises

ArgumentError if serialization fails.

serialize_encrypted(packet, encryption_opts)

@spec serialize_encrypted(
  BTHome.Packet.t(),
  keyword()
) :: {:ok, binary()} | {:error, String.t()}

Serializes an encrypted packet to binary format.

This convenience function serializes a packet with the provided encryption options, providing a cleaner API for encrypted packets.

Parameters

  • packet - Packet struct
  • encryption_opts - Encryption options from encryption_opts/3

Returns

  • {:ok, binary} - Successfully serialized encrypted binary
  • {:error, error} - Serialization failed with error details

Examples

{packet, opts} = BTHome.new_encrypted_packet(key, mac, 1)
{:ok, packet} = BTHome.add_measurement(packet, :temperature, 23.5)

{:ok, binary} = BTHome.serialize_encrypted(packet, opts)

serialize_encrypted(measurements, key, mac_address, counter)

@spec serialize_encrypted(
  [BTHome.Measurement.t()],
  binary(),
  binary(),
  non_neg_integer()
) ::
  {:ok, binary()} | {:error, String.t()}

Serializes measurements with encryption.

This is a convenience function for encrypting BTHome v2 data with the specified encryption parameters.

Parameters

  • measurements - List of measurements to encrypt
  • key - 16-byte encryption key
  • mac_address - 6-byte MAC address
  • counter - 4-byte counter value

Returns

  • {:ok, binary} - Successfully encrypted BTHome v2 binary data
  • {:error, error} - Encryption failed with error details

Examples

key = :crypto.strong_rand_bytes(16)
mac = <<0x54, 0x48, 0xe6, 0x8f, 0x80, 0xa5>>
{:ok, temp} = BTHome.measurement(:temperature, 23.45)

{:ok, encrypted} = BTHome.serialize_encrypted([temp], key, mac, 1)

serialize_for_device(measurements, device_context)

@spec serialize_for_device([BTHome.Measurement.t()], map()) ::
  {:ok, binary()} | {:error, String.t()}

Serializes measurements for a specific device.

This convenience function integrates with device management by accepting a device context map containing encryption parameters.

Parameters

  • measurements - List of measurements (structs or maps)
  • device_context - Map containing device information

Returns

  • {:ok, binary} - Successfully serialized binary (encrypted if device has encryption)
  • {:error, error} - Serialization failed with error details

Examples

device = %{
  encryption_key: key,
  mac_address: mac,
  counter: 1
}

{:ok, temp} = BTHome.measurement(:temperature, 23.5)
{:ok, binary} = BTHome.serialize_for_device([temp], device)

supported_types()

@spec supported_types() :: map()

Returns all supported measurement types and their properties.

Provides a map of measurement types to their complete definitions including units, scaling factors, data sizes, and other metadata.

Returns

A map where keys are measurement type atoms and values are definition maps.

Examples

iex> types = BTHome.supported_types()
iex> Map.has_key?(types, :temperature)
true
iex> types[:temperature].unit
"°C"

validate_measurement(measurement)

@spec validate_measurement(BTHome.Measurement.t()) :: :ok | {:error, String.t()}

Validates a single measurement for serialization.

Checks that the measurement type is supported and the value is valid for that type (correct data type and within acceptable range).

Parameters

  • measurement - A measurement struct or map to validate

Returns

  • :ok - Measurement is valid
  • {:error, error} - Validation failed with error details

Examples

iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> BTHome.validate_measurement(temp)
:ok

iex> invalid = %BTHome.Measurement{type: :invalid, value: 42}
iex> BTHome.validate_measurement(invalid)
{:error, "Unsupported measurement type: :invalid"}

validate_measurements(measurements)

@spec validate_measurements([BTHome.Measurement.t()]) :: :ok | {:error, String.t()}

Validates a list of measurements for serialization.

Validates each measurement in the list and ensures the overall list is suitable for serialization.

Parameters

  • measurements - List of measurement structs or maps to validate

Returns

  • :ok - All measurements are valid
  • {:error, error} - Validation failed with error details

Examples

iex> {:ok, temp} = BTHome.measurement(:temperature, 23.45)
iex> {:ok, humidity} = BTHome.measurement(:humidity, 67.8)
iex> BTHome.validate_measurements([temp, humidity])
:ok

iex> invalid = %BTHome.Measurement{type: :invalid, value: 42}
iex> BTHome.validate_measurements([invalid])
{:error, "Measurement 0: Unsupported measurement type: :invalid"}